creative_review.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. """Tencent dynamic creative review result scanner.
  2. This module tracks creatives submitted by the creation pipeline and polls
  3. Tencent's official review-result API. It does not perform prereview.
  4. """
  5. from __future__ import annotations
  6. import json
  7. import logging
  8. from dataclasses import dataclass, field
  9. from datetime import datetime, timezone
  10. from typing import Iterable, Iterator, Optional
  11. logger = logging.getLogger(__name__)
  12. CREATE_REVIEW_TABLES_SQL = [
  13. """
  14. CREATE TABLE IF NOT EXISTS creative_creation_task (
  15. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  16. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  17. adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
  18. dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID',
  19. dynamic_creative_name VARCHAR(200) DEFAULT NULL COMMENT '动态创意名称',
  20. landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID',
  21. material_id VARCHAR(100) DEFAULT NULL COMMENT '内部素材ID',
  22. material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
  23. review_status VARCHAR(50) NOT NULL DEFAULT 'submitted' COMMENT '审核状态',
  24. submit_status VARCHAR(50) NOT NULL DEFAULT 'submitted' COMMENT '提交状态',
  25. submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间',
  26. last_review_checked_at TIMESTAMP NULL DEFAULT NULL COMMENT '最近审核扫描时间',
  27. review_finished_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核完成时间',
  28. error TEXT DEFAULT NULL COMMENT '提交或扫描错误',
  29. raw_record MEDIUMTEXT DEFAULT NULL COMMENT '创建记录JSON',
  30. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  31. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  32. UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id),
  33. KEY idx_review_status (review_status),
  34. KEY idx_submitted_at (submitted_at),
  35. KEY idx_landing_submitted (landing_video_id, submitted_at),
  36. KEY idx_last_review_checked_at (last_review_checked_at),
  37. KEY idx_account_status (account_id, review_status)
  38. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意创建与审核扫描任务'
  39. """,
  40. """
  41. CREATE TABLE IF NOT EXISTS creative_review_result (
  42. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  43. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  44. dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID',
  45. review_status VARCHAR(50) NOT NULL COMMENT '解析后的审核状态',
  46. reject_messages MEDIUMTEXT DEFAULT NULL COMMENT '拒绝原因JSON数组',
  47. delay_messages MEDIUMTEXT DEFAULT NULL COMMENT '延迟审核原因JSON数组',
  48. raw_result MEDIUMTEXT NOT NULL COMMENT '腾讯审核结果原文JSON',
  49. checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '扫描时间',
  50. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  51. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  52. UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id),
  53. KEY idx_review_status (review_status),
  54. KEY idx_checked_at (checked_at)
  55. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯动态创意审核结果'
  56. """,
  57. """
  58. CREATE TABLE IF NOT EXISTS creative_rejection_fact (
  59. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  60. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  61. dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID',
  62. fact_type VARCHAR(50) NOT NULL COMMENT '拒绝事实类型:site/element/compose',
  63. fact_key VARCHAR(255) DEFAULT NULL COMMENT '元素/版位/组件标识',
  64. reason TEXT NOT NULL COMMENT '拒绝原因',
  65. site_set VARCHAR(100) DEFAULT NULL COMMENT '影响版位',
  66. element_type VARCHAR(100) DEFAULT NULL COMMENT '元素类型',
  67. component_type VARCHAR(100) DEFAULT NULL COMMENT '组件类型',
  68. raw_detail MEDIUMTEXT DEFAULT NULL COMMENT '原始明细JSON',
  69. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  70. KEY idx_account_creative (account_id, dynamic_creative_id),
  71. KEY idx_fact_type (fact_type),
  72. KEY idx_fact_key (fact_key(100))
  73. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意审核拒绝事实'
  74. """,
  75. ]
  76. FINAL_REVIEW_STATUSES = {"approved", "rejected"}
  77. PENDING_REVIEW_STATUSES = {"submitted", "pending", "unknown"}
  78. @dataclass
  79. class RejectionFact:
  80. fact_type: str
  81. reason: str
  82. fact_key: str = ""
  83. site_set: str = ""
  84. element_type: str = ""
  85. component_type: str = ""
  86. raw_detail: dict = field(default_factory=dict)
  87. @dataclass
  88. class ParsedReviewResult:
  89. dynamic_creative_id: Optional[int]
  90. review_status: str
  91. reject_messages: list[str] = field(default_factory=list)
  92. delay_messages: list[str] = field(default_factory=list)
  93. rejection_facts: list[RejectionFact] = field(default_factory=list)
  94. def _dedupe(values: Iterable[str]) -> list[str]:
  95. """字符串列表去重,保留首次出现顺序,跳过空字符串。"""
  96. out: list[str] = []
  97. seen: set[str] = set()
  98. for raw in values:
  99. value = str(raw or "").strip()
  100. if not value or value in seen:
  101. continue
  102. seen.add(value)
  103. out.append(value)
  104. return out
  105. def _status_is_rejected(status: str) -> bool:
  106. """判断审核状态是否为拒绝(REJECT 或 DENIED)。"""
  107. upper = (status or "").upper()
  108. return "REJECT" in upper or "DENIED" in upper
  109. def _status_is_pending(status: str) -> bool:
  110. """判断审核状态是否为待审(PENDING 或 REVIEWING)。"""
  111. upper = (status or "").upper()
  112. return "PENDING" in upper or "REVIEWING" in upper
  113. def _status_is_approved(status: str) -> bool:
  114. """判断审核状态是否为通过(PASS 或 APPROVED 或 NORMAL)。"""
  115. upper = (status or "").upper()
  116. return "PASS" in upper or "APPROVED" in upper or "NORMAL" in upper
  117. def _component_type(detail: dict) -> str:
  118. """从审核明细中提取组件类型(component_type)。"""
  119. component = detail.get("component_info") or {}
  120. if isinstance(component, dict):
  121. return str(component.get("component_type") or "")
  122. return ""
  123. def _collect_element_facts(result: dict) -> tuple[list[str], list[RejectionFact], bool, bool]:
  124. """从审核结果中提取元素级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表, 是否有待审, 是否有通过)。"""
  125. messages: list[str] = []
  126. facts: list[RejectionFact] = []
  127. has_pending = False
  128. has_approved = False
  129. for element in result.get("element_result_list") or []:
  130. if not isinstance(element, dict):
  131. continue
  132. status = str(element.get("review_status") or "")
  133. has_pending = has_pending or _status_is_pending(status)
  134. has_approved = has_approved or _status_is_approved(status)
  135. if not _status_is_rejected(status):
  136. continue
  137. details = element.get("element_reject_detail_info") or []
  138. if not details:
  139. details = [{"reason": element.get("reason") or ""}]
  140. for detail in details:
  141. if not isinstance(detail, dict):
  142. continue
  143. reason = detail.get("reason") or element.get("reason") or ""
  144. if reason:
  145. messages.append(str(reason))
  146. facts.append(RejectionFact(
  147. fact_type="element",
  148. fact_key=str(
  149. element.get("image_id")
  150. or element.get("video_id")
  151. or element.get("element_name")
  152. or ""
  153. ),
  154. reason=str(reason),
  155. element_type=str(element.get("element_type") or ""),
  156. component_type=_component_type(element),
  157. raw_detail={**element, "element_reject_detail": detail},
  158. ))
  159. return messages, facts, has_pending, has_approved
  160. def _collect_site_facts(result: dict) -> tuple[list[str], list[RejectionFact], bool, bool]:
  161. """从审核结果中提取版位级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表, 是否有待审, 是否有通过)。"""
  162. messages: list[str] = []
  163. facts: list[RejectionFact] = []
  164. has_pending = False
  165. has_approved = False
  166. for site in result.get("site_set_result_list") or []:
  167. if not isinstance(site, dict):
  168. continue
  169. status = str(site.get("system_status") or site.get("review_status") or "")
  170. has_pending = has_pending or _status_is_pending(status)
  171. has_approved = has_approved or _status_is_approved(status)
  172. reject_message = site.get("reject_message") or ""
  173. if _status_is_rejected(status) or reject_message:
  174. if reject_message:
  175. messages.append(str(reject_message))
  176. facts.append(RejectionFact(
  177. fact_type="site",
  178. fact_key=str(site.get("site_set") or ""),
  179. site_set=str(site.get("site_set") or ""),
  180. reason=str(reject_message),
  181. raw_detail=site,
  182. ))
  183. for detail in site.get("element_reject_detail_info") or []:
  184. if not isinstance(detail, dict):
  185. continue
  186. reason = detail.get("reason") or ""
  187. if reason:
  188. messages.append(str(reason))
  189. facts.append(RejectionFact(
  190. fact_type="element",
  191. fact_key=str(detail.get("element_name") or ""),
  192. site_set=str(site.get("site_set") or ""),
  193. reason=str(reason),
  194. element_type=str(detail.get("element_type") or ""),
  195. component_type=_component_type(detail),
  196. raw_detail={**detail, "site_set": site.get("site_set")},
  197. ))
  198. return messages, facts, has_pending, has_approved
  199. def _collect_compose_facts(result: dict) -> tuple[list[str], list[RejectionFact]]:
  200. """从审核结果中提取组件组合级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表)。"""
  201. messages: list[str] = []
  202. facts: list[RejectionFact] = []
  203. for item in result.get("reject_component_compose_info_list") or []:
  204. if not isinstance(item, dict):
  205. continue
  206. reason = item.get("reject_message") or ""
  207. if reason:
  208. messages.append(str(reason))
  209. facts.append(RejectionFact(
  210. fact_type="compose",
  211. fact_key="component_compose",
  212. reason=str(reason),
  213. raw_detail=item,
  214. ))
  215. return messages, facts
  216. def parse_review_result(result: dict) -> ParsedReviewResult:
  217. """Parse Tencent dynamic creative review result into stable internal status."""
  218. reject_messages: list[str] = []
  219. delay_messages = [str(v) for v in (result.get("delay_message_list") or []) if v]
  220. facts: list[RejectionFact] = []
  221. has_pending = bool(result.get("is_all_component_compose_pending"))
  222. has_approved = False
  223. reject_messages.extend(str(v) for v in (result.get("reject_message_list") or []) if v)
  224. if result.get("reject_component_compose_count"):
  225. reject_messages.append("组件组合审核拒绝")
  226. if result.get("pass_component_compose_count"):
  227. has_approved = True
  228. if result.get("total_component_compose_count") and result.get("reject_component_compose_count") == 0:
  229. has_approved = True
  230. for collector in (_collect_site_facts, _collect_element_facts):
  231. messages, new_facts, pending, approved = collector(result)
  232. reject_messages.extend(messages)
  233. facts.extend(new_facts)
  234. has_pending = has_pending or pending
  235. has_approved = has_approved or approved
  236. messages, new_facts = _collect_compose_facts(result)
  237. reject_messages.extend(messages)
  238. facts.extend(new_facts)
  239. reject_messages = _dedupe(reject_messages)
  240. delay_messages = _dedupe(delay_messages)
  241. if reject_messages or facts or int(result.get("reject_component_compose_count") or 0) > 0:
  242. status = "rejected"
  243. elif has_pending or delay_messages:
  244. status = "pending"
  245. elif has_approved:
  246. status = "approved"
  247. else:
  248. status = "unknown"
  249. dynamic_creative_id = result.get("dynamic_creative_id")
  250. try:
  251. dynamic_creative_id = int(dynamic_creative_id) if dynamic_creative_id is not None else None
  252. except (TypeError, ValueError):
  253. dynamic_creative_id = None
  254. return ParsedReviewResult(
  255. dynamic_creative_id=dynamic_creative_id,
  256. review_status=status,
  257. reject_messages=reject_messages,
  258. delay_messages=delay_messages,
  259. rejection_facts=facts,
  260. )
  261. def group_review_tasks(
  262. tasks: Iterable[dict],
  263. batch_size: int = 100,
  264. ) -> Iterator[tuple[int, list[int]]]:
  265. """Yield (account_id, dynamic_creative_ids) batches for Tencent API."""
  266. by_account: dict[int, list[int]] = {}
  267. for task in tasks:
  268. try:
  269. account_id = int(task["account_id"])
  270. creative_id = int(task["dynamic_creative_id"])
  271. except (KeyError, TypeError, ValueError):
  272. continue
  273. by_account.setdefault(account_id, []).append(creative_id)
  274. for account_id in sorted(by_account):
  275. ids = by_account[account_id]
  276. for i in range(0, len(ids), batch_size):
  277. yield account_id, ids[i:i + batch_size]
  278. def ensure_review_tables() -> None:
  279. """确保审核相关的数据库表(creative_creation_task / creative_review_result / creative_rejection_fact)已创建。"""
  280. from db.connection import get_connection
  281. conn = get_connection()
  282. try:
  283. with conn.cursor() as cur:
  284. for sql in CREATE_REVIEW_TABLES_SQL:
  285. cur.execute(sql)
  286. finally:
  287. conn.close()
  288. def record_creation_submission(record: dict, dynamic_creative_id: int) -> None:
  289. """Persist a successfully submitted creative as pending review scan."""
  290. ensure_review_tables()
  291. from db.connection import get_connection
  292. body = record.get("_request_body") or {}
  293. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  294. conn = get_connection()
  295. try:
  296. with conn.cursor() as cur:
  297. cur.execute(
  298. """
  299. INSERT INTO creative_creation_task
  300. (account_id, adgroup_id, dynamic_creative_id, dynamic_creative_name,
  301. landing_video_id, material_id, material_image_id,
  302. review_status, submit_status, submitted_at, raw_record)
  303. VALUES (%s, %s, %s, %s, %s, %s, %s, 'submitted', 'submitted', NOW(), %s)
  304. ON DUPLICATE KEY UPDATE
  305. adgroup_id=VALUES(adgroup_id),
  306. dynamic_creative_name=VALUES(dynamic_creative_name),
  307. landing_video_id=VALUES(landing_video_id),
  308. material_id=VALUES(material_id),
  309. material_image_id=VALUES(material_image_id),
  310. submit_status='submitted',
  311. review_status=IF(review_status IN ('approved','rejected'), review_status, 'submitted'),
  312. raw_record=VALUES(raw_record),
  313. updated_at=CURRENT_TIMESTAMP
  314. """,
  315. (
  316. int(record["account_id"]),
  317. int(record.get("adgroup_id") or body.get("adgroup_id") or 0) or None,
  318. int(dynamic_creative_id),
  319. record.get("creative_name") or body.get("dynamic_creative_name"),
  320. record.get("landing_video_id"),
  321. str(record.get("_material_id") or ""),
  322. str(record.get("_material_image_id") or ""),
  323. raw_record,
  324. ),
  325. )
  326. finally:
  327. conn.close()
  328. def mark_creation_submit_failed(record: dict, error: str) -> None:
  329. """Persist submit failure for observability."""
  330. ensure_review_tables()
  331. from db.connection import get_connection
  332. body = record.get("_request_body") or {}
  333. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  334. conn = get_connection()
  335. try:
  336. with conn.cursor() as cur:
  337. cur.execute(
  338. """
  339. INSERT INTO creative_creation_task
  340. (account_id, adgroup_id, dynamic_creative_id, dynamic_creative_name,
  341. landing_video_id, material_id, material_image_id,
  342. review_status, submit_status, error, raw_record)
  343. VALUES (%s, %s, 0, %s, %s, %s, %s, 'submit_failed', 'failed', %s, %s)
  344. """,
  345. (
  346. int(record["account_id"]),
  347. int(record.get("adgroup_id") or body.get("adgroup_id") or 0) or None,
  348. record.get("creative_name") or body.get("dynamic_creative_name"),
  349. record.get("landing_video_id"),
  350. str(record.get("_material_id") or ""),
  351. str(record.get("_material_image_id") or ""),
  352. error[:2000],
  353. raw_record,
  354. ),
  355. )
  356. except Exception as e:
  357. logger.warning("[creative_review] 记录提交失败状态异常:%s", e)
  358. finally:
  359. conn.close()
  360. def load_pending_review_tasks(lookback_hours: int = 72, limit: int = 1000) -> list[dict]:
  361. """从数据库中加载待审核的创意任务列表(最近 N 小时内提交且状态为 submitted/pending/unknown)。"""
  362. ensure_review_tables()
  363. from db.connection import get_connection
  364. conn = get_connection()
  365. try:
  366. with conn.cursor() as cur:
  367. cur.execute(
  368. """
  369. SELECT account_id, dynamic_creative_id
  370. FROM creative_creation_task
  371. WHERE dynamic_creative_id > 0
  372. AND review_status IN ('submitted','pending','unknown')
  373. AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s HOUR)
  374. ORDER BY COALESCE(last_review_checked_at, submitted_at) ASC
  375. LIMIT %s
  376. """,
  377. (int(lookback_hours), int(limit)),
  378. )
  379. return list(cur.fetchall())
  380. finally:
  381. conn.close()
  382. def fetch_dynamic_creative_review_results(
  383. account_id: int,
  384. creative_ids: list[int],
  385. ) -> list[dict]:
  386. """调腾讯 API 批量查询动态创意的审核结果。"""
  387. if not creative_ids:
  388. return []
  389. from tools.ad_api import _check, _get
  390. resp = _get(
  391. "/dynamic_creative_review_results/get",
  392. {
  393. "account_id": account_id,
  394. "dynamic_creative_id_list": [int(v) for v in creative_ids],
  395. },
  396. )
  397. data = _check(resp, "dynamic_creative_review_results/get")
  398. return data.get("list") or []
  399. def save_review_result(account_id: int, raw_result: dict) -> ParsedReviewResult:
  400. """解析并持久化一条创意的审核结果,同步更新 creation_task 状态与 rejection_fact 明细。"""
  401. ensure_review_tables()
  402. parsed = parse_review_result(raw_result)
  403. if parsed.dynamic_creative_id is None:
  404. raise ValueError("审核结果缺 dynamic_creative_id")
  405. raw_json = json.dumps(raw_result, ensure_ascii=False, default=str)
  406. reject_json = json.dumps(parsed.reject_messages, ensure_ascii=False)
  407. delay_json = json.dumps(parsed.delay_messages, ensure_ascii=False)
  408. finished_at_sql = "NOW()" if parsed.review_status in FINAL_REVIEW_STATUSES else "NULL"
  409. from db.connection import get_connection
  410. conn = get_connection()
  411. try:
  412. with conn.cursor() as cur:
  413. cur.execute(
  414. """
  415. INSERT INTO creative_review_result
  416. (account_id, dynamic_creative_id, review_status, reject_messages, delay_messages, raw_result, checked_at)
  417. VALUES (%s, %s, %s, %s, %s, %s, NOW())
  418. ON DUPLICATE KEY UPDATE
  419. review_status=VALUES(review_status),
  420. reject_messages=VALUES(reject_messages),
  421. delay_messages=VALUES(delay_messages),
  422. raw_result=VALUES(raw_result),
  423. checked_at=NOW(),
  424. updated_at=CURRENT_TIMESTAMP
  425. """,
  426. (
  427. int(account_id),
  428. int(parsed.dynamic_creative_id),
  429. parsed.review_status,
  430. reject_json,
  431. delay_json,
  432. raw_json,
  433. ),
  434. )
  435. cur.execute(
  436. f"""
  437. UPDATE creative_creation_task
  438. SET review_status=%s,
  439. last_review_checked_at=NOW(),
  440. review_finished_at={finished_at_sql},
  441. updated_at=CURRENT_TIMESTAMP
  442. WHERE account_id=%s AND dynamic_creative_id=%s
  443. """,
  444. (parsed.review_status, int(account_id), int(parsed.dynamic_creative_id)),
  445. )
  446. cur.execute(
  447. """
  448. DELETE FROM creative_rejection_fact
  449. WHERE account_id=%s AND dynamic_creative_id=%s
  450. """,
  451. (int(account_id), int(parsed.dynamic_creative_id)),
  452. )
  453. if parsed.rejection_facts:
  454. cur.executemany(
  455. """
  456. INSERT INTO creative_rejection_fact
  457. (account_id, dynamic_creative_id, fact_type, fact_key, reason,
  458. site_set, element_type, component_type, raw_detail)
  459. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
  460. """,
  461. [
  462. (
  463. int(account_id),
  464. int(parsed.dynamic_creative_id),
  465. fact.fact_type,
  466. fact.fact_key[:255] if fact.fact_key else None,
  467. fact.reason,
  468. fact.site_set or None,
  469. fact.element_type or None,
  470. fact.component_type or None,
  471. json.dumps(fact.raw_detail, ensure_ascii=False, default=str),
  472. )
  473. for fact in parsed.rejection_facts
  474. ],
  475. )
  476. finally:
  477. conn.close()
  478. return parsed
  479. def scan_pending_reviews(
  480. lookback_hours: int = 72,
  481. limit: int = 1000,
  482. batch_size: int = 100,
  483. ) -> dict:
  484. """Scan pending submitted creatives and persist official review results."""
  485. tasks = load_pending_review_tasks(lookback_hours=lookback_hours, limit=limit)
  486. summary = {
  487. "run_started": datetime.now(timezone.utc).isoformat(),
  488. "tasks": len(tasks),
  489. "batches": 0,
  490. "results": 0,
  491. "approved": 0,
  492. "rejected": 0,
  493. "pending": 0,
  494. "unknown": 0,
  495. "errors": [],
  496. }
  497. for account_id, creative_ids in group_review_tasks(tasks, batch_size=batch_size):
  498. summary["batches"] += 1
  499. try:
  500. results = fetch_dynamic_creative_review_results(account_id, creative_ids)
  501. except Exception as e:
  502. err = f"account={account_id} ids={len(creative_ids)} error={e}"
  503. logger.exception("[creative_review] 查询审核结果失败:%s", err)
  504. summary["errors"].append(err)
  505. continue
  506. for raw in results:
  507. try:
  508. parsed = save_review_result(account_id, raw)
  509. except Exception as e:
  510. err = f"account={account_id} creative={raw.get('dynamic_creative_id')} error={e}"
  511. logger.exception("[creative_review] 保存审核结果失败:%s", err)
  512. summary["errors"].append(err)
  513. continue
  514. summary["results"] += 1
  515. summary[parsed.review_status] = summary.get(parsed.review_status, 0) + 1
  516. logger.info(
  517. "[creative_review] account=%d creative=%s status=%s reject=%d delay=%d",
  518. account_id,
  519. parsed.dynamic_creative_id,
  520. parsed.review_status,
  521. len(parsed.reject_messages),
  522. len(parsed.delay_messages),
  523. )
  524. summary["run_finished"] = datetime.now(timezone.utc).isoformat()
  525. return summary