"""Tencent dynamic creative review result scanner. This module tracks creatives submitted by the creation pipeline and polls Tencent's official review-result API. It does not perform prereview. """ from __future__ import annotations import json import logging from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Iterable, Iterator, Optional logger = logging.getLogger(__name__) CREATE_REVIEW_TABLES_SQL = [ """ CREATE TABLE IF NOT EXISTS creative_creation_task ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键', account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID', adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID', dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID', dynamic_creative_name VARCHAR(200) DEFAULT NULL COMMENT '动态创意名称', landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID', material_id VARCHAR(100) DEFAULT NULL COMMENT '内部素材ID', material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID', review_status VARCHAR(50) NOT NULL DEFAULT 'submitted' COMMENT '审核状态', submit_status VARCHAR(50) NOT NULL DEFAULT 'submitted' COMMENT '提交状态', submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', last_review_checked_at TIMESTAMP NULL DEFAULT NULL COMMENT '最近审核扫描时间', review_finished_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核完成时间', error TEXT DEFAULT NULL COMMENT '提交或扫描错误', raw_record MEDIUMTEXT DEFAULT NULL COMMENT '创建记录JSON', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id), KEY idx_review_status (review_status), KEY idx_submitted_at (submitted_at), KEY idx_landing_submitted (landing_video_id, submitted_at), KEY idx_last_review_checked_at (last_review_checked_at), KEY idx_account_status (account_id, review_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意创建与审核扫描任务' """, """ CREATE TABLE IF NOT EXISTS creative_review_result ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键', account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID', dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID', review_status VARCHAR(50) NOT NULL COMMENT '解析后的审核状态', reject_messages MEDIUMTEXT DEFAULT NULL COMMENT '拒绝原因JSON数组', delay_messages MEDIUMTEXT DEFAULT NULL COMMENT '延迟审核原因JSON数组', raw_result MEDIUMTEXT NOT NULL COMMENT '腾讯审核结果原文JSON', checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '扫描时间', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id), KEY idx_review_status (review_status), KEY idx_checked_at (checked_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯动态创意审核结果' """, """ CREATE TABLE IF NOT EXISTS creative_rejection_fact ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键', account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID', dynamic_creative_id BIGINT NOT NULL COMMENT '动态创意ID', fact_type VARCHAR(50) NOT NULL COMMENT '拒绝事实类型:site/element/compose', fact_key VARCHAR(255) DEFAULT NULL COMMENT '元素/版位/组件标识', reason TEXT NOT NULL COMMENT '拒绝原因', site_set VARCHAR(100) DEFAULT NULL COMMENT '影响版位', element_type VARCHAR(100) DEFAULT NULL COMMENT '元素类型', component_type VARCHAR(100) DEFAULT NULL COMMENT '组件类型', raw_detail MEDIUMTEXT DEFAULT NULL COMMENT '原始明细JSON', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_account_creative (account_id, dynamic_creative_id), KEY idx_fact_type (fact_type), KEY idx_fact_key (fact_key(100)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意审核拒绝事实' """, ] FINAL_REVIEW_STATUSES = {"approved", "rejected"} PENDING_REVIEW_STATUSES = {"submitted", "pending", "unknown"} @dataclass class RejectionFact: fact_type: str reason: str fact_key: str = "" site_set: str = "" element_type: str = "" component_type: str = "" raw_detail: dict = field(default_factory=dict) @dataclass class ParsedReviewResult: dynamic_creative_id: Optional[int] review_status: str reject_messages: list[str] = field(default_factory=list) delay_messages: list[str] = field(default_factory=list) rejection_facts: list[RejectionFact] = field(default_factory=list) def _dedupe(values: Iterable[str]) -> list[str]: """字符串列表去重,保留首次出现顺序,跳过空字符串。""" out: list[str] = [] seen: set[str] = set() for raw in values: value = str(raw or "").strip() if not value or value in seen: continue seen.add(value) out.append(value) return out def _status_is_rejected(status: str) -> bool: """判断审核状态是否为拒绝(REJECT 或 DENIED)。""" upper = (status or "").upper() return "REJECT" in upper or "DENIED" in upper def _status_is_pending(status: str) -> bool: """判断审核状态是否为待审(PENDING 或 REVIEWING)。""" upper = (status or "").upper() return "PENDING" in upper or "REVIEWING" in upper def _status_is_approved(status: str) -> bool: """判断审核状态是否为通过(PASS 或 APPROVED 或 NORMAL)。""" upper = (status or "").upper() return "PASS" in upper or "APPROVED" in upper or "NORMAL" in upper def _component_type(detail: dict) -> str: """从审核明细中提取组件类型(component_type)。""" component = detail.get("component_info") or {} if isinstance(component, dict): return str(component.get("component_type") or "") return "" def _collect_element_facts(result: dict) -> tuple[list[str], list[RejectionFact], bool, bool]: """从审核结果中提取元素级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表, 是否有待审, 是否有通过)。""" messages: list[str] = [] facts: list[RejectionFact] = [] has_pending = False has_approved = False for element in result.get("element_result_list") or []: if not isinstance(element, dict): continue status = str(element.get("review_status") or "") has_pending = has_pending or _status_is_pending(status) has_approved = has_approved or _status_is_approved(status) if not _status_is_rejected(status): continue details = element.get("element_reject_detail_info") or [] if not details: details = [{"reason": element.get("reason") or ""}] for detail in details: if not isinstance(detail, dict): continue reason = detail.get("reason") or element.get("reason") or "" if reason: messages.append(str(reason)) facts.append(RejectionFact( fact_type="element", fact_key=str( element.get("image_id") or element.get("video_id") or element.get("element_name") or "" ), reason=str(reason), element_type=str(element.get("element_type") or ""), component_type=_component_type(element), raw_detail={**element, "element_reject_detail": detail}, )) return messages, facts, has_pending, has_approved def _collect_site_facts(result: dict) -> tuple[list[str], list[RejectionFact], bool, bool]: """从审核结果中提取版位级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表, 是否有待审, 是否有通过)。""" messages: list[str] = [] facts: list[RejectionFact] = [] has_pending = False has_approved = False for site in result.get("site_set_result_list") or []: if not isinstance(site, dict): continue status = str(site.get("system_status") or site.get("review_status") or "") has_pending = has_pending or _status_is_pending(status) has_approved = has_approved or _status_is_approved(status) reject_message = site.get("reject_message") or "" if _status_is_rejected(status) or reject_message: if reject_message: messages.append(str(reject_message)) facts.append(RejectionFact( fact_type="site", fact_key=str(site.get("site_set") or ""), site_set=str(site.get("site_set") or ""), reason=str(reject_message), raw_detail=site, )) for detail in site.get("element_reject_detail_info") or []: if not isinstance(detail, dict): continue reason = detail.get("reason") or "" if reason: messages.append(str(reason)) facts.append(RejectionFact( fact_type="element", fact_key=str(detail.get("element_name") or ""), site_set=str(site.get("site_set") or ""), reason=str(reason), element_type=str(detail.get("element_type") or ""), component_type=_component_type(detail), raw_detail={**detail, "site_set": site.get("site_set")}, )) return messages, facts, has_pending, has_approved def _collect_compose_facts(result: dict) -> tuple[list[str], list[RejectionFact]]: """从审核结果中提取组件组合级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表)。""" messages: list[str] = [] facts: list[RejectionFact] = [] for item in result.get("reject_component_compose_info_list") or []: if not isinstance(item, dict): continue reason = item.get("reject_message") or "" if reason: messages.append(str(reason)) facts.append(RejectionFact( fact_type="compose", fact_key="component_compose", reason=str(reason), raw_detail=item, )) return messages, facts def parse_review_result(result: dict) -> ParsedReviewResult: """Parse Tencent dynamic creative review result into stable internal status.""" reject_messages: list[str] = [] delay_messages = [str(v) for v in (result.get("delay_message_list") or []) if v] facts: list[RejectionFact] = [] has_pending = bool(result.get("is_all_component_compose_pending")) has_approved = False reject_messages.extend(str(v) for v in (result.get("reject_message_list") or []) if v) if result.get("reject_component_compose_count"): reject_messages.append("组件组合审核拒绝") if result.get("pass_component_compose_count"): has_approved = True if result.get("total_component_compose_count") and result.get("reject_component_compose_count") == 0: has_approved = True for collector in (_collect_site_facts, _collect_element_facts): messages, new_facts, pending, approved = collector(result) reject_messages.extend(messages) facts.extend(new_facts) has_pending = has_pending or pending has_approved = has_approved or approved messages, new_facts = _collect_compose_facts(result) reject_messages.extend(messages) facts.extend(new_facts) reject_messages = _dedupe(reject_messages) delay_messages = _dedupe(delay_messages) if reject_messages or facts or int(result.get("reject_component_compose_count") or 0) > 0: status = "rejected" elif has_pending or delay_messages: status = "pending" elif has_approved: status = "approved" else: status = "unknown" dynamic_creative_id = result.get("dynamic_creative_id") try: dynamic_creative_id = int(dynamic_creative_id) if dynamic_creative_id is not None else None except (TypeError, ValueError): dynamic_creative_id = None return ParsedReviewResult( dynamic_creative_id=dynamic_creative_id, review_status=status, reject_messages=reject_messages, delay_messages=delay_messages, rejection_facts=facts, ) def group_review_tasks( tasks: Iterable[dict], batch_size: int = 100, ) -> Iterator[tuple[int, list[int]]]: """Yield (account_id, dynamic_creative_ids) batches for Tencent API.""" by_account: dict[int, list[int]] = {} for task in tasks: try: account_id = int(task["account_id"]) creative_id = int(task["dynamic_creative_id"]) except (KeyError, TypeError, ValueError): continue by_account.setdefault(account_id, []).append(creative_id) for account_id in sorted(by_account): ids = by_account[account_id] for i in range(0, len(ids), batch_size): yield account_id, ids[i:i + batch_size] def ensure_review_tables() -> None: """确保审核相关的数据库表(creative_creation_task / creative_review_result / creative_rejection_fact)已创建。""" from db.connection import get_connection conn = get_connection() try: with conn.cursor() as cur: for sql in CREATE_REVIEW_TABLES_SQL: cur.execute(sql) finally: conn.close() def record_creation_submission(record: dict, dynamic_creative_id: int) -> None: """Persist a successfully submitted creative as pending review scan.""" ensure_review_tables() from db.connection import get_connection body = record.get("_request_body") or {} raw_record = json.dumps(record, ensure_ascii=False, default=str) conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ INSERT INTO creative_creation_task (account_id, adgroup_id, dynamic_creative_id, dynamic_creative_name, landing_video_id, material_id, material_image_id, review_status, submit_status, submitted_at, raw_record) VALUES (%s, %s, %s, %s, %s, %s, %s, 'submitted', 'submitted', NOW(), %s) ON DUPLICATE KEY UPDATE adgroup_id=VALUES(adgroup_id), dynamic_creative_name=VALUES(dynamic_creative_name), landing_video_id=VALUES(landing_video_id), material_id=VALUES(material_id), material_image_id=VALUES(material_image_id), submit_status='submitted', review_status=IF(review_status IN ('approved','rejected'), review_status, 'submitted'), raw_record=VALUES(raw_record), updated_at=CURRENT_TIMESTAMP """, ( int(record["account_id"]), int(record.get("adgroup_id") or body.get("adgroup_id") or 0) or None, int(dynamic_creative_id), record.get("creative_name") or body.get("dynamic_creative_name"), record.get("landing_video_id"), str(record.get("_material_id") or ""), str(record.get("_material_image_id") or ""), raw_record, ), ) finally: conn.close() def mark_creation_submit_failed(record: dict, error: str) -> None: """Persist submit failure for observability.""" ensure_review_tables() from db.connection import get_connection body = record.get("_request_body") or {} raw_record = json.dumps(record, ensure_ascii=False, default=str) conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ INSERT INTO creative_creation_task (account_id, adgroup_id, dynamic_creative_id, dynamic_creative_name, landing_video_id, material_id, material_image_id, review_status, submit_status, error, raw_record) VALUES (%s, %s, 0, %s, %s, %s, %s, 'submit_failed', 'failed', %s, %s) """, ( int(record["account_id"]), int(record.get("adgroup_id") or body.get("adgroup_id") or 0) or None, record.get("creative_name") or body.get("dynamic_creative_name"), record.get("landing_video_id"), str(record.get("_material_id") or ""), str(record.get("_material_image_id") or ""), error[:2000], raw_record, ), ) except Exception as e: logger.warning("[creative_review] 记录提交失败状态异常:%s", e) finally: conn.close() def load_pending_review_tasks(lookback_hours: int = 72, limit: int = 1000) -> list[dict]: """从数据库中加载待审核的创意任务列表(最近 N 小时内提交且状态为 submitted/pending/unknown)。""" ensure_review_tables() from db.connection import get_connection conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT account_id, dynamic_creative_id FROM creative_creation_task WHERE dynamic_creative_id > 0 AND review_status IN ('submitted','pending','unknown') AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s HOUR) ORDER BY COALESCE(last_review_checked_at, submitted_at) ASC LIMIT %s """, (int(lookback_hours), int(limit)), ) return list(cur.fetchall()) finally: conn.close() def fetch_dynamic_creative_review_results( account_id: int, creative_ids: list[int], ) -> list[dict]: """调腾讯 API 批量查询动态创意的审核结果。""" if not creative_ids: return [] from tools.ad_api import _check, _get resp = _get( "/dynamic_creative_review_results/get", { "account_id": account_id, "dynamic_creative_id_list": [int(v) for v in creative_ids], }, ) data = _check(resp, "dynamic_creative_review_results/get") return data.get("list") or [] def save_review_result(account_id: int, raw_result: dict) -> ParsedReviewResult: """解析并持久化一条创意的审核结果,同步更新 creation_task 状态与 rejection_fact 明细。""" ensure_review_tables() parsed = parse_review_result(raw_result) if parsed.dynamic_creative_id is None: raise ValueError("审核结果缺 dynamic_creative_id") raw_json = json.dumps(raw_result, ensure_ascii=False, default=str) reject_json = json.dumps(parsed.reject_messages, ensure_ascii=False) delay_json = json.dumps(parsed.delay_messages, ensure_ascii=False) finished_at_sql = "NOW()" if parsed.review_status in FINAL_REVIEW_STATUSES else "NULL" from db.connection import get_connection conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ INSERT INTO creative_review_result (account_id, dynamic_creative_id, review_status, reject_messages, delay_messages, raw_result, checked_at) VALUES (%s, %s, %s, %s, %s, %s, NOW()) ON DUPLICATE KEY UPDATE review_status=VALUES(review_status), reject_messages=VALUES(reject_messages), delay_messages=VALUES(delay_messages), raw_result=VALUES(raw_result), checked_at=NOW(), updated_at=CURRENT_TIMESTAMP """, ( int(account_id), int(parsed.dynamic_creative_id), parsed.review_status, reject_json, delay_json, raw_json, ), ) cur.execute( f""" UPDATE creative_creation_task SET review_status=%s, last_review_checked_at=NOW(), review_finished_at={finished_at_sql}, updated_at=CURRENT_TIMESTAMP WHERE account_id=%s AND dynamic_creative_id=%s """, (parsed.review_status, int(account_id), int(parsed.dynamic_creative_id)), ) cur.execute( """ DELETE FROM creative_rejection_fact WHERE account_id=%s AND dynamic_creative_id=%s """, (int(account_id), int(parsed.dynamic_creative_id)), ) if parsed.rejection_facts: cur.executemany( """ INSERT INTO creative_rejection_fact (account_id, dynamic_creative_id, fact_type, fact_key, reason, site_set, element_type, component_type, raw_detail) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, [ ( int(account_id), int(parsed.dynamic_creative_id), fact.fact_type, fact.fact_key[:255] if fact.fact_key else None, fact.reason, fact.site_set or None, fact.element_type or None, fact.component_type or None, json.dumps(fact.raw_detail, ensure_ascii=False, default=str), ) for fact in parsed.rejection_facts ], ) finally: conn.close() return parsed def scan_pending_reviews( lookback_hours: int = 72, limit: int = 1000, batch_size: int = 100, ) -> dict: """Scan pending submitted creatives and persist official review results.""" tasks = load_pending_review_tasks(lookback_hours=lookback_hours, limit=limit) summary = { "run_started": datetime.now(timezone.utc).isoformat(), "tasks": len(tasks), "batches": 0, "results": 0, "approved": 0, "rejected": 0, "pending": 0, "unknown": 0, "errors": [], } for account_id, creative_ids in group_review_tasks(tasks, batch_size=batch_size): summary["batches"] += 1 try: results = fetch_dynamic_creative_review_results(account_id, creative_ids) except Exception as e: err = f"account={account_id} ids={len(creative_ids)} error={e}" logger.exception("[creative_review] 查询审核结果失败:%s", err) summary["errors"].append(err) continue for raw in results: try: parsed = save_review_result(account_id, raw) except Exception as e: err = f"account={account_id} creative={raw.get('dynamic_creative_id')} error={e}" logger.exception("[creative_review] 保存审核结果失败:%s", err) summary["errors"].append(err) continue summary["results"] += 1 summary[parsed.review_status] = summary.get(parsed.review_status, 0) + 1 logger.info( "[creative_review] account=%d creative=%s status=%s reject=%d delay=%d", account_id, parsed.dynamic_creative_id, parsed.review_status, len(parsed.reject_messages), len(parsed.delay_messages), ) summary["run_finished"] = datetime.now(timezone.utc).isoformat() return summary