creative_review.py 23 KB

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