run_daily_service.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. #!/usr/bin/env python
  2. """Schedule daily creation and two-hour creative review scans."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import logging
  7. import os
  8. import subprocess
  9. import sys
  10. from datetime import datetime, timedelta
  11. from pathlib import Path
  12. from apscheduler.schedulers.blocking import BlockingScheduler
  13. from apscheduler.triggers.cron import CronTrigger
  14. from apscheduler.triggers.interval import IntervalTrigger
  15. from dotenv import load_dotenv
  16. HERE = Path(__file__).resolve().parent
  17. ROOT = HERE.parents[1]
  18. RTC_DIR = ROOT / "examples" / "tencent_realtime_control"
  19. for path in (ROOT, HERE, RTC_DIR):
  20. if str(path) not in sys.path:
  21. sys.path.insert(0, str(path))
  22. load_dotenv(HERE / ".env", override=False)
  23. load_dotenv(Path.cwd() / ".env", override=False)
  24. from config import TIME_SERIES_DEFAULT # noqa: E402
  25. # config import 会调整 sys.path;恢复本服务目录优先级,避免 im-client/tools.py
  26. # 遮蔽 auto_put_ad_mini/tools 包。
  27. while str(HERE) in sys.path:
  28. sys.path.remove(str(HERE))
  29. sys.path.insert(0, str(HERE))
  30. from db.connection import get_connection # noqa: E402
  31. from storage import advisory_lock, initialize_schema # noqa: E402
  32. from roi_control.config import AgencyWebhookConfig, RoiConfig # noqa: E402
  33. from roi_control.data_source import ( # noqa: E402
  34. SHANGHAI,
  35. SourceDataNotReadyError,
  36. validate_source_ready,
  37. )
  38. from roi_control.feishu import RoiFeishuPublisher # noqa: E402
  39. from roi_control.odps_client import ODPSClient # noqa: E402
  40. from roi_control.repository import load_formal_run_for_end_date # noqa: E402
  41. from tools.delivery_config import validate_region_dictionary # noqa: E402
  42. logger = logging.getLogger("auto_put_ad_mini.daily_service")
  43. def _env_flag(name: str, default: bool) -> bool:
  44. raw = os.getenv(name)
  45. if raw is None:
  46. return default
  47. return raw.strip().lower() in {"1", "true", "yes", "on"}
  48. def _notify_roi_failure(
  49. *,
  50. extra_args: list[str] | None,
  51. error: str,
  52. ) -> None:
  53. if "--internal-test" in (extra_args or []):
  54. mode = "内部15:30测试"
  55. else:
  56. report_hour = int(os.getenv("DAILY_ROI_HOUR", "11"))
  57. report_minute = int(os.getenv("DAILY_ROI_MINUTE", "0"))
  58. mode = f"正式{report_hour:02d}:{report_minute:02d}起轮询任务"
  59. publisher: RoiFeishuPublisher | None = None
  60. try:
  61. publisher = RoiFeishuPublisher(require_chat_ids=False)
  62. publisher.send_service_alert(
  63. title="日级ROI任务失败",
  64. content=(
  65. f"任务:**{mode}**\n"
  66. f"错误:`{error[:500]}`\n"
  67. "本次已停止,不会生成或发送不完整报表。"
  68. ),
  69. )
  70. except Exception as exc:
  71. logger.error("Failed to send ROI failure alert: %s", exc)
  72. finally:
  73. if publisher is not None:
  74. publisher.close()
  75. def _notify_roi_source_cutoff(
  76. *,
  77. target_date: str,
  78. cutoff_time: str,
  79. error: str,
  80. ) -> None:
  81. publisher: RoiFeishuPublisher | None = None
  82. try:
  83. publisher = RoiFeishuPublisher(require_chat_ids=False)
  84. publisher.send_service_alert(
  85. title="日级ROI数据截止仍未就绪",
  86. content=(
  87. f"目标数据日期:**{target_date}**\n"
  88. f"截止时间:**{cutoff_time}**\n"
  89. f"原因:`{error[:500]}`\n"
  90. "当天已停止自动轮询,不会使用旧分区,也不会生成或补发ROI报表。"
  91. ),
  92. )
  93. except Exception as exc:
  94. logger.error("Failed to send ROI source cutoff alert: %s", exc)
  95. finally:
  96. if publisher is not None:
  97. publisher.close()
  98. def sync_enabled_delivery_templates() -> int:
  99. """Keep enabled DB templates aligned with the code's global delivery window."""
  100. serialized = json.dumps(TIME_SERIES_DEFAULT)
  101. connection = get_connection()
  102. try:
  103. with connection.cursor() as cursor:
  104. return cursor.execute(
  105. """
  106. UPDATE ad_delivery_template
  107. SET time_series_json=%s,
  108. updated_by='ad_daily_service'
  109. WHERE enabled=TRUE
  110. AND time_series_json<>%s
  111. """,
  112. (serialized, serialized),
  113. )
  114. finally:
  115. connection.close()
  116. def _run_script(
  117. script_name: str,
  118. lock_name: str,
  119. extra_args: list[str] | None = None,
  120. ) -> None:
  121. with advisory_lock(lock_name) as acquired:
  122. if not acquired:
  123. logger.warning("Skip %s: another instance holds %s", script_name, lock_name)
  124. return
  125. logger.info("Starting %s", script_name)
  126. try:
  127. completed = subprocess.run(
  128. [sys.executable, str(HERE / script_name), *(extra_args or [])],
  129. cwd=HERE,
  130. check=False,
  131. )
  132. except Exception as exc:
  133. if script_name == "run_daily_roi.py":
  134. _notify_roi_failure(extra_args=extra_args, error=str(exc))
  135. raise
  136. if completed.returncode:
  137. if script_name == "run_daily_roi.py":
  138. _notify_roi_failure(
  139. extra_args=extra_args,
  140. error=f"process exited with code {completed.returncode}",
  141. )
  142. raise RuntimeError(
  143. f"{script_name} exited with code {completed.returncode}"
  144. )
  145. logger.info("Finished %s", script_name)
  146. def run_creation() -> None:
  147. _run_script(
  148. "execute_creation_once.py",
  149. os.getenv("DAILY_CREATION_LOCK_NAME", "ad_daily_creation"),
  150. )
  151. def run_creation_once(
  152. *,
  153. account_ids: list[int],
  154. config_date: str | None = None,
  155. ) -> None:
  156. """Run one locked creation pass for explicitly selected eligible accounts."""
  157. extra_args: list[str] = []
  158. if config_date:
  159. extra_args.extend(["--config-date", config_date])
  160. for account_id in account_ids:
  161. extra_args.extend(["--account-id", str(account_id)])
  162. _run_script(
  163. "execute_creation_once.py",
  164. os.getenv("DAILY_CREATION_LOCK_NAME", "ad_daily_creation"),
  165. extra_args,
  166. )
  167. def run_creative_review() -> None:
  168. _run_script(
  169. "scan_creative_reviews.py",
  170. os.getenv("DAILY_REVIEW_LOCK_NAME", "ad_creative_review_scan"),
  171. )
  172. def run_daily_roi() -> None:
  173. _run_script(
  174. "run_daily_roi.py",
  175. os.getenv("DAILY_ROI_LOCK_NAME", "ad_daily_roi"),
  176. ["--send-feishu"],
  177. )
  178. def _effective_shanghai_now(now: datetime | None = None) -> datetime:
  179. current = now or datetime.now(SHANGHAI)
  180. if current.tzinfo is None:
  181. current = current.replace(tzinfo=SHANGHAI)
  182. return current.astimezone(SHANGHAI)
  183. def _daily_roi_times(
  184. config: RoiConfig,
  185. now: datetime,
  186. ) -> tuple[datetime, datetime]:
  187. start = now.replace(
  188. hour=config.report_hour,
  189. minute=config.report_minute,
  190. second=0,
  191. microsecond=0,
  192. )
  193. cutoff = now.replace(
  194. hour=config.daily_poll_cutoff_hour,
  195. minute=config.daily_poll_cutoff_minute,
  196. second=0,
  197. microsecond=0,
  198. )
  199. return start, cutoff
  200. def _roi_poll_minutes(config: RoiConfig) -> str:
  201. values = {
  202. (config.report_minute + offset) % 60
  203. for offset in range(0, 60, config.daily_poll_interval_minutes)
  204. }
  205. return ",".join(str(value) for value in sorted(values))
  206. def _attempt_daily_roi_when_ready(
  207. config: RoiConfig,
  208. *,
  209. now: datetime,
  210. at_cutoff: bool,
  211. ) -> str:
  212. target = (now - timedelta(days=1)).date()
  213. existing = load_formal_run_for_end_date(target)
  214. if existing:
  215. logger.info(
  216. "Skip daily ROI target=%s: formal run already exists run=%s status=%s",
  217. target.strftime("%Y%m%d"),
  218. existing.get("run_id"),
  219. existing.get("status"),
  220. )
  221. return "ALREADY_RAN"
  222. target_date = target.strftime("%Y%m%d")
  223. client = ODPSClient(project=os.getenv("ODPS_PROJECT", "loghubods"))
  224. try:
  225. counts = validate_source_ready(client, target_date)
  226. except SourceDataNotReadyError as exc:
  227. if at_cutoff:
  228. logger.error(
  229. "Daily ROI source missed cutoff target=%s: %s",
  230. target_date,
  231. exc,
  232. )
  233. _notify_roi_source_cutoff(
  234. target_date=target_date,
  235. cutoff_time=(
  236. f"{config.daily_poll_cutoff_hour:02d}:"
  237. f"{config.daily_poll_cutoff_minute:02d}"
  238. ),
  239. error=str(exc),
  240. )
  241. return "CUTOFF_NOT_READY"
  242. logger.info("Daily ROI source not ready target=%s: %s", target_date, exc)
  243. return "WAITING_SOURCE"
  244. logger.info("Daily ROI source ready target=%s counts=%s", target_date, counts)
  245. run_daily_roi()
  246. return "TRIGGERED"
  247. def poll_daily_roi(
  248. config: RoiConfig,
  249. *,
  250. now: datetime | None = None,
  251. ) -> str:
  252. current = _effective_shanghai_now(now)
  253. start, cutoff = _daily_roi_times(config, current)
  254. if current < start or current >= cutoff:
  255. return "OUTSIDE_WINDOW"
  256. return _attempt_daily_roi_when_ready(config, now=current, at_cutoff=False)
  257. def finalize_daily_roi_at_cutoff(
  258. config: RoiConfig,
  259. *,
  260. now: datetime | None = None,
  261. ) -> str:
  262. current = _effective_shanghai_now(now)
  263. return _attempt_daily_roi_when_ready(config, now=current, at_cutoff=True)
  264. def run_internal_roi_test() -> None:
  265. _run_script(
  266. "run_daily_roi.py",
  267. os.getenv("DAILY_ROI_INTERNAL_TEST_LOCK_NAME", "ad_daily_roi"),
  268. [
  269. "--internal-test",
  270. "--output-dir",
  271. str(HERE / "outputs" / "roi_control" / "internal_test"),
  272. ],
  273. )
  274. def _positive_account_id(raw: str) -> int:
  275. try:
  276. account_id = int(raw)
  277. except ValueError as exc:
  278. raise argparse.ArgumentTypeError(f"账户 ID 必须是正整数: {raw!r}") from exc
  279. if account_id <= 0:
  280. raise argparse.ArgumentTypeError(f"账户 ID 必须是正整数: {raw!r}")
  281. return account_id
  282. def main(argv: list[str] | None = None) -> None:
  283. parser = argparse.ArgumentParser(description="广告日级生产调度服务")
  284. parser.add_argument(
  285. "--run-creation-once",
  286. action="store_true",
  287. help="使用生产数据库锁执行一次广告/创意创建后退出",
  288. )
  289. parser.add_argument(
  290. "--account-id",
  291. action="append",
  292. type=_positive_account_id,
  293. dest="account_ids",
  294. help="一次性创建限定账户;必须与 --run-creation-once 一起使用,可重复",
  295. )
  296. parser.add_argument(
  297. "--config-date",
  298. help="历史复现时限定飞书配置日期;默认每账户使用日期最新的一行",
  299. )
  300. args = parser.parse_args(argv)
  301. if args.run_creation_once and not args.account_ids:
  302. parser.error("--run-creation-once 至少需要一个 --account-id")
  303. if not args.run_creation_once and (args.account_ids or args.config_date):
  304. parser.error("--account-id/--config-date 只能与 --run-creation-once 一起使用")
  305. roi_config = RoiConfig.from_env()
  306. if roi_config.internal_test_enabled:
  307. agency_config = AgencyWebhookConfig.from_env()
  308. if not (agency_config.webhooks or {}).get("内部"):
  309. raise ValueError("ROI internal test requires webhook route: 内部")
  310. region_path = validate_region_dictionary()
  311. logger.info("Validated Tencent region dictionary: %s", region_path)
  312. initialize_schema()
  313. if args.run_creation_once:
  314. run_creation_once(
  315. account_ids=args.account_ids,
  316. config_date=args.config_date,
  317. )
  318. return
  319. if _env_flag("DAILY_SYNC_DELIVERY_TEMPLATE", False):
  320. changed = sync_enabled_delivery_templates()
  321. logger.info("Synchronized %d enabled delivery template(s)", changed)
  322. scheduler = BlockingScheduler(timezone="Asia/Shanghai")
  323. if _env_flag("DAILY_CREATION_ENABLED", False):
  324. creation_hour = int(os.getenv("DAILY_CREATION_HOUR", "10"))
  325. creation_minute = int(os.getenv("DAILY_CREATION_MINUTE", "30"))
  326. scheduler.add_job(
  327. run_creation,
  328. CronTrigger(
  329. hour=creation_hour,
  330. minute=creation_minute,
  331. timezone="Asia/Shanghai",
  332. ),
  333. id="daily_creation",
  334. name="广告与创意创建",
  335. max_instances=1,
  336. coalesce=True,
  337. misfire_grace_time=int(
  338. os.getenv("DAILY_CREATION_MISFIRE_GRACE_SECONDS", "3600")
  339. ),
  340. )
  341. if _env_flag("DAILY_REVIEW_ENABLED", False):
  342. scheduler.add_job(
  343. run_creative_review,
  344. IntervalTrigger(
  345. hours=int(os.getenv("DAILY_REVIEW_INTERVAL_HOURS", "2")),
  346. timezone="Asia/Shanghai",
  347. ),
  348. id="creative_review_scan",
  349. name="腾讯创意审核扫描",
  350. max_instances=1,
  351. coalesce=True,
  352. misfire_grace_time=1800,
  353. )
  354. if roi_config.daily_enabled:
  355. logger.info(
  356. "Daily ROI polling configured start=%02d:%02d interval=%dm cutoff=%02d:%02d",
  357. roi_config.report_hour,
  358. roi_config.report_minute,
  359. roi_config.daily_poll_interval_minutes,
  360. roi_config.daily_poll_cutoff_hour,
  361. roi_config.daily_poll_cutoff_minute,
  362. )
  363. scheduler.add_job(
  364. poll_daily_roi,
  365. CronTrigger(
  366. minute=_roi_poll_minutes(roi_config),
  367. timezone="Asia/Shanghai",
  368. ),
  369. id="daily_roi",
  370. name="日级ROI数据就绪轮询",
  371. args=[roi_config],
  372. max_instances=1,
  373. coalesce=True,
  374. misfire_grace_time=int(
  375. os.getenv("DAILY_ROI_MISFIRE_GRACE_SECONDS", "3600")
  376. ),
  377. )
  378. scheduler.add_job(
  379. finalize_daily_roi_at_cutoff,
  380. CronTrigger(
  381. hour=roi_config.daily_poll_cutoff_hour,
  382. minute=roi_config.daily_poll_cutoff_minute,
  383. timezone="Asia/Shanghai",
  384. ),
  385. id="daily_roi_cutoff",
  386. name="日级ROI数据就绪截止检查",
  387. args=[roi_config],
  388. max_instances=1,
  389. coalesce=True,
  390. misfire_grace_time=300,
  391. )
  392. if roi_config.internal_test_enabled:
  393. scheduler.add_job(
  394. run_internal_roi_test,
  395. CronTrigger(
  396. hour=roi_config.internal_test_hour,
  397. minute=roi_config.internal_test_minute,
  398. timezone="Asia/Shanghai",
  399. ),
  400. id="daily_roi_internal_test",
  401. name="日级ROI内部端到端测试",
  402. max_instances=1,
  403. coalesce=True,
  404. misfire_grace_time=int(
  405. os.getenv("ROI_INTERNAL_TEST_MISFIRE_GRACE_SECONDS", "3600")
  406. ),
  407. )
  408. if _env_flag("DAILY_RUN_ON_STARTUP", False):
  409. scheduler.add_job(
  410. run_creation,
  411. id="daily_creation_startup",
  412. name="启动时创建检查",
  413. )
  414. if _env_flag("DAILY_REVIEW_RUN_ON_STARTUP", False):
  415. scheduler.add_job(
  416. run_creative_review,
  417. id="creative_review_startup",
  418. name="启动时审核扫描",
  419. )
  420. if roi_config.daily_enabled and _env_flag("DAILY_ROI_RUN_ON_STARTUP", False):
  421. scheduler.add_job(
  422. poll_daily_roi,
  423. id="daily_roi_startup",
  424. name="启动时ROI数据就绪检查",
  425. args=[roi_config],
  426. )
  427. logger.info(
  428. "Daily service started jobs=%s",
  429. [job.id for job in scheduler.get_jobs()],
  430. )
  431. scheduler.start()
  432. if __name__ == "__main__":
  433. logging.basicConfig(
  434. level=os.getenv("LOG_LEVEL", "INFO"),
  435. format="%(asctime)s %(levelname)s %(name)s %(message)s",
  436. )
  437. main()