| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- """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
|