Преглед изворни кода

创意审核检查删除通知

wangyunpeng пре 1 недеља
родитељ
комит
89582bf2e0

+ 67 - 0
examples/auto_put_ad_mini/cleanup_rejected_creatives.py

@@ -0,0 +1,67 @@
+#!/usr/bin/env python
+"""Run one rejected-creative cleanup and agency notification pass."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import sys
+import time
+from pathlib import Path
+
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parents[1]
+RTC_DIR = ROOT / "examples" / "tencent_realtime_control"
+for path in (ROOT, HERE, RTC_DIR):
+    if str(path) not in sys.path:
+        sys.path.insert(0, str(path))
+
+from dotenv import load_dotenv  # noqa: E402
+
+load_dotenv(HERE / ".env", override=False)
+load_dotenv(Path.cwd() / ".env", override=False)
+
+while str(HERE) in sys.path:
+    sys.path.remove(str(HERE))
+sys.path.insert(0, str(HERE))
+
+from tools.creative_rejection_cleanup import run_rejected_creative_cleanup  # noqa: E402
+
+
+logger = logging.getLogger("auto_put_ad_mini.creative_rejection_cleanup")
+
+
+def main() -> int:
+    from logging_setup import setup_logging
+
+    setup_logging(level=os.getenv("LOG_LEVEL", "INFO"))
+    started = time.monotonic()
+    logger.info("event=creative_rejection_cleanup stage=run result=started")
+    try:
+        summary = run_rejected_creative_cleanup(
+            output_dir=HERE / "outputs" / "creative_rejection_cleanup"
+        )
+    except Exception:
+        logger.exception(
+            "event=creative_rejection_cleanup stage=run result=failed duration_ms=%d",
+            int((time.monotonic() - started) * 1000),
+        )
+        raise
+    has_errors = bool(
+        summary["scan_errors"]
+        or summary["delete_errors"]
+        or summary["notification_errors"]
+    )
+    logger.info(
+        "event=creative_rejection_cleanup stage=run result=%s duration_ms=%d summary=%s",
+        "partial" if has_errors else "succeeded",
+        int((time.monotonic() - started) * 1000),
+        json.dumps(summary, ensure_ascii=False, default=str),
+    )
+    return 1 if has_errors else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 53 - 0
examples/auto_put_ad_mini/docs/unified_services_deployment.md

@@ -85,6 +85,13 @@ DAILY_CREATION_HOUR=10
 DAILY_CREATION_MINUTE=30
 DAILY_CREATION_MINUTE=30
 DAILY_REVIEW_ENABLED=0
 DAILY_REVIEW_ENABLED=0
 DAILY_REVIEW_INTERVAL_HOURS=2
 DAILY_REVIEW_INTERVAL_HOURS=2
+DAILY_REJECTED_CREATIVE_CLEANUP_ENABLED=0
+DAILY_REJECTED_CREATIVE_APPLY_ENABLED=0
+DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN=50
+DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN=100
+DAILY_REJECTED_CREATIVE_CLEANUP_HOUR=11
+DAILY_REJECTED_CREATIVE_CLEANUP_MINUTE=0
+DAILY_REJECTED_CREATIVE_RUN_ON_STARTUP=0
 DAILY_RUN_ON_STARTUP=0
 DAILY_RUN_ON_STARTUP=0
 DAILY_REVIEW_RUN_ON_STARTUP=0
 DAILY_REVIEW_RUN_ON_STARTUP=0
 DAILY_SYNC_DELIVERY_TEMPLATE=0
 DAILY_SYNC_DELIVERY_TEMPLATE=0
@@ -126,6 +133,52 @@ ROI_AGENCY_WEBHOOKS_JSON={}
 代码默认 `RTC_APPLY_ENABLED=0`,日级创建、审核扫描、ROI 和模板同步也默认关闭;
 代码默认 `RTC_APPLY_ENABLED=0`,日级创建、审核扫描、ROI 和模板同步也默认关闭;
 缺少显式生产配置时不会创建广告或修改腾讯。
 缺少显式生产配置时不会创建广告或修改腾讯。
 
 
+`DAILY_REJECTED_CREATIVE_CLEANUP_ENABLED=1` 会在默认 11:00 注册一个与日级 ROI
+并行的独立调度线程。`DAILY_REJECTED_CREATIVE_APPLY_ENABLED` 默认关闭;关闭时任务只枚举、
+判断和保存待清理审计,不调用腾讯写接口。任务从 `loghubods.opengid_base_data` 查询
+T-3 至 T-1 三个完整日内 `SUM(成本)>0` 的 `账号id`,枚举这些账户的全部未删除创意;
+账户范围不附加 `usersharedepth`、`videoid` 或 `hotsencetype` 条件。
+账户范围不再依赖 MySQL 白名单或自动创建配置。任务批量读取腾讯正式审核结果:
+`creative_set_approval_status=CREATIVE_SET_APPROVAL_STATUS_DENIED` 时删除整条创意。
+`creative_set_approval_status=CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL` 时,通过腾讯
+`/daily_reports/get` 的 `REPORT_LEVEL_DYNAMIC_CREATIVE` 层级实时汇总任务执行日前三个自然日
+(昨天及前两天,不含今天)的创意消耗。历史三天消耗严格低于
+`DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN`(默认 50 元)时删除整条创意,达到 50 元
+不会自动删除。部分投放中创意如果元素粒度存在名称为“微信小程序”且状态为拒绝的记录,历史三天消耗
+严格低于 `DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN`(默认 100 元)时删除,达到 100 元
+则进入代理人工判断;实时消耗接口
+失败时同样只报警,禁止把失败结果当作 0 消耗。除此之外,普通创意的元素或版位审核拒绝均不触发
+任何自动删除;系统不再提供删除拒审元素组件的流程。逐账户扫描前会按
+`TENCENT_AD_TOKEN_PREFETCH_WORKERS`(默认 8)并发
+预取并缓存 access token;腾讯只读 GET 遇到连接/TLS 中断、超时、408、429 或 5xx 时按
+`TENCENT_AD_GET_RETRY_ATTEMPTS`(默认总尝试 3 次)和指数退避自动重试。腾讯查询、数据库
+审计和清理操作仍保持原有顺序。账户级创意、广告和审核结果读取按
+`TENCENT_AD_ACCOUNT_SCAN_WORKERS`(默认 8)有限并发,每个账户任务使用独立 HTTP Session;
+候选落库、删除、回读和通知仍在主线程顺序执行。写操作前在锁内重新读取创意和审核结果,
+只有整创意删除动作保持一致才执行。历史遗留的组件删除候选不会被加载执行。清理前后使用
+`RTC_DB_LOCK_NAME` 与实时 CPM/ROI 写操作互斥,并将每条写前状态、回读结果、失败状态和
+通知状态持久化;超时导致写结果未知时,下次运行先回读确认,不会盲目重复写入。
+
+自动清理成功项和待人工判断报警项按日级 ROI 数据中的代理归属生成一代理一份
+`YYYYMMDD_代理名称_创意审核异常处理_批次摘要.xlsx`。代理报表仅展示代理、账户、广告、创意、
+配置状态、创意审核状态 (`creative_set_approval_status`)、元素粒度审核状态/原因、版位粒度审核状态/原因、
+审核不通过原因、检查时间和执行操作;近 3 天消耗、消耗日期范围和操作判断原因只保留在数据库内部审计中,
+不向代理展示。代理归属优先使用日级 ROI 数据中的创意级归属,其次使用账户级唯一归属;仍为空时按账户 ID
+从 `loghubods.ad_put_tencent_account` 按账户读取 `id` 最大的一条记录;只有该最新记录满足
+`is_delete=0`、`status=1` 且 `agent_name` 非空时才作为兜底,不会使用更旧记录,也不会覆盖已有归属。
+Excel 中
+已知状态枚举按腾讯中文描述展示,未知枚举保留原值;删除判断和数据库审计始终使用腾讯返回的
+原始枚举值。报表通过现有
+`ROI_AGENCY_WEBHOOKS_JSON` 精确路由到代理群;同批次还会生成包含全部代理创意的汇总表,通过飞书应用
+发送到 `FEISHU_OPERATOR_CHAT_ID` 投放调控通知群。代理通知与投放调控汇总通知分别记录成功状态;
+单一目标失败时只重试失败目标,已成功目标不会重复发送。上传或机器人通知失败时复用已有在线表,
+只重试通知,不重复删除。每天的检查使用独立日级审计记录,同一需人工判断创意如果次日仍命中规则,
+会按当天新检查再次提醒。代理归属为空,或已解析代理但没有配置对应代理群时,记录置为 `DEFERRED`,
+禁止执行腾讯删除,待归属和路由完整后再处理。该任务要求同时设置
+`ROI_AGENCY_WEBHOOK_ENABLED=1` 和 `FEISHU_OPERATOR_CHAT_ID`;启用真实删除但缺少任一配置时,
+任务会在腾讯写操作前拒绝启动。生产启用顺序为:先只开启任务完成只读审核结果核对,再开启
+`ROI_AGENCY_WEBHOOK_ENABLED=1` 和 `DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1`。
+
 ## 3. ECS 部署脚本
 ## 3. ECS 部署脚本
 
 
 `/home/sh/deploy.sh` 使用:
 `/home/sh/deploy.sh` 使用:

+ 31 - 12
examples/auto_put_ad_mini/roi_control/agency_delivery.py

@@ -57,11 +57,27 @@ class AgencyWebhookNotifier:
         sheet_url: str,
         sheet_url: str,
         creative_rows: int,
         creative_rows: int,
         ad_rows: int,
         ad_rows: int,
+        notification_type: str = "roi_advice",
     ) -> str:
     ) -> str:
+        if notification_type == "creative_rejection_cleanup":
+            content = (
+                f"创意审核异常处理及人工报警:**{creative_rows}** 条\n"
+                "请查看已自动删除项及需要人工判断的部分投放中创意。"
+            )
+            button_text = "查看处理明细"
+            header_template = "orange"
+        else:
+            content = (
+                f"创意建议:**{creative_rows}** 条\n"
+                f"广告建议:**{ad_rows}** 条\n"
+                "请点击下方按钮查看调控建议。"
+            )
+            button_text = "查看调控建议"
+            header_template = "blue"
         card = {
         card = {
             "config": {"wide_screen_mode": True},
             "config": {"wide_screen_mode": True},
             "header": {
             "header": {
-                "template": "blue",
+                "template": header_template,
                 "title": {"tag": "plain_text", "content": title},
                 "title": {"tag": "plain_text", "content": title},
             },
             },
             "elements": [
             "elements": [
@@ -69,11 +85,7 @@ class AgencyWebhookNotifier:
                     "tag": "div",
                     "tag": "div",
                     "text": {
                     "text": {
                         "tag": "lark_md",
                         "tag": "lark_md",
-                        "content": (
-                            f"创意建议:**{creative_rows}** 条\n"
-                            f"广告建议:**{ad_rows}** 条\n"
-                            "请点击下方按钮查看调控建议。"
-                        ),
+                        "content": content,
                     },
                     },
                 },
                 },
                 {
                 {
@@ -82,7 +94,7 @@ class AgencyWebhookNotifier:
                         {
                         {
                             "tag": "button",
                             "tag": "button",
                             "type": "primary",
                             "type": "primary",
-                            "text": {"tag": "plain_text", "content": "查看调控建议"},
+                            "text": {"tag": "plain_text", "content": button_text},
                             "url": sheet_url,
                             "url": sheet_url,
                         }
                         }
                     ],
                     ],
@@ -110,6 +122,8 @@ def publish_agency_reports(
     publisher: RoiFeishuPublisher,
     publisher: RoiFeishuPublisher,
     notifier: AgencyWebhookNotifier | None = None,
     notifier: AgencyWebhookNotifier | None = None,
     now: datetime | None = None,
     now: datetime | None = None,
+    upsert_delivery=None,
+    update_delivery=None,
 ) -> list[dict[str, object]]:
 ) -> list[dict[str, object]]:
     """Publish configured agency reports independently and return safe outcomes."""
     """Publish configured agency reports independently and return safe outcomes."""
 
 
@@ -118,6 +132,8 @@ def publish_agency_reports(
     routes = config.webhooks or {}
     routes = config.webhooks or {}
     owned_notifier = notifier is None
     owned_notifier = notifier is None
     sender = notifier or AgencyWebhookNotifier()
     sender = notifier or AgencyWebhookNotifier()
+    upsert_record = upsert_delivery or upsert_agency_delivery
+    update_record = update_delivery or update_agency_delivery
     outcomes: list[dict[str, object]] = []
     outcomes: list[dict[str, object]] = []
     try:
     try:
         for report in reports:
         for report in reports:
@@ -129,7 +145,7 @@ def publish_agency_reports(
             try:
             try:
                 if not path.is_file():
                 if not path.is_file():
                     raise FileNotFoundError(path)
                     raise FileNotFoundError(path)
-                delivery = upsert_agency_delivery(
+                delivery = upsert_record(
                     {
                     {
                         "run_id": run_id,
                         "run_id": run_id,
                         "agency_name": agency_name,
                         "agency_name": agency_name,
@@ -156,7 +172,7 @@ def publish_agency_reports(
                     )
                     )
                     continue
                     continue
                 if not webhook_url:
                 if not webhook_url:
-                    update_agency_delivery(
+                    update_record(
                         delivery_id,
                         delivery_id,
                         status="SKIPPED",
                         status="SKIPPED",
                         error_message="未配置代理机器人",
                         error_message="未配置代理机器人",
@@ -175,7 +191,7 @@ def publish_agency_reports(
                     imported = publisher.upload_workbook(path)
                     imported = publisher.upload_workbook(path)
                     sheet_url = imported["url"]
                     sheet_url = imported["url"]
                     sheet_token = imported["sheet_token"]
                     sheet_token = imported["sheet_token"]
-                    update_agency_delivery(
+                    update_record(
                         delivery_id,
                         delivery_id,
                         status="UPLOADED",
                         status="UPLOADED",
                         sheet_token=sheet_token,
                         sheet_token=sheet_token,
@@ -187,8 +203,11 @@ def publish_agency_reports(
                     sheet_url=sheet_url,
                     sheet_url=sheet_url,
                     creative_rows=int(report.get("creative_rows") or 0),
                     creative_rows=int(report.get("creative_rows") or 0),
                     ad_rows=int(report.get("ad_rows") or 0),
                     ad_rows=int(report.get("ad_rows") or 0),
+                    notification_type=str(
+                        report.get("notification_type") or "roi_advice"
+                    ),
                 )
                 )
-                update_agency_delivery(
+                update_record(
                     delivery_id,
                     delivery_id,
                     status="SENT",
                     status="SENT",
                     response_code=response_code,
                     response_code=response_code,
@@ -210,7 +229,7 @@ def publish_agency_reports(
                     safe_error = safe_error.replace(webhook_url, "<redacted>")
                     safe_error = safe_error.replace(webhook_url, "<redacted>")
                 if delivery_id is not None:
                 if delivery_id is not None:
                     try:
                     try:
-                        update_agency_delivery(
+                        update_record(
                             delivery_id,
                             delivery_id,
                             status="FAILED",
                             status="FAILED",
                             error_message=safe_error,
                             error_message=safe_error,

+ 112 - 0
examples/auto_put_ad_mini/roi_control/data_source.py

@@ -203,6 +203,118 @@ GROUP BY dt, channel, 合作方名, 公众号名
 """.strip()
 """.strip()
 
 
 
 
+def build_recent_spend_accounts_sql(start_date: str, end_date: str) -> str:
+    """Build the account scope query for three complete cost partitions."""
+
+    parse_yyyymmdd(start_date)
+    parse_yyyymmdd(end_date)
+    return f"""
+SELECT
+  账号id AS account_id,
+  MAX(NVL(账号名称, '')) AS account_name,
+  SUM(NVL(成本, 0)) AS cost_yuan
+FROM {TABLE_NAME}
+WHERE dt BETWEEN '{start_date}' AND '{end_date}'
+  AND 账号id IS NOT NULL
+  AND NVL(账号id, '') <> ''
+GROUP BY 账号id
+HAVING SUM(NVL(成本, 0)) > 0
+ORDER BY account_id
+""".strip()
+
+
+def fetch_recent_spend_accounts(
+    client: ODPSClient,
+    start_date: str,
+    end_date: str,
+) -> list[dict[str, object]]:
+    frame = client.execute_sql(
+        build_recent_spend_accounts_sql(start_date, end_date)
+    )
+    accounts: list[dict[str, object]] = []
+    for row in frame.to_dict(orient="records"):
+        raw_account_id = row.get("account_id")
+        try:
+            account_id = int(raw_account_id)
+        except (TypeError, ValueError):
+            continue
+        if account_id <= 0:
+            continue
+        accounts.append(
+            {
+                "account_id": account_id,
+                "account_name": str(row.get("account_name") or ""),
+                "cost_yuan": float(row.get("cost_yuan") or 0),
+            }
+        )
+    return accounts
+
+
+def build_account_agency_fallback_sql(account_ids: list[int]) -> str:
+    """Build the account-level agency fallback query for known numeric IDs."""
+
+    normalized_ids: set[int] = set()
+    for value in account_ids:
+        try:
+            account_id = int(value)
+        except (TypeError, ValueError):
+            continue
+        if account_id > 0:
+            normalized_ids.add(account_id)
+    sorted_ids = sorted(normalized_ids)
+    if not sorted_ids:
+        raise ValueError("account_ids must contain at least one positive account ID")
+    account_filter = ", ".join(f"'{value}'" for value in sorted_ids)
+    return f"""
+SELECT account_id, account_name, agent_name
+FROM (
+  SELECT
+    account_id,
+    account_name,
+    agent_name,
+    is_delete,
+    status,
+    ROW_NUMBER() OVER (
+      PARTITION BY account_id
+      ORDER BY id DESC
+    ) AS row_number
+  FROM loghubods.ad_put_tencent_account
+  WHERE account_id IN ({account_filter})
+) latest
+WHERE row_number = 1
+  AND NVL(is_delete, 0) = 0
+  AND status = 1
+  AND agent_name IS NOT NULL
+  AND TRIM(agent_name) <> ''
+""".strip()
+
+
+def fetch_account_agency_fallbacks(
+    client: ODPSClient,
+    account_ids: list[int],
+) -> dict[int, str]:
+    """Read each account's latest non-deleted, non-empty agency name."""
+
+    if not account_ids:
+        return {}
+    frame = client.execute_sql(build_account_agency_fallback_sql(account_ids))
+    agencies: dict[int, str] = {}
+    for row in frame.to_dict(orient="records"):
+        try:
+            account_id = int(row.get("account_id"))
+        except (TypeError, ValueError):
+            continue
+        raw_agency_name = row.get("agent_name")
+        agency_name = (
+            ""
+            if raw_agency_name is None or pd.isna(raw_agency_name)
+            else "".join(str(raw_agency_name).split())
+        )
+        if account_id > 0 and agency_name:
+            agencies[account_id] = agency_name
+    return agencies
+
+
 def build_ad_age_sql(end_date: str, lookback_days: int = 30) -> str:
 def build_ad_age_sql(end_date: str, lookback_days: int = 30) -> str:
     end = parse_yyyymmdd(end_date)
     end = parse_yyyymmdd(end_date)
     start_date = (end - timedelta(days=lookback_days - 1)).strftime("%Y%m%d")
     start_date = (end - timedelta(days=lookback_days - 1)).strftime("%Y%m%d")

+ 51 - 0
examples/auto_put_ad_mini/roi_control/feishu.py

@@ -299,6 +299,57 @@ class RoiFeishuPublisher:
             "message_id"
             "message_id"
         ]
         ]
 
 
+    def send_report_card(
+        self,
+        *,
+        title: str,
+        content: str,
+        sheet_url: str,
+        chat_id: str,
+        button_text: str = "查看汇总明细",
+    ) -> str:
+        """Send one report link to one explicitly selected Feishu group."""
+
+        target_chat_id = str(chat_id or "").strip()
+        if not target_chat_id:
+            raise RuntimeError("Missing Feishu report chat_id")
+        card = {
+            "config": {"wide_screen_mode": True},
+            "header": {
+                "template": "orange",
+                "title": {"tag": "plain_text", "content": title},
+            },
+            "elements": [
+                {
+                    "tag": "div",
+                    "text": {"tag": "lark_md", "content": content},
+                },
+                {
+                    "tag": "action",
+                    "actions": [
+                        {
+                            "tag": "button",
+                            "type": "primary",
+                            "text": {"tag": "plain_text", "content": button_text},
+                            "url": sheet_url,
+                        }
+                    ],
+                },
+            ],
+        }
+        token = self._token()
+        response = self.client.post(
+            f"{BASE_URL}/im/v1/messages",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            params={"receive_id_type": "chat_id"},
+            json={
+                "receive_id": target_chat_id,
+                "msg_type": "interactive",
+                "content": json.dumps(card, ensure_ascii=False),
+            },
+        )
+        return self._json(response, "send Feishu report card")["data"]["message_id"]
+
     def publish(
     def publish(
         self,
         self,
         path: Path,
         path: Path,

+ 71 - 0
examples/auto_put_ad_mini/run_daily_service.py

@@ -77,6 +77,24 @@ def _notify_roi_failure(
             publisher.close()
             publisher.close()
 
 
 
 
+def _notify_creative_cleanup_failure(error: str) -> None:
+    publisher: RoiFeishuPublisher | None = None
+    try:
+        publisher = RoiFeishuPublisher(require_chat_ids=False)
+        publisher.send_service_alert(
+            title="拒审创意自动删除任务失败",
+            content=(
+                f"错误:`{error[:500]}`\n"
+                "请检查审核扫描、腾讯回读、数据库审计和代理通知日志。"
+            ),
+        )
+    except Exception as exc:
+        logger.error("Failed to send creative cleanup failure alert: %s", exc)
+    finally:
+        if publisher is not None:
+            publisher.close()
+
+
 def sync_enabled_delivery_templates() -> int:
 def sync_enabled_delivery_templates() -> int:
     """Keep enabled DB templates aligned with the code's global delivery window."""
     """Keep enabled DB templates aligned with the code's global delivery window."""
     serialized = json.dumps(TIME_SERIES_DEFAULT)
     serialized = json.dumps(TIME_SERIES_DEFAULT)
@@ -134,6 +152,8 @@ def _run_script(
             )
             )
             if script_name == "run_daily_roi.py":
             if script_name == "run_daily_roi.py":
                 _notify_roi_failure(extra_args=extra_args, error=str(exc))
                 _notify_roi_failure(extra_args=extra_args, error=str(exc))
+            elif script_name == "cleanup_rejected_creatives.py":
+                _notify_creative_cleanup_failure(str(exc))
             raise
             raise
         if completed.returncode:
         if completed.returncode:
             logger.error(
             logger.error(
@@ -147,6 +167,10 @@ def _run_script(
                     extra_args=extra_args,
                     extra_args=extra_args,
                     error=f"process exited with code {completed.returncode}",
                     error=f"process exited with code {completed.returncode}",
                 )
                 )
+            elif script_name == "cleanup_rejected_creatives.py":
+                _notify_creative_cleanup_failure(
+                    f"process exited with code {completed.returncode}"
+                )
             raise RuntimeError(
             raise RuntimeError(
                 f"{script_name} exited with code {completed.returncode}"
                 f"{script_name} exited with code {completed.returncode}"
             )
             )
@@ -189,6 +213,18 @@ def run_creative_review() -> None:
     )
     )
 
 
 
 
+def run_creative_rejection_cleanup() -> None:
+    """Run the independent rejected-creative deletion and notification task."""
+
+    _run_script(
+        "cleanup_rejected_creatives.py",
+        os.getenv(
+            "DAILY_REJECTED_CREATIVE_LOCK_NAME",
+            "ad_rejected_creative_cleanup",
+        ),
+    )
+
+
 def run_daily_roi() -> None:
 def run_daily_roi() -> None:
     _run_script(
     _run_script(
         "run_daily_roi.py",
         "run_daily_roi.py",
@@ -312,6 +348,35 @@ def main(argv: list[str] | None = None) -> None:
                 os.getenv("DAILY_ROI_MISFIRE_GRACE_SECONDS", "3600")
                 os.getenv("DAILY_ROI_MISFIRE_GRACE_SECONDS", "3600")
             ),
             ),
         )
         )
+    if _env_flag("DAILY_REJECTED_CREATIVE_CLEANUP_ENABLED", False):
+        scheduler.add_job(
+            run_creative_rejection_cleanup,
+            CronTrigger(
+                hour=int(
+                    os.getenv(
+                        "DAILY_REJECTED_CREATIVE_CLEANUP_HOUR",
+                        str(roi_config.report_hour),
+                    )
+                ),
+                minute=int(
+                    os.getenv(
+                        "DAILY_REJECTED_CREATIVE_CLEANUP_MINUTE",
+                        str(roi_config.report_minute),
+                    )
+                ),
+                timezone="Asia/Shanghai",
+            ),
+            id="rejected_creative_cleanup",
+            name="拒审创意自动删除与代理通知",
+            max_instances=1,
+            coalesce=True,
+            misfire_grace_time=int(
+                os.getenv(
+                    "DAILY_REJECTED_CREATIVE_MISFIRE_GRACE_SECONDS",
+                    "3600",
+                )
+            ),
+        )
     if roi_config.internal_test_enabled:
     if roi_config.internal_test_enabled:
         scheduler.add_job(
         scheduler.add_job(
             run_internal_roi_test,
             run_internal_roi_test,
@@ -340,6 +405,12 @@ def main(argv: list[str] | None = None) -> None:
             id="creative_review_startup",
             id="creative_review_startup",
             name="启动时审核扫描",
             name="启动时审核扫描",
         )
         )
+    if _env_flag("DAILY_REJECTED_CREATIVE_RUN_ON_STARTUP", False):
+        scheduler.add_job(
+            run_creative_rejection_cleanup,
+            id="rejected_creative_cleanup_startup",
+            name="启动时拒审创意清理",
+        )
     if roi_config.daily_enabled and _env_flag("DAILY_ROI_RUN_ON_STARTUP", False):
     if roi_config.daily_enabled and _env_flag("DAILY_ROI_RUN_ON_STARTUP", False):
         scheduler.add_job(
         scheduler.add_job(
             run_daily_roi,
             run_daily_roi,

+ 2187 - 0
examples/auto_put_ad_mini/test_creative_review_scan.py

@@ -1,7 +1,74 @@
+import json
+import os
+import tempfile
 import unittest
 import unittest
+from contextlib import contextmanager
+from datetime import date, datetime
+from pathlib import Path
+from unittest.mock import Mock, patch
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+from openpyxl import load_workbook
 
 
 
 
 class CreativeReviewScanTests(unittest.TestCase):
 class CreativeReviewScanTests(unittest.TestCase):
+    def test_tencent_get_retries_transient_tls_connection_failure(self):
+        from tools import ad_api
+
+        response = Mock()
+        response.raise_for_status.return_value = None
+        response.json.return_value = {"code": 0, "data": {"list": []}}
+        connection_error = ad_api.httpx.ConnectError("temporary TLS EOF")
+
+        with patch.dict(
+            os.environ,
+            {
+                "TENCENT_AD_GET_RETRY_ATTEMPTS": "3",
+                "TENCENT_AD_GET_RETRY_BACKOFF_SECONDS": "0",
+            },
+            clear=False,
+        ), patch.object(
+            ad_api, "_common_params", side_effect=[{"nonce": "1"}, {"nonce": "2"}]
+        ) as common_params, patch.object(
+            ad_api.httpx,
+            "get",
+            side_effect=[connection_error, response],
+            create=True,
+        ) as get:
+            payload = ad_api._get("/test/get", {"account_id": 123})
+
+        self.assertEqual(payload, {"code": 0, "data": {"list": []}})
+        self.assertEqual(get.call_count, 2)
+        self.assertEqual(common_params.call_count, 2)
+
+    def test_access_tokens_are_prefetched_concurrently_and_deduplicated(self):
+        import threading
+
+        from tools import ad_api
+
+        barrier = threading.Barrier(3, timeout=2)
+
+        def fetch(account_id):
+            barrier.wait()
+            return f"access-token-{account_id}"
+
+        with patch.dict(ad_api._token_cache, {}, clear=True), patch.object(
+            ad_api,
+            "_get_access_token",
+            side_effect=fetch,
+        ) as get_token:
+            tokens = ad_api.prefetch_access_tokens([3, 1, 2, 2], max_workers=3)
+            cached_accounts = set(ad_api._token_cache)
+
+        self.assertEqual(tokens, {
+            1: "access-token-1",
+            2: "access-token-2",
+            3: "access-token-3",
+        })
+        self.assertEqual(get_token.call_count, 3)
+        self.assertEqual(cached_accounts, {1, 2, 3})
+
     def test_parse_rejected_result_collects_reasons_and_locations(self):
     def test_parse_rejected_result_collects_reasons_and_locations(self):
         from tools.creative_review import parse_review_result
         from tools.creative_review import parse_review_result
 
 
@@ -50,6 +117,83 @@ class CreativeReviewScanTests(unittest.TestCase):
         self.assertEqual(parsed.review_status, "pending")
         self.assertEqual(parsed.review_status, "pending")
         self.assertIn("审核延迟", parsed.delay_messages)
         self.assertIn("审核延迟", parsed.delay_messages)
 
 
+    def test_review_granularity_fields_lists_element_and_site_results(self):
+        from tools.creative_review import review_granularity_fields
+
+        fields = review_granularity_fields(
+            {
+                "element_result_list": [
+                    {
+                        "element_name": "主图",
+                        "image_id": "10001",
+                        "review_status": "REVIEW_STATUS_REJECTED",
+                        "element_reject_detail_info": [
+                            {"reason": "图片违规"},
+                            {"reason": "文字违规"},
+                        ],
+                    },
+                    {
+                        "element_type": "ELEMENT_TYPE_VIDEO",
+                        "video_id": "20002",
+                        "review_status": "REVIEW_STATUS_APPROVED",
+                    },
+                    {
+                        "element_name": "正常元素",
+                        "element_id": "30003",
+                        "system_status": "AD_STATUS_NORMAL",
+                    },
+                ],
+                "site_set_result_list": [
+                    {
+                        "site_set": "SITE_SET_MOMENTS",
+                        "site_set_id": "40004",
+                        "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
+                        "reject_message": "朋友圈版位拒绝",
+                        "element_reject_detail_info": [
+                            {"reason": "版位素材不适配"}
+                        ],
+                    },
+                    {
+                        "site_set": "SITE_SET_WECHAT_CHANNELS",
+                        "review_status": "REVIEW_STATUS_APPROVED",
+                    },
+                    {
+                        "site_set": "SITE_SET_NORMAL",
+                        "review_status": "AD_STATUS_NORMAL",
+                    },
+                ],
+            }
+        )
+
+        self.assertEqual(
+            fields["element_review_status"],
+            "主图(id=10001): 审核拒绝\nELEMENT_TYPE_VIDEO: 审核通过",
+        )
+        self.assertEqual(
+            fields["element_reject_reason"],
+            "主图(id=10001): 图片违规\n主图(id=10001): 文字违规",
+        )
+        self.assertEqual(
+            fields["site_review_status"],
+            "SITE_SET_MOMENTS(id=40004): 审核拒绝\n"
+            "SITE_SET_WECHAT_CHANNELS: 审核通过",
+        )
+        self.assertEqual(
+            fields["site_reject_reason"],
+            "SITE_SET_MOMENTS(id=40004): 朋友圈版位拒绝\n"
+            "SITE_SET_MOMENTS(id=40004): 版位素材不适配",
+        )
+
+    def test_status_desc_preserves_unknown_enum(self):
+        from tools.creative_review import status_desc
+
+        self.assertEqual(status_desc("AD_STATUS_NORMAL"), "投放中")
+        self.assertEqual(
+            status_desc("CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"),
+            "部分投放中",
+        )
+        self.assertEqual(status_desc("FUTURE_STATUS_NEW"), "FUTURE_STATUS_NEW")
+
     def test_chunk_ids_by_account_limits_to_100(self):
     def test_chunk_ids_by_account_limits_to_100(self):
         from tools.creative_review import group_review_tasks
         from tools.creative_review import group_review_tasks
 
 
@@ -68,5 +212,2048 @@ class CreativeReviewScanTests(unittest.TestCase):
         self.assertEqual(groups[3], (2, [999]))
         self.assertEqual(groups[3], (2, [999]))
 
 
 
 
+class CreativeRejectionCleanupTests(unittest.TestCase):
+    def test_dynamic_creative_api_requests_creative_approval_status(self):
+        from tencent_client import DYNAMIC_CREATIVE_FIELDS, TencentClient
+
+        response = Mock()
+        response.raise_for_status.return_value = None
+        response.json.return_value = {
+            "code": 0,
+            "data": {"list": [], "page_info": {"total_page": 1}},
+        }
+        client = TencentClient()
+        client.session.get = Mock(return_value=response)
+        client._common_params = Mock(return_value={"access_token": "token"})
+
+        client.get_dynamic_creatives(1)
+
+        self.assertIn("creative_set_approval_status", DYNAMIC_CREATIVE_FIELDS)
+        requested_fields = json.loads(client.session.get.call_args.kwargs["params"]["fields"])
+        self.assertIn("creative_set_approval_status", requested_fields)
+
+    def test_dynamic_creative_costs_query_sums_three_day_api_rows(self):
+        from datetime import date
+
+        from tencent_client import TencentClient
+
+        response = Mock()
+        response.raise_for_status.return_value = None
+        response.json.return_value = {
+            "code": 0,
+            "data": {
+                "list": [
+                    {"dynamic_creative_id": 3, "date": "2026-08-10", "cost": 1200},
+                    {"dynamic_creative_id": 3, "date": "2026-08-11", "cost": 1799},
+                    {"dynamic_creative_id": 4, "date": "2026-08-12", "cost": 3000},
+                ],
+                "page_info": {"total_page": 1},
+            },
+        }
+        client = TencentClient()
+        client.session.get = Mock(return_value=response)
+        client._common_params = Mock(return_value={"access_token": "token"})
+
+        costs = client.get_dynamic_creative_costs(
+            1,
+            [3, 4],
+            date(2026, 8, 10),
+            date(2026, 8, 12),
+        )
+
+        self.assertEqual(costs, {3: 2999, 4: 3000})
+        request = client.session.get.call_args
+        self.assertTrue(
+            request.args[0].endswith("/daily_reports/get")
+        )
+        self.assertEqual(
+            request.kwargs["params"]["level"],
+            "REPORT_LEVEL_DYNAMIC_CREATIVE",
+        )
+        self.assertEqual(
+            json.loads(request.kwargs["params"]["date_range"]),
+            {"start_date": "2026-08-10", "end_date": "2026-08-12"},
+        )
+        self.assertEqual(
+            json.loads(request.kwargs["params"]["filtering"]),
+            [
+                {
+                    "field": "dynamic_creative_id",
+                    "operator": "IN",
+                    "values": ["3", "4"],
+                }
+            ],
+        )
+        self.assertEqual(request.kwargs["params"]["time_line"], "REQUEST_TIME")
+
+    def test_read_only_account_scan_runs_concurrently_with_isolated_clients(self):
+        import threading
+
+        from report_rejected_creatives_once import collect_rejected_creatives
+
+        barrier = threading.Barrier(2, timeout=2)
+        created_clients = []
+        cost_requests = []
+
+        class Session:
+            def __init__(self):
+                self.closed = False
+
+            def close(self):
+                self.closed = True
+
+        class Client:
+            def __init__(self):
+                self.session = Session()
+                created_clients.append(self)
+
+            def get_dynamic_creatives(self, account_id):
+                barrier.wait()
+                return [{
+                    "dynamic_creative_id": account_id * 10,
+                    "dynamic_creative_name": f"创意{account_id}",
+                    "adgroup_id": account_id * 100,
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "creative_set_approval_status": (
+                        "CREATIVE_SET_APPROVAL_STATUS_DENIED"
+                    ),
+                    "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
+                }]
+
+            def get_ads(self, account_id):
+                return [{
+                    "adgroup_id": account_id * 100,
+                    "adgroup_name": f"广告{account_id}",
+                }]
+
+            def get_dynamic_creative_costs(
+                self, account_id, creative_ids, start_date, end_date
+            ):
+                cost_requests.append(
+                    (account_id, list(creative_ids), start_date, end_date)
+                )
+                return {creative_id: account_id * 100 for creative_id in creative_ids}
+
+        rows, errors, scanned = collect_rejected_creatives(
+            accounts=[{"account_id": 1}, {"account_id": 2}],
+            agency_context={
+                "creative_agencies": {},
+                "account_agencies": {},
+                "account_names": {},
+            },
+            tencent=Mock(),
+            tencent_factory=Client,
+            review_fetcher=lambda _account, _ids: [],
+            checked_at=datetime(2026, 8, 12, 11, 0),
+            max_workers=2,
+        )
+
+        self.assertEqual(errors, [])
+        self.assertEqual(scanned, 2)
+        self.assertEqual([row["账户ID"] for row in rows], [1, 2])
+        self.assertTrue(all(row["配置状态"] == "投放中" for row in rows))
+        self.assertTrue(
+            all(
+                row["创意审核状态"] == "审核拒绝"
+                for row in rows
+            )
+        )
+        self.assertEqual(
+            [row["近3天历史消耗(元)"] for row in rows],
+            ["1.00", "2.00"],
+        )
+        self.assertTrue(all(row["执行操作"] == "删除创意" for row in rows))
+        self.assertEqual(len(cost_requests), 2)
+        self.assertTrue(
+            all(request[2].isoformat() == "2026-08-09" for request in cost_requests)
+        )
+        self.assertTrue(
+            all(request[3].isoformat() == "2026-08-11" for request in cost_requests)
+        )
+        self.assertEqual(len(created_clients), 2)
+        self.assertTrue(all(client.session.closed for client in created_clients))
+
+    def test_read_only_report_marks_partial_at_threshold_for_manual_review(self):
+        from report_rejected_creatives_once import collect_rejected_creatives
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [{
+            "dynamic_creative_id": 3,
+            "dynamic_creative_name": "创意三",
+            "adgroup_id": 13,
+            "configured_status": "AD_STATUS_NORMAL",
+            "creative_set_approval_status": (
+                "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+            ),
+        }]
+        tencent.get_ads.return_value = [{"adgroup_id": 13, "adgroup_name": "广告三"}]
+        tencent.get_dynamic_creative_costs.return_value = {3: 3000}
+
+        rows, errors, scanned = collect_rejected_creatives(
+            accounts=[{"account_id": 1}],
+            agency_context={
+                "creative_agencies": {},
+                "account_agencies": {},
+                "account_names": {},
+            },
+            tencent=tencent,
+            review_fetcher=lambda _account, _ids: [],
+            checked_at=datetime(2026, 8, 12, 11, 0),
+            max_workers=1,
+        )
+
+        self.assertEqual(errors, [])
+        self.assertEqual(scanned, 1)
+        self.assertEqual(len(rows), 1)
+        self.assertEqual(rows[0]["近3天历史消耗(元)"], "30.00")
+        self.assertEqual(rows[0]["消耗日期范围"], "2026-08-09 ~ 2026-08-11")
+        self.assertEqual(rows[0]["执行操作"], "删除创意")
+
+    def test_read_only_workbook_writes_cost_and_action_columns(self):
+        from report_rejected_creatives_once import REPORT_COLUMNS, write_workbook
+
+        row = {column: "" for column in REPORT_COLUMNS}
+        row.update(
+            {
+                "代理名称": "棱镜",
+                "账户ID": 1000000000001,
+                "广告ID": 2000000000002,
+                "创意ID": 3000000000003,
+                "近3天历史消耗(元)": "29.99",
+                "消耗日期范围": "2026-08-10 ~ 2026-08-12",
+                "执行操作": "删除创意",
+                "操作判断原因": "近3天实时消耗低于30元",
+            }
+        )
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "read-only.xlsx"
+            write_workbook([row], path)
+            sheet = load_workbook(path)["审核不通过创意"]
+
+        headers = [cell.value for cell in sheet[1]]
+        self.assertEqual(headers, list(REPORT_COLUMNS))
+        columns = {cell.value: cell.column for cell in sheet[1]}
+        self.assertEqual(sheet.cell(2, columns["近3天历史消耗(元)"]).value, "29.99")
+        self.assertEqual(sheet.cell(2, columns["执行操作"]).value, "删除创意")
+        for column_name in ("账户ID", "广告ID", "创意ID"):
+            cell = sheet.cell(2, columns[column_name])
+            self.assertEqual(cell.data_type, "s")
+            self.assertEqual(cell.number_format, "@")
+
+    def test_account_scope_uses_recent_three_day_opengid_spend(self):
+        from roi_control.data_source import (
+            build_recent_spend_accounts_sql,
+            fetch_recent_spend_accounts,
+        )
+
+        sql = build_recent_spend_accounts_sql("20260808", "20260810")
+        self.assertIn("FROM loghubods.opengid_base_data", sql)
+        self.assertIn("dt BETWEEN '20260808' AND '20260810'", sql)
+        self.assertNotIn("usersharedepth", sql)
+        self.assertNotIn("videoid", sql)
+        self.assertNotIn("hotsencetype", sql)
+        self.assertIn("GROUP BY 账号id", sql)
+        self.assertIn("HAVING SUM(NVL(成本, 0)) > 0", sql)
+        self.assertNotIn("account_whitelist", sql)
+
+        client = Mock()
+        client.execute_sql.return_value = pd.DataFrame(
+            [
+                {"account_id": "1", "account_name": "账户一", "cost_yuan": 10},
+                {"account_id": "", "account_name": "无效", "cost_yuan": 20},
+                {"account_id": "2", "account_name": "账户二", "cost_yuan": 30},
+            ]
+        )
+        self.assertEqual(
+            fetch_recent_spend_accounts(client, "20260808", "20260810"),
+            [
+                {"account_id": 1, "account_name": "账户一", "cost_yuan": 10.0},
+                {"account_id": 2, "account_name": "账户二", "cost_yuan": 30.0},
+            ],
+        )
+
+    def test_account_agency_fallback_uses_latest_active_account_record(self):
+        from roi_control.data_source import (
+            build_account_agency_fallback_sql,
+            fetch_account_agency_fallbacks,
+        )
+
+        sql = build_account_agency_fallback_sql([2, 1, 2])
+        self.assertIn("FROM loghubods.ad_put_tencent_account", sql)
+        self.assertIn("account_id IN ('1', '2')", sql)
+        self.assertIn("NVL(is_delete, 0) = 0", sql)
+        self.assertIn("status = 1", sql)
+        self.assertIn("TRIM(agent_name) <> ''", sql)
+        self.assertIn("ORDER BY id DESC", sql)
+        self.assertIn("WHERE row_number = 1", sql)
+        inner_projection = sql.split("FROM (", 1)[1].split(
+            "FROM loghubods.ad_put_tencent_account", 1
+        )[0]
+        self.assertIn("is_delete", inner_projection)
+        self.assertIn("status", inner_projection)
+
+        client = Mock()
+        client.execute_sql.return_value = pd.DataFrame(
+            [
+                {"account_id": "1", "agent_name": " 代理 A "},
+                {"account_id": "2", "agent_name": float("nan")},
+                {"account_id": "invalid", "agent_name": "代理 B"},
+            ]
+        )
+        self.assertEqual(fetch_account_agency_fallbacks(client, [1, 2]), {1: "代理A"})
+
+    def test_agency_resolution_only_uses_account_table_as_last_fallback(self):
+        from tools.creative_rejection_cleanup import _resolve_agency
+
+        context = {
+            "creative_agencies": {(1, 101): "创意代理"},
+            "account_agencies": {1: "账户代理"},
+            "fallback_account_agencies": {1: "表中代理", 2: "兜底代理"},
+            "account_names": {},
+        }
+
+        self.assertEqual(_resolve_agency(context, 1, 101), "创意代理")
+        self.assertEqual(_resolve_agency(context, 1, 102), "账户代理")
+        self.assertEqual(_resolve_agency(context, 2, 201), "兜底代理")
+
+    def test_cleanup_requires_current_rejection_confirmation(self):
+        from tools.creative_rejection_cleanup import (
+            DELETE_CREATIVE,
+            cleanup_precondition_failure,
+        )
+
+        item = {
+            "account_id": 1,
+            "dynamic_creative_id": 3,
+            "cleanup_status": "DISCOVERED",
+            "cleanup_action": DELETE_CREATIVE,
+        }
+        self.assertEqual(
+            cleanup_precondition_failure(item, set(), set())[0],
+            "DEFERRED",
+        )
+        self.assertEqual(
+            cleanup_precondition_failure(item, {1}, {})[0],
+            "SKIPPED_REVIEW_NOT_RECONFIRMED",
+        )
+        confirmed = {
+            (1, 3): {
+                "cleanup_action": DELETE_CREATIVE,
+            }
+        }
+        self.assertIsNone(cleanup_precondition_failure(item, {1}, confirmed))
+        item["cleanup_action"] = "DELETE_COMPONENTS"
+        self.assertEqual(
+            cleanup_precondition_failure(item, {1}, confirmed)[0],
+            "SKIPPED_REVIEW_NOT_RECONFIRMED",
+        )
+
+    def test_cleanup_action_follows_creative_approval_and_element_statuses(self):
+        from tools.creative_rejection_cleanup import (
+            ALERT_ONLY,
+            DELETE_CREATIVE,
+            determine_cleanup_action,
+        )
+
+        denied_element = {
+            "element_result_list": [
+                {
+                    "element_id": 101,
+                    "review_status": "AD_STATUS_DENIED",
+                    "component_info": {"component_id": 201},
+                }
+            ]
+        }
+        denied_action = determine_cleanup_action(
+                {
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "creative_set_approval_status": (
+                        "CREATIVE_SET_APPROVAL_STATUS_DENIED"
+                    ),
+                },
+                denied_element,
+        )
+        self.assertEqual(denied_action["cleanup_action"], DELETE_CREATIVE)
+        self.assertEqual(denied_action["component_ids"], [])
+        normal_action = determine_cleanup_action(
+                {
+                    "configured_status": "AD_STATUS_SUSPEND",
+                    "creative_set_approval_status": (
+                        "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
+                    ),
+                },
+                denied_element,
+        )
+        self.assertIsNone(normal_action)
+
+        partial = {
+            "creative_set_approval_status": (
+                "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+            )
+        }
+        low_cost = determine_cleanup_action(
+            partial,
+            denied_element,
+            recent_cost_fen=4999,
+        )
+        self.assertEqual(low_cost["cleanup_action"], DELETE_CREATIVE)
+        at_threshold = determine_cleanup_action(
+            partial,
+            denied_element,
+            recent_cost_fen=5000,
+        )
+        self.assertEqual(at_threshold["cleanup_action"], ALERT_ONLY)
+
+    def test_partial_creative_with_rejected_wechat_mini_program_is_deleted(self):
+        from tools.creative_rejection_cleanup import (
+            DELETE_CREATIVE,
+            determine_cleanup_action,
+        )
+
+        action = determine_cleanup_action(
+            {
+                "creative_set_approval_status": (
+                    "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+                )
+            },
+            {
+                "element_result_list": [
+                    {
+                        "element_name": "微信小程序",
+                        "element_id": -8101,
+                        "review_status": "AD_STATUS_DENIED",
+                    }
+                ]
+            },
+            recent_cost_fen=9999,
+        )
+
+        self.assertEqual(action["cleanup_action"], DELETE_CREATIVE)
+        self.assertIn("微信小程序元素审核拒绝", action["action_reason"])
+
+    def test_partial_creative_with_high_cost_rejected_wechat_requires_review(self):
+        from tools.creative_rejection_cleanup import ALERT_ONLY, determine_cleanup_action
+
+        action = determine_cleanup_action(
+            {
+                "creative_set_approval_status": (
+                    "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+                )
+            },
+            {
+                "element_result_list": [{
+                    "element_name": "微信小程序",
+                    "review_status": "AD_STATUS_DENIED",
+                }]
+            },
+            recent_cost_fen=10001,
+        )
+
+        self.assertEqual(action["cleanup_action"], ALERT_ONLY)
+        self.assertIn("需人工判断", action["action_reason"])
+
+    def test_partial_creative_spend_error_only_alerts(self):
+        from tools.creative_rejection_cleanup import ALERT_ONLY, determine_cleanup_action
+
+        action = determine_cleanup_action(
+            {
+                "creative_set_approval_status": (
+                    "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+                )
+            },
+            {},
+            spend_error="timeout",
+        )
+
+        self.assertEqual(action["cleanup_action"], ALERT_ONLY)
+        self.assertIn("消耗读取失败", action["action_reason"])
+
+    def test_cleanup_candidate_insert_has_one_value_per_column(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        class Cursor:
+            def __init__(self):
+                self.calls = []
+
+            def __enter__(self):
+                return self
+
+            def __exit__(self, *_args):
+                return None
+
+            def execute(self, sql, params=None):
+                self.calls.append((sql, params))
+
+            def fetchone(self):
+                return {"id": 7}
+
+        cursor = Cursor()
+        connection = Mock()
+        connection.cursor.return_value = cursor
+        with patch.object(cleanup, "get_connection", return_value=connection):
+            result = cleanup.upsert_cleanup_candidate(
+                {
+                    "account_id": 1,
+                    "account_name": "账户一",
+                    "agency_name": "代理一",
+                    "adgroup_id": 2,
+                    "adgroup_name": "广告二",
+                    "dynamic_creative_id": 3,
+                    "dynamic_creative_name": "创意三",
+                    "check_date": "2026-08-12",
+                    "cleanup_action": cleanup.ALERT_ONLY,
+                    "component_ids": [],
+                    "element_ids": [],
+                    "recent_cost_fen": 3000,
+                    "cost_start_date": "2026-08-10",
+                    "cost_end_date": "2026-08-12",
+                    "reject_reason": "待人工判断",
+                    "review_result": {},
+                    "pre_state": {},
+                }
+            )
+
+        insert_sql, insert_params = cursor.calls[0]
+        values_sql = insert_sql.split("ON DUPLICATE KEY UPDATE", 1)[0]
+        self.assertEqual(values_sql.count("%s"), 19)
+        self.assertEqual(len(insert_params), 19)
+        self.assertEqual(insert_params[-1], "ALERT_PENDING")
+        self.assertIn(
+            "NOT (recent_cost_fen <=> VALUES(recent_cost_fen))",
+            insert_sql,
+        )
+        self.assertEqual(result, {"id": 7})
+        connection.close.assert_called_once()
+
+    def test_retry_query_only_loads_whole_creative_deletes(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        cursor = Mock()
+        cursor.__enter__ = Mock(return_value=cursor)
+        cursor.__exit__ = Mock(return_value=None)
+        cursor.fetchall.return_value = []
+        connection = Mock()
+        connection.cursor.return_value = cursor
+
+        with patch.object(cleanup, "get_connection", return_value=connection):
+            self.assertEqual(cleanup.load_retryable_cleanup_items(), [])
+
+        sql = cursor.execute.call_args.args[0]
+        self.assertIn("cleanup_action='DELETE_CREATIVE'", sql)
+        self.assertNotIn("COMPONENTS_PARTIAL", sql)
+
+    def test_cleanup_action_does_not_delete_for_overall_or_site_denial(self):
+        from tools.creative_rejection_cleanup import determine_cleanup_action
+
+        creative = {
+            "configured_status": "AD_STATUS_NORMAL",
+            "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
+        }
+        result = {
+            "site_set_result_list": [
+                {"system_status": "DYNAMIC_CREATIVE_STATUS_DENIED"}
+            ]
+        }
+        self.assertIsNone(determine_cleanup_action(creative, result))
+
+    def test_denied_element_is_not_a_cleanup_candidate(self):
+        from tools.creative_rejection_cleanup import determine_cleanup_action
+
+        action = determine_cleanup_action(
+            {"configured_status": "AD_STATUS_NORMAL"},
+            {
+                "element_result_list": [
+                    {"element_id": 101, "system_status": "AD_STATUS_DENIED"}
+                ]
+            },
+        )
+        self.assertIsNone(action)
+
+    def test_read_only_report_skips_non_rule_element_denial(self):
+        from report_rejected_creatives_once import collect_rejected_creatives
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [{
+            "dynamic_creative_id": 3,
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+            "creative_set_approval_status": "CREATIVE_SET_APPROVAL_STATUS_NORMAL",
+        }]
+        tencent.get_ads.return_value = [{"adgroup_id": 2}]
+        review = {
+            "dynamic_creative_id": 3,
+            "element_result_list": [{
+                "element_name": "图片",
+                "review_status": "AD_STATUS_DENIED",
+            }],
+        }
+
+        rows, errors, _ = collect_rejected_creatives(
+            accounts=[{"account_id": 1}],
+            agency_context={
+                "creative_agencies": {},
+                "account_agencies": {},
+                "account_names": {},
+            },
+            tencent=tencent,
+            review_fetcher=lambda _account, _ids: [review],
+            checked_at=datetime(2026, 8, 12, 11, 0),
+            max_workers=1,
+        )
+
+        self.assertEqual(errors, [])
+        self.assertEqual(rows, [])
+
+    def test_tencent_client_uses_official_creative_delete_endpoint(self):
+        from tencent_client import TencentClient
+
+        response = Mock(status_code=200, text="ok")
+        response.raise_for_status.return_value = None
+        response.json.return_value = {"code": 0, "data": {}}
+        client = TencentClient()
+        client.session.post = Mock(return_value=response)
+        client._common_params = Mock(return_value={"nonce": "1"})
+        client._user_token = Mock(return_value="user-token")
+        client.get_dynamic_creative = Mock(
+            side_effect=RuntimeError("Dynamic creative not found: account=1 creative=3")
+        )
+
+        result = client.delete_dynamic_creative(1, 3)
+
+        request = client.session.post.call_args
+        self.assertTrue(request.args[0].endswith("/dynamic_creatives/delete"))
+        self.assertEqual(
+            request.kwargs["json"],
+            {"account_id": 1, "dynamic_creative_id": 3},
+        )
+        self.assertEqual(result["configured_status"], "AD_STATUS_DELETED")
+
+    def test_tencent_client_uses_component_id_for_component_delete(self):
+        from tencent_client import TencentClient
+
+        response = Mock(status_code=200, text="ok")
+        response.raise_for_status.return_value = None
+        response.json.return_value = {"code": 0, "data": {}}
+        client = TencentClient()
+        client.session.post = Mock(return_value=response)
+        client._common_params = Mock(return_value={"nonce": "1"})
+        client.get_creative_component = Mock(
+            return_value={"component_id": 201, "is_deleted": True}
+        )
+
+        result = client.delete_creative_component(1, 201)
+
+        request = client.session.post.call_args
+        self.assertTrue(request.args[0].endswith("/components/delete"))
+        self.assertEqual(request.kwargs["json"]["component_id"], 201)
+        self.assertEqual(
+            request.kwargs["json"]["delete_strategy"],
+            "DELETE_STRATEGY_FORCE",
+        )
+        self.assertTrue(result["is_deleted"])
+
+    def test_agency_context_prefers_creative_and_rejects_ambiguous_fallback(self):
+        from tools.creative_rejection_cleanup import build_agency_context
+
+        daily = pd.DataFrame(
+            [
+                {
+                    "entity_type": "self",
+                    "账号id": "1",
+                    "账号名称": "账户一",
+                    "创意id": "101",
+                    "代理名称": "小程序-代投-棱镜",
+                },
+                {
+                    "entity_type": "self",
+                    "账号id": "2",
+                    "账号名称": "账户二",
+                    "创意id": "201",
+                    "代理名称": "代理A",
+                },
+                {
+                    "entity_type": "self",
+                    "账号id": "2",
+                    "账号名称": "账户二",
+                    "创意id": "202",
+                    "代理名称": "代理B",
+                },
+                {
+                    "entity_type": "self",
+                    "账号id": "3",
+                    "账号名称": "账户三",
+                    "创意id": "301",
+                    "代理名称": float("nan"),
+                },
+            ]
+        )
+        context = build_agency_context(daily)
+
+        self.assertEqual(
+            context["creative_agencies"][(1, 101)],
+            "小程序-代投-棱镜",
+        )
+        self.assertEqual(context["account_agencies"][1], "小程序-代投-棱镜")
+        self.assertNotIn(2, context["account_agencies"])
+        self.assertNotIn(3, context["account_agencies"])
+
+    def test_agency_context_uses_latest_date_regardless_of_row_order(self):
+        from tools.creative_rejection_cleanup import build_agency_context
+
+        rows = [
+            {
+                "entity_type": "self",
+                "dt": "20260810",
+                "账号id": "1",
+                "创意id": "101",
+                "代理名称": "旧代理",
+            },
+            {
+                "entity_type": "self",
+                "dt": "20260812",
+                "账号id": "1",
+                "创意id": "101",
+                "代理名称": "新代理",
+            },
+        ]
+        for ordered_rows in (rows, list(reversed(rows))):
+            context = build_agency_context(pd.DataFrame(ordered_rows))
+            self.assertEqual(context["creative_agencies"][(1, 101)], "新代理")
+            self.assertEqual(context["account_agencies"][1], "新代理")
+
+    def test_agency_context_rejects_conflicting_agencies_on_latest_date(self):
+        from tools.creative_rejection_cleanup import build_agency_context
+
+        context = build_agency_context(pd.DataFrame([
+            {
+                "entity_type": "self",
+                "dt": "20260812",
+                "账号id": "1",
+                "创意id": "101",
+                "代理名称": "代理A",
+            },
+            {
+                "entity_type": "self",
+                "dt": "20260812",
+                "账号id": "1",
+                "创意id": "101",
+                "代理名称": "代理B",
+            },
+        ]))
+
+        self.assertNotIn((1, 101), context["creative_agencies"])
+        self.assertNotIn(1, context["account_agencies"])
+
+    def test_agency_report_run_id_is_independent_per_destination(self):
+        from tools.creative_rejection_cleanup import write_cleanup_reports
+
+        def row(item_id, agency):
+            return {
+                "id": item_id,
+                "agency_name": agency,
+                "account_id": item_id,
+                "adgroup_id": item_id + 10,
+                "dynamic_creative_id": item_id + 20,
+                "cleanup_action": "ALERT_ONLY",
+                "cleanup_status": "ALERT_PENDING",
+                "reject_reason": "需人工判断",
+            }
+
+        with tempfile.TemporaryDirectory() as directory:
+            _, one_report, _ = write_cleanup_reports(
+                [row(1, "代理A")], Path(directory), "20260812"
+            )
+            _, two_reports, _ = write_cleanup_reports(
+                [row(1, "代理A"), row(2, "代理B")],
+                Path(directory),
+                "20260812",
+            )
+
+        run_id_a = next(
+            report["run_id"]
+            for report in two_reports
+            if report["agency_name"] == "代理A"
+        )
+        self.assertEqual(one_report[0]["run_id"], run_id_a)
+
+    def test_daily_cleanup_schema_uses_check_date_in_unique_key(self):
+        schema = (
+            Path(__file__).parents[1]
+            / "tencent_realtime_control"
+            / "schema.sql"
+        ).read_text(encoding="utf-8")
+        self.assertIn(
+            "(account_id, dynamic_creative_id, check_date)",
+            schema,
+        )
+
+    def test_cleanup_report_has_required_business_columns(self):
+        from tools.creative_rejection_cleanup import (
+            REPORT_COLUMNS,
+            write_cleanup_reports,
+        )
+
+        now = datetime(2026, 8, 11, 11, 0)
+        with tempfile.TemporaryDirectory() as directory:
+            _, reports, item_ids = write_cleanup_reports(
+                [
+                    {
+                        "id": 7,
+                        "agency_name": "棱镜",
+                        "account_id": 1000000000001,
+                        "account_name": "账户一",
+                        "adgroup_id": 2000000000002,
+                        "adgroup_name": "广告二",
+                        "dynamic_creative_id": 3000000000003,
+                        "dynamic_creative_name": "创意三",
+                        "cleanup_action": "DELETE_CREATIVE",
+                        "target_element_ids_json": "[]",
+                        "target_component_ids_json": "[]",
+                        "cleanup_status": "CREATIVE_DELETED",
+                        "recent_cost_fen": 54261,
+                        "cost_start_date": "2026-08-08",
+                        "cost_end_date": "2026-08-10",
+                        "action_reason": "创意审核状态为审核拒绝",
+                        "reject_reason": "图片违规",
+                        "review_result_json": json.dumps(
+                            {
+                                "element_result_list": [
+                                    {
+                                        "element_name": "主图",
+                                        "image_id": "10001",
+                                        "review_status": "REVIEW_STATUS_REJECTED",
+                                        "element_reject_detail_info": [
+                                            {"reason": "图片违规"}
+                                        ],
+                                    },
+                                    {
+                                        "element_name": "正常元素",
+                                        "element_id": "30003",
+                                        "system_status": "AD_STATUS_NORMAL",
+                                    },
+                                ],
+                                "site_set_result_list": [
+                                    {
+                                        "site_set": "SITE_SET_MOMENTS",
+                                        "site_set_id": "40004",
+                                        "system_status": (
+                                            "DYNAMIC_CREATIVE_STATUS_DENIED"
+                                        ),
+                                        "reject_message": "朋友圈版位拒绝",
+                                    },
+                                    {
+                                        "site_set": "SITE_SET_NORMAL",
+                                        "review_status": "AD_STATUS_NORMAL",
+                                    },
+                                ],
+                            },
+                            ensure_ascii=False,
+                        ),
+                        "pre_state_json": json.dumps(
+                            {
+                                "configured_status": "AD_STATUS_NORMAL",
+                                "creative_set_approval_status": (
+                                    "CREATIVE_SET_APPROVAL_STATUS_DENIED"
+                                ),
+                            }
+                        ),
+                        "deleted_at": now,
+                    }
+                ],
+                Path(directory),
+                "20260811",
+            )
+            workbook = load_workbook(reports[0]["report"])
+            sheet = workbook["审核不通过创意清理"]
+
+        headers = [cell.value for cell in sheet[1]]
+        self.assertEqual(
+            headers,
+            [
+                "代理名称",
+                "账户ID",
+                "账户名称",
+                "广告ID",
+                "广告名称",
+                "创意ID",
+                "创意名称",
+                "配置状态",
+                "创意审核状态",
+                "元素粒度审核状态",
+                "元素粒度审核不通过原因",
+                "版位粒度审核状态",
+                "版位粒度审核不通过原因",
+                "审核不通过原因",
+                "检查时间",
+                "执行操作",
+            ],
+        )
+        self.assertEqual(headers, list(REPORT_COLUMNS))
+        for column_index, expected in (
+            (2, "1000000000001"),
+            (4, "2000000000002"),
+            (6, "3000000000003"),
+        ):
+            cell = sheet.cell(2, column_index)
+            self.assertEqual(cell.value, expected)
+            self.assertEqual(cell.data_type, "s")
+            self.assertEqual(cell.number_format, "@")
+        by_header = {cell.value: cell.column for cell in sheet[1]}
+        self.assertEqual(sheet.cell(2, by_header["配置状态"]).value, "投放中")
+        self.assertEqual(
+            sheet.cell(2, by_header["创意审核状态"]).value,
+            "审核拒绝",
+        )
+        self.assertEqual(
+            sheet.cell(2, by_header["元素粒度审核状态"]).value,
+            "主图(id=10001): 审核拒绝",
+        )
+        self.assertEqual(
+            sheet.cell(2, by_header["元素粒度审核不通过原因"]).value,
+            "主图(id=10001): 图片违规",
+        )
+        self.assertEqual(
+            sheet.cell(2, by_header["版位粒度审核状态"]).value,
+            "SITE_SET_MOMENTS(id=40004): 审核拒绝",
+        )
+        self.assertEqual(
+            sheet.cell(2, by_header["版位粒度审核不通过原因"]).value,
+            "SITE_SET_MOMENTS(id=40004): 朋友圈版位拒绝",
+        )
+        self.assertEqual(
+            sheet.cell(2, by_header["执行操作"]).value,
+            "删除创意",
+        )
+        self.assertEqual(sheet.cell(2, by_header["代理名称"]).value, "棱镜")
+        self.assertEqual(
+            sheet.cell(2, by_header["审核不通过原因"]).value,
+            "图片违规",
+        )
+        self.assertEqual(
+            sheet.cell(2, by_header["检查时间"]).value,
+            "2026-08-11 11:00:00",
+        )
+        for removed_header in (
+            "近3天历史消耗(元)",
+            "消耗日期范围",
+            "操作判断原因",
+            "元素ID",
+            "组件ID",
+            "处理结果",
+            "处理时间",
+        ):
+            self.assertNotIn(removed_header, by_header)
+        self.assertEqual(item_ids, {"棱镜": [7]})
+        self.assertEqual(reports[0]["notification_type"], "creative_rejection_cleanup")
+
+    def test_cleanup_report_displays_partial_creative_manual_alert(self):
+        from tools.creative_rejection_cleanup import write_cleanup_reports
+
+        with tempfile.TemporaryDirectory() as directory:
+            _, reports, _ = write_cleanup_reports(
+                [
+                    {
+                        "id": 8,
+                        "agency_name": "棱镜",
+                        "account_id": 1,
+                        "account_name": "账户一",
+                        "adgroup_id": 2,
+                        "adgroup_name": "广告二",
+                        "dynamic_creative_id": 3,
+                        "dynamic_creative_name": "创意三",
+                        "cleanup_action": "ALERT_ONLY",
+                        "target_element_ids_json": "[]",
+                        "target_component_ids_json": "[]",
+                        "cleanup_status": "ALERT_PENDING",
+                        "recent_cost_fen": 3000,
+                        "cost_start_date": "2026-08-10",
+                        "cost_end_date": "2026-08-12",
+                        "reject_reason": "部分投放中,需人工判断是否删除",
+                        "review_result_json": "{}",
+                        "pre_state_json": json.dumps(
+                            {
+                                "creative_set_approval_status": (
+                                    "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+                                )
+                            }
+                        ),
+                        "updated_at": datetime(2026, 8, 12, 11, 0),
+                    }
+                ],
+                Path(directory),
+                "20260812",
+            )
+            sheet = load_workbook(reports[0]["report"])["审核不通过创意清理"]
+
+        columns = {cell.value: cell.column for cell in sheet[1]}
+        self.assertEqual(sheet.cell(2, columns["执行操作"]).value, "需人工判断")
+        self.assertEqual(sheet.cell(2, columns["创意审核状态"]).value, "部分投放中")
+        self.assertNotIn("近3天历史消耗(元)", columns)
+        self.assertNotIn("操作判断原因", columns)
+
+    def test_operator_summary_report_contains_all_agencies(self):
+        from tools.creative_rejection_cleanup import (
+            write_cleanup_operator_summary,
+        )
+
+        rows = [
+            {
+                "id": index,
+                "agency_name": agency,
+                "account_id": index,
+                "account_name": f"账户{index}",
+                "adgroup_id": index + 10,
+                "adgroup_name": f"广告{index}",
+                "dynamic_creative_id": index + 20,
+                "dynamic_creative_name": f"创意{index}",
+                "cleanup_action": "DELETE_CREATIVE",
+                "reject_reason": "图片违规",
+                "review_result_json": "{}",
+                "pre_state_json": "{}",
+                "updated_at": datetime(2026, 8, 12, 11, 0),
+            }
+            for index, agency in ((1, "代理A"), (2, "代理B"), (3, ""))
+        ]
+        with tempfile.TemporaryDirectory() as directory:
+            report = write_cleanup_operator_summary(
+                rows,
+                Path(directory),
+                "20260812",
+                "reject_20260812_abc123",
+            )
+            sheet = load_workbook(report["report"])["审核不通过创意清理"]
+
+        agencies = [sheet.cell(row, 1).value or "" for row in range(2, 5)]
+        self.assertEqual(agencies, ["代理A", "代理B", ""])
+        self.assertEqual(report["creative_rows"], 3)
+        self.assertIn("投放调控", Path(report["report"]).name)
+
+    def test_operator_summary_reuses_uploaded_sheet_when_notification_retries(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        publisher = Mock()
+        publisher.send_report_card.return_value = "message-1"
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "summary.xlsx"
+            path.write_bytes(b"xlsx")
+            report = {
+                "report_version": "summary-v1",
+                "report": str(path),
+                "title": "创意审核异常处理汇总",
+                "creative_rows": 3,
+            }
+            with patch.object(
+                cleanup,
+                "upsert_cleanup_delivery",
+                return_value={
+                    "id": 9,
+                    "status": "FAILED",
+                    "sheet_url": "https://example.test/existing-summary",
+                    "sheet_token": "existing-token",
+                },
+            ), patch.object(cleanup, "update_cleanup_delivery") as update:
+                outcome = cleanup.publish_cleanup_operator_summary(
+                    run_id="reject_20260812_abc123",
+                    report=report,
+                    chat_id="chat-operator",
+                    publisher=publisher,
+                    now=datetime(2026, 8, 12, 11, 0),
+                )
+
+        publisher.upload_workbook.assert_not_called()
+        publisher.send_report_card.assert_called_once_with(
+            title="创意审核异常处理汇总",
+            content="本批次共 **3** 条创意,包含各代理自动删除及需人工判断的完整汇总。",
+            sheet_url="https://example.test/existing-summary",
+            chat_id="chat-operator",
+            button_text="查看全部处理明细",
+        )
+        self.assertEqual(outcome["status"], "SENT")
+        self.assertEqual(update.call_args.kwargs["status"], "SENT")
+
+    def test_apply_deletes_denied_creative_and_marks_sent_notification(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        class FakeTencent:
+            def __init__(self):
+                self.deleted = []
+                self.seeded_tokens = {}
+
+            def seed_access_tokens(self, tokens):
+                self.seeded_tokens.update(tokens)
+
+            def get_dynamic_creatives(self, account_id):
+                return [
+                    {
+                        "dynamic_creative_id": 3,
+                        "dynamic_creative_name": "创意三",
+                        "adgroup_id": 2,
+                        "configured_status": "AD_STATUS_NORMAL",
+                        "creative_set_approval_status": (
+                            "CREATIVE_SET_APPROVAL_STATUS_DENIED"
+                        ),
+                        "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
+                    }
+                ]
+
+            def get_ads(self, account_id):
+                return [{"adgroup_id": 2, "adgroup_name": "广告二"}]
+
+            def get_dynamic_creative(self, account_id, creative_id):
+                return {
+                    "dynamic_creative_id": creative_id,
+                    "adgroup_id": 2,
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "creative_set_approval_status": (
+                        "CREATIVE_SET_APPROVAL_STATUS_DENIED"
+                    ),
+                    "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
+                }
+
+            def delete_dynamic_creative(self, account_id, creative_id):
+                self.deleted.append((account_id, creative_id))
+                return {
+                    "dynamic_creative_id": creative_id,
+                    "configured_status": "AD_STATUS_DELETED",
+                }
+
+        tencent = FakeTencent()
+        current = datetime(2026, 8, 11, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+        retry_item = {
+            "id": 7,
+            "account_id": 1,
+            "account_name": "账户一",
+            "agency_name": "棱镜",
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "dynamic_creative_id": 3,
+            "dynamic_creative_name": "创意三",
+            "cleanup_action": "DELETE_CREATIVE",
+            "target_component_ids_json": "[]",
+            "target_element_ids_json": "[]",
+            "reject_reason": "图片违规",
+            "cleanup_status": "DISCOVERED",
+        }
+        deleted_item = {
+            **retry_item,
+            "cleanup_status": "CREATIVE_DELETED",
+            "deleted_at": current,
+        }
+        updates = []
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
+                "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
+                    {
+                        "棱镜": (
+                            "https://open.feishu.cn/open-apis/bot/v2/hook/"
+                            "test-cleanup-route"
+                        )
+                    }
+                ),
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260810"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame(
+                [
+                    {
+                        "entity_type": "self",
+                        "账号id": "1",
+                        "账号名称": "账户一",
+                        "创意id": "3",
+                        "代理名称": "棱镜",
+                    }
+                ]
+            )
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup,
+            "prefetch_account_access_tokens",
+            return_value={1: "access-token-account-1"},
+        ), patch.object(
+            cleanup, "upsert_cleanup_candidate"
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup,
+            "update_cleanup_item",
+            side_effect=lambda item_id, **values: updates.append((item_id, values)),
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[deleted_item]
+        ), patch.object(
+            cleanup, "advisory_lock", side_effect=acquired_lock
+        ), patch.object(
+            cleanup,
+            "publish_agency_reports",
+            return_value=[{"agency_name": "棱镜", "status": "SENT"}],
+        ), patch.object(
+            cleanup,
+            "publish_cleanup_operator_summary",
+            return_value={"route": "投放调控汇总", "status": "SENT"},
+        ), patch.object(
+            cleanup, "mark_cleanup_items_notified"
+        ) as mark_notified, patch.object(
+            cleanup, "mark_cleanup_items_operator_notified"
+        ) as mark_operator_notified:
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=current,
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=lambda _account, _ids: [
+                    {
+                        "dynamic_creative_id": 3,
+                        "reject_message_list": ["图片违规"],
+                    }
+                ],
+                publisher=Mock(),
+            )
+
+        self.assertEqual(tencent.deleted, [(1, 3)])
+        self.assertEqual(tencent.seeded_tokens, {1: "access-token-account-1"})
+        self.assertEqual(summary["account_scope"], "opengid_recent_3d_spend")
+        self.assertEqual(summary["account_scope_start_date"], "20260808")
+        self.assertEqual(summary["account_scope_end_date"], "20260810")
+        self.assertEqual(summary["account_ids"], [1])
+        self.assertEqual(summary["tokens_prefetched"], 1)
+        self.assertEqual(summary["deleted"], 1)
+        self.assertTrue(
+            any(
+                values.get("cleanup_status") == "CREATIVE_DELETED"
+                for _, values in updates
+            )
+        )
+        mark_notified.assert_called_once_with([7], current)
+        mark_operator_notified.assert_called_once_with([7], current)
+
+    def test_apply_deletes_partial_below_threshold_and_alerts_at_threshold(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        class FakeTencent:
+            def __init__(self):
+                self.deleted = []
+                self.cost_requests = []
+
+            def seed_access_tokens(self, _tokens):
+                return None
+
+            def get_dynamic_creatives(self, _account_id):
+                return [
+                    {
+                        "dynamic_creative_id": creative_id,
+                        "dynamic_creative_name": f"创意{creative_id}",
+                        "adgroup_id": creative_id + 10,
+                        "configured_status": "AD_STATUS_NORMAL",
+                        "creative_set_approval_status": (
+                            "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+                        ),
+                    }
+                    for creative_id in (3, 4)
+                ]
+
+            def get_ads(self, _account_id):
+                return [
+                    {"adgroup_id": creative_id + 10, "adgroup_name": f"广告{creative_id}"}
+                    for creative_id in (3, 4)
+                ]
+
+            def get_dynamic_creative(self, _account_id, creative_id):
+                return {
+                    "dynamic_creative_id": creative_id,
+                    "adgroup_id": creative_id + 10,
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "creative_set_approval_status": (
+                        "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+                    ),
+                }
+
+            def get_dynamic_creative_costs(
+                self, account_id, creative_ids, start_date, end_date
+            ):
+                self.cost_requests.append(
+                    (account_id, list(creative_ids), start_date, end_date)
+                )
+                return {
+                    creative_id: {3: 2999, 4: 3000}[creative_id]
+                    for creative_id in creative_ids
+                }
+
+            def delete_dynamic_creative(self, account_id, creative_id):
+                self.deleted.append((account_id, creative_id))
+                return {"dynamic_creative_id": creative_id, "deleted_from_listing": True}
+
+        current = datetime(2026, 8, 12, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+        retry_item = {
+            "id": 7,
+            "account_id": 1,
+            "agency_name": "棱镜",
+            "adgroup_id": 13,
+            "dynamic_creative_id": 3,
+            "cleanup_action": "DELETE_CREATIVE",
+            "target_component_ids_json": "[]",
+            "target_element_ids_json": "[]",
+            "cleanup_status": "DISCOVERED",
+        }
+        candidates = []
+        updates = []
+        tencent = FakeTencent()
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN": "30",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
+                "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
+                    {
+                        "棱镜": (
+                            "https://open.feishu.cn/open-apis/bot/v2/hook/"
+                            "test-cleanup-route"
+                        )
+                    }
+                ),
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260811"
+        ), patch.object(
+            cleanup,
+            "fetch_daily_data",
+            return_value=pd.DataFrame(
+                [
+                    {
+                        "entity_type": "self",
+                        "账号id": "1",
+                        "账号名称": "账户一",
+                        "创意id": str(creative_id),
+                        "代理名称": "棱镜",
+                    }
+                    for creative_id in (3, 4)
+                ]
+            ),
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
+        ), patch.object(
+            cleanup,
+            "upsert_cleanup_candidate",
+            side_effect=lambda record: candidates.append(record) or {"id": len(candidates)},
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup,
+            "update_cleanup_item",
+            side_effect=lambda item_id, **values: updates.append((item_id, values)),
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ), patch.object(
+            cleanup, "advisory_lock", side_effect=acquired_lock
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=current,
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=lambda _account, _ids: [],
+            )
+
+        actions = {
+            row["dynamic_creative_id"]: row["cleanup_action"] for row in candidates
+        }
+        self.assertEqual(actions, {3: "DELETE_CREATIVE", 4: "ALERT_ONLY"})
+        self.assertEqual(tencent.deleted, [(1, 3)])
+        self.assertEqual(summary["deleted"], 1)
+        self.assertEqual(len(tencent.cost_requests), 2)
+        for _, _, start_date, end_date in tencent.cost_requests:
+            self.assertEqual(start_date.isoformat(), "2026-08-09")
+            self.assertEqual(end_date.isoformat(), "2026-08-11")
+        self.assertTrue(
+            any(
+                values.get("cleanup_status") == "CREATIVE_DELETED"
+                for _, values in updates
+            )
+        )
+
+    def test_lock_time_spend_failure_downgrades_delete_to_manual_alert(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        class FakeTencent:
+            def __init__(self):
+                self.cost_call_count = 0
+                self.deleted = []
+
+            def seed_access_tokens(self, _tokens):
+                return None
+
+            def get_dynamic_creatives(self, _account_id):
+                return [{
+                    "dynamic_creative_id": 3,
+                    "adgroup_id": 13,
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "creative_set_approval_status": (
+                        "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+                    ),
+                }]
+
+            def get_ads(self, _account_id):
+                return [{"adgroup_id": 13}]
+
+            def get_dynamic_creative(self, _account_id, _creative_id):
+                return self.get_dynamic_creatives(1)[0]
+
+            def get_dynamic_creative_costs(self, *_args):
+                self.cost_call_count += 1
+                if self.cost_call_count == 1:
+                    return {3: 2999}
+                raise RuntimeError("cost API timeout")
+
+            def delete_dynamic_creative(self, account_id, creative_id):
+                self.deleted.append((account_id, creative_id))
+
+        retry_item = {
+            "id": 7,
+            "account_id": 1,
+            "agency_name": "棱镜",
+            "adgroup_id": 13,
+            "dynamic_creative_id": 3,
+            "cleanup_action": "DELETE_CREATIVE",
+            "target_component_ids_json": "[]",
+            "target_element_ids_json": "[]",
+            "cleanup_status": "DISCOVERED",
+        }
+        updates = []
+        tencent = FakeTencent()
+        current = datetime(2026, 8, 12, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
+                "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
+                    {
+                        "棱镜": (
+                            "https://open.feishu.cn/open-apis/bot/v2/hook/"
+                            "test-cleanup-route"
+                        )
+                    }
+                ),
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260811"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
+        ), patch.object(
+            cleanup, "upsert_cleanup_candidate"
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup,
+            "update_cleanup_item",
+            side_effect=lambda item_id, **values: updates.append((item_id, values)),
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ), patch.object(
+            cleanup, "advisory_lock", side_effect=acquired_lock
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=current,
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=lambda _account, _ids: [],
+            )
+
+        self.assertEqual(tencent.deleted, [])
+        self.assertEqual(summary["deleted"], 0)
+        alert_update = next(
+            values
+            for _, values in updates
+            if values.get("cleanup_status") == "ALERT_PENDING"
+        )
+        self.assertEqual(alert_update["cleanup_action"], "ALERT_ONLY")
+        self.assertIsNone(alert_update["recent_cost_fen"])
+        self.assertIn("消耗读取失败", alert_update["action_reason"])
+        self.assertEqual(alert_update["reject_reason"], "腾讯正式审核未通过")
+
+    def test_historical_component_cleanup_candidate_is_never_executed(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        class FakeTencent:
+            def __init__(self):
+                self.deleted_creatives = []
+                self.deleted_components = []
+
+            def seed_access_tokens(self, _tokens):
+                return None
+
+            def get_dynamic_creatives(self, _account_id):
+                return [{
+                    "dynamic_creative_id": 3,
+                    "adgroup_id": 2,
+                    "configured_status": "AD_STATUS_NORMAL",
+                }]
+
+            def get_ads(self, _account_id):
+                return [{"adgroup_id": 2}]
+
+            def get_dynamic_creative(self, _account_id, _creative_id):
+                return {
+                    "dynamic_creative_id": 3,
+                    "adgroup_id": 2,
+                    "configured_status": "AD_STATUS_NORMAL",
+                }
+
+            def get_creative_component(
+                self, _account_id, component_id, *, include_deleted=True
+            ):
+                return {"component_id": component_id, "is_deleted": False}
+
+            def delete_creative_component(self, account_id, component_id):
+                self.deleted_components.append((account_id, component_id))
+                return {"component_id": component_id, "is_deleted": True}
+
+            def delete_dynamic_creative(self, account_id, creative_id):
+                self.deleted_creatives.append((account_id, creative_id))
+
+        review = {
+            "dynamic_creative_id": 3,
+            "element_result_list": [
+                {
+                    "element_id": 102,
+                    "review_status": "AD_STATUS_DENIED",
+                    "component_info": {"component_id": 12},
+                },
+            ],
+        }
+        retry_item = {
+            "id": 7,
+            "account_id": 1,
+            "agency_name": "",
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "cleanup_action": "DELETE_COMPONENTS",
+            "target_component_ids_json": "[11, 12]",
+            "target_element_ids_json": "[101, 102]",
+            "readback_json": json.dumps(
+                {"component_results": {"11": {"status": "DELETED"}}}
+            ),
+            "cleanup_status": "COMPONENTS_PARTIAL",
+        }
+        tencent = FakeTencent()
+        updates = []
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
+                "ROI_AGENCY_WEBHOOKS_JSON": "{}",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260810"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
+        ), patch.object(
+            cleanup, "upsert_cleanup_candidate"
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup,
+            "update_cleanup_item",
+            side_effect=lambda item_id, **values: updates.append((item_id, values)),
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ), patch.object(
+            cleanup, "advisory_lock", side_effect=acquired_lock
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=lambda _account, _ids: [review],
+            )
+
+        self.assertEqual(tencent.deleted_creatives, [])
+        self.assertEqual(tencent.deleted_components, [])
+        self.assertEqual(summary["deleted"], 0)
+        self.assertTrue(
+            any(
+                values.get("cleanup_status") == "SKIPPED_REVIEW_NOT_RECONFIRMED"
+                for _, values in updates
+            )
+        )
+
+    def test_historical_component_candidate_without_id_is_never_executed(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [{
+            "dynamic_creative_id": 3,
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+        }]
+        tencent.get_ads.return_value = [{"adgroup_id": 2}]
+        tencent.get_dynamic_creative.return_value = {
+            "dynamic_creative_id": 3,
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+        }
+        review = {
+            "dynamic_creative_id": 3,
+            "element_result_list": [
+                {"element_id": 101, "review_status": "AD_STATUS_DENIED"}
+            ],
+        }
+        retry_item = {
+            "id": 7,
+            "account_id": 1,
+            "agency_name": "",
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "cleanup_action": "DELETE_COMPONENTS",
+            "target_component_ids_json": "[]",
+            "target_element_ids_json": "[101]",
+            "cleanup_status": "DISCOVERED",
+        }
+        updates = []
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
+                "ROI_AGENCY_WEBHOOKS_JSON": "{}",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260810"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
+        ), patch.object(
+            cleanup, "upsert_cleanup_candidate"
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup,
+            "update_cleanup_item",
+            side_effect=lambda item_id, **values: updates.append((item_id, values)),
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ), patch.object(
+            cleanup, "advisory_lock", side_effect=acquired_lock
+        ):
+            cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=lambda _account, _ids: [review],
+            )
+
+        tencent.delete_dynamic_creative.assert_not_called()
+        tencent.delete_creative_component.assert_not_called()
+        self.assertTrue(
+            any(
+                values.get("cleanup_status") == "SKIPPED_REVIEW_NOT_RECONFIRMED"
+                for _, values in updates
+            )
+        )
+
+    def test_dry_run_discovers_but_never_deletes(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [
+            {
+                "dynamic_creative_id": 3,
+                "adgroup_id": 2,
+                "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
+            }
+        ]
+        tencent.get_ads.return_value = [{"adgroup_id": 2}]
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260810"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup,
+            "prefetch_account_access_tokens",
+            return_value={1: "access-token-account-1"},
+        ), patch.object(
+            cleanup, "upsert_cleanup_candidate"
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[{"id": 7}]
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=lambda _account, _ids: [],
+            )
+
+        tencent.delete_dynamic_creative.assert_not_called()
+        self.assertFalse(summary["apply_enabled"])
+        self.assertEqual(summary["pending_cleanup"], 1)
+
+    def test_disabled_webhook_keeps_manual_alert_pending_without_feishu_client(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = []
+        tencent.get_ads.return_value = []
+        pending_alert = {
+            "id": 7,
+            "agency_name": "棱镜",
+            "cleanup_status": "ALERT_PENDING",
+        }
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_APP_ID": "",
+                "FEISHU_APP_SECRET": "",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260810"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[]
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={}
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[]
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[pending_alert]
+        ), patch.object(
+            cleanup, "RoiFeishuPublisher"
+        ) as publisher, patch.object(
+            cleanup, "publish_agency_reports"
+        ) as publish:
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                tencent=tencent,
+                odps=Mock(),
+            )
+
+        publisher.assert_not_called()
+        publish.assert_not_called()
+        self.assertEqual(summary["deliveries"], [])
+
+    def test_apply_requires_operator_chat_before_any_external_work(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        with patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
+                    {
+                        "棱镜": (
+                            "https://open.feishu.cn/open-apis/bot/v2/hook/"
+                            "test-cleanup-route"
+                        )
+                    }
+                ),
+                "FEISHU_OPERATOR_CHAT_ID": "",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema") as initialize:
+            with self.assertRaisesRegex(RuntimeError, "FEISHU_OPERATOR_CHAT_ID"):
+                cleanup.run_rejected_creative_cleanup(output_dir=Path("unused"))
+
+        initialize.assert_not_called()
+
+    def test_missing_agency_defers_before_tencent_delete(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [{
+            "dynamic_creative_id": 3,
+            "adgroup_id": 2,
+            "creative_set_approval_status": cleanup.CREATIVE_DENIED_STATUS,
+        }]
+        tencent.get_ads.return_value = [{"adgroup_id": 2}]
+        retry_item = {
+            "id": 7,
+            "account_id": 1,
+            "agency_name": "",
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "cleanup_action": cleanup.DELETE_CREATIVE,
+            "cleanup_status": "DISCOVERED",
+        }
+        updates = []
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
+                    "代理A": (
+                        "https://open.feishu.cn/open-apis/bot/v2/hook/"
+                        "test-cleanup-route"
+                    )
+                }),
+                "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260812"
+        ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(cleanup, "fetch_account_agency_fallbacks", return_value={}), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={}
+        ), patch.object(cleanup, "upsert_cleanup_candidate"), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup,
+            "update_cleanup_item",
+            side_effect=lambda item_id, **values: updates.append((item_id, values)),
+        ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[]), patch.object(
+            cleanup, "advisory_lock"
+        ) as lock:
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=lambda _account, _ids: [],
+            )
+
+        tencent.delete_dynamic_creative.assert_not_called()
+        lock.assert_not_called()
+        self.assertEqual(summary["deferred"], 1)
+        self.assertTrue(any(
+            values.get("cleanup_status") == "DEFERRED"
+            and "代理商归属为空" in values.get("error_message", "")
+            for _, values in updates
+        ))
+
+    def test_unknown_write_result_recovers_from_missing_creative_without_redelete(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = []
+        tencent.get_ads.return_value = []
+        tencent.get_dynamic_creative.side_effect = RuntimeError(
+            "Dynamic creative not found: account=1 creative=3"
+        )
+        retry_item = {
+            "id": 7,
+            "account_id": 1,
+            "agency_name": "代理A",
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "cleanup_action": cleanup.DELETE_CREATIVE,
+            "cleanup_status": "WRITE_OUTCOME_UNKNOWN",
+        }
+        updates = []
+        with tempfile.TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
+                    "代理A": (
+                        "https://open.feishu.cn/open-apis/bot/v2/hook/"
+                        "test-cleanup-route"
+                    )
+                }),
+                "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260812"
+        ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(cleanup, "fetch_account_agency_fallbacks", return_value={}), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={}
+        ), patch.object(cleanup, "upsert_cleanup_candidate"), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup,
+            "update_cleanup_item",
+            side_effect=lambda item_id, **values: updates.append((item_id, values)),
+        ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[]), patch.object(
+            cleanup, "advisory_lock", side_effect=acquired_lock
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
+                tencent=tencent,
+                odps=Mock(),
+            )
+
+        tencent.delete_dynamic_creative.assert_not_called()
+        self.assertEqual(summary["deleted"], 1)
+        self.assertTrue(any(
+            values.get("cleanup_status") == "CREATIVE_DELETED"
+            for _, values in updates
+        ))
+
+    def test_notification_channels_retry_independently(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        base_row = {
+            "id": 7,
+            "check_date": date(2026, 8, 13),
+            "agency_name": "代理A",
+            "account_id": 1,
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "cleanup_action": cleanup.ALERT_ONLY,
+            "cleanup_status": "ALERT_PENDING",
+            "reject_reason": "需人工判断",
+        }
+
+        def run_with(row):
+            with tempfile.TemporaryDirectory() as directory, patch.dict(
+                os.environ,
+                {
+                    "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                    "ROI_AGENCY_WEBHOOK_ENABLED": "1",
+                    "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
+                        "代理A": (
+                            "https://open.feishu.cn/open-apis/bot/v2/hook/"
+                            "test-cleanup-route"
+                        )
+                    }),
+                    "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
+                },
+                clear=False,
+            ), patch.object(cleanup, "initialize_schema"), patch.object(
+                cleanup, "resolve_end_date", return_value="20260812"
+            ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
+                cleanup, "fetch_recent_spend_accounts", return_value=[]
+            ), patch.object(cleanup, "prefetch_account_access_tokens", return_value={}), patch.object(
+                cleanup, "load_retryable_cleanup_items", return_value=[]
+            ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[row]), patch.object(
+                cleanup, "publish_agency_reports", return_value=[{
+                    "agency_name": "代理A", "status": "SENT"
+                }]
+            ) as agency_publish, patch.object(
+                cleanup, "publish_cleanup_operator_summary", return_value={
+                    "route": cleanup.OPERATOR_SUMMARY_ROUTE, "status": "SENT"
+                }
+            ) as operator_publish, patch.object(
+                cleanup, "mark_cleanup_items_notified"
+            ) as mark_agency, patch.object(
+                cleanup, "mark_cleanup_items_operator_notified"
+            ) as mark_operator:
+                cleanup.run_rejected_creative_cleanup(
+                    output_dir=Path(directory),
+                    now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
+                    tencent=Mock(),
+                    odps=Mock(),
+                    publisher=Mock(),
+                )
+            return agency_publish, operator_publish, mark_agency, mark_operator
+
+        agency, operator, mark_agency, mark_operator = run_with({
+            **base_row,
+            "agency_notified_at": datetime(2026, 8, 13, 11, 0),
+            "operator_notified_at": None,
+        })
+        agency.assert_not_called()
+        operator.assert_called_once()
+        mark_agency.assert_not_called()
+        mark_operator.assert_called_once()
+
+        agency, operator, mark_agency, mark_operator = run_with({
+            **base_row,
+            "agency_notified_at": None,
+            "operator_notified_at": datetime(2026, 8, 13, 11, 0),
+        })
+        agency.assert_called_once()
+        operator.assert_not_called()
+        mark_agency.assert_called_once()
+        mark_operator.assert_not_called()
+
+    def test_cleanup_entry_returns_nonzero_for_notification_failure(self):
+        from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
+
+        summary = {
+            "scan_errors": [],
+            "delete_errors": [],
+            "notification_errors": ["operator failed"],
+        }
+        with patch("logging_setup.setup_logging"), patch.object(
+            entry, "run_rejected_creative_cleanup", return_value=summary
+        ):
+            self.assertEqual(entry.main(), 1)
+
+
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main()
     unittest.main()

+ 95 - 0
examples/auto_put_ad_mini/test_roi_agency_delivery.py

@@ -265,6 +265,37 @@ class AgencyDeliveryTest(unittest.TestCase):
         button = captured["card"]["elements"][1]["actions"][0]
         button = captured["card"]["elements"][1]["actions"][0]
         self.assertEqual(button["url"], "https://example.test/sheet")
         self.assertEqual(button["url"], "https://example.test/sheet")
 
 
+    def test_rejection_cleanup_card_uses_cleanup_copy_and_sheet_button(self):
+        captured = {}
+
+        def handler(request):
+            captured.update(json.loads(request.content))
+            return httpx.Response(200, json={"code": 0, "msg": "success"})
+
+        notifier = AgencyWebhookNotifier()
+        notifier.client.close()
+        notifier.client = httpx.Client(transport=httpx.MockTransport(handler))
+        try:
+            notifier.send(
+                WEBHOOK,
+                title="20260811_棱镜_拒审创意清理通知",
+                sheet_url="https://example.test/deleted-sheet",
+                creative_rows=2,
+                ad_rows=0,
+                notification_type="creative_rejection_cleanup",
+            )
+        finally:
+            notifier.close()
+
+        card = captured["card"]
+        self.assertIn(
+            "创意审核异常处理及人工报警",
+            card["elements"][0]["text"]["content"],
+        )
+        button = card["elements"][1]["actions"][0]
+        self.assertEqual(button["text"]["content"], "查看处理明细")
+        self.assertEqual(button["url"], "https://example.test/deleted-sheet")
+
     def test_main_chat_publish_still_uploads_once_and_notifies_each_chat(self):
     def test_main_chat_publish_still_uploads_once_and_notifies_each_chat(self):
         publisher = RoiFeishuPublisher.__new__(RoiFeishuPublisher)
         publisher = RoiFeishuPublisher.__new__(RoiFeishuPublisher)
         publisher.chat_ids = ["chat-1", "chat-2"]
         publisher.chat_ids = ["chat-1", "chat-2"]
@@ -310,6 +341,29 @@ class AgencyDeliveryTest(unittest.TestCase):
         self.assertEqual(card["header"]["template"], "red")
         self.assertEqual(card["header"]["template"], "red")
         self.assertEqual(card["header"]["title"]["content"], "日级ROI任务失败")
         self.assertEqual(card["header"]["title"]["content"], "日级ROI任务失败")
 
 
+    def test_report_card_only_sends_to_explicit_operator_chat(self):
+        publisher = RoiFeishuPublisher.__new__(RoiFeishuPublisher)
+        publisher.client = Mock()
+        publisher._token = Mock(return_value="tenant-token")
+        publisher._json = Mock(return_value={"data": {"message_id": "summary-1"}})
+
+        message_id = publisher.send_report_card(
+            title="创意审核异常处理汇总",
+            content="共 3 条创意",
+            sheet_url="https://example.test/summary",
+            chat_id="chat-operator",
+        )
+
+        self.assertEqual(message_id, "summary-1")
+        request = publisher.client.post.call_args
+        self.assertEqual(request.kwargs["json"]["receive_id"], "chat-operator")
+        card = json.loads(request.kwargs["json"]["content"])
+        self.assertEqual(card["header"]["title"]["content"], "创意审核异常处理汇总")
+        self.assertEqual(
+            card["elements"][1]["actions"][0]["url"],
+            "https://example.test/summary",
+        )
+
     def test_service_failure_alert_falls_back_to_operator_chat(self):
     def test_service_failure_alert_falls_back_to_operator_chat(self):
         publisher = RoiFeishuPublisher.__new__(RoiFeishuPublisher)
         publisher = RoiFeishuPublisher.__new__(RoiFeishuPublisher)
         publisher.client = Mock()
         publisher.client = Mock()
@@ -434,6 +488,47 @@ class AgencyDeliveryTest(unittest.TestCase):
         agency_from_env.assert_not_called()
         agency_from_env.assert_not_called()
         scheduler.start.assert_called_once_with()
         scheduler.start.assert_called_once_with()
 
 
+    def test_rejected_creative_cleanup_registers_an_independent_job(self):
+        import run_daily_service as daily_service
+
+        scheduler = Mock()
+        scheduler.get_jobs.return_value = []
+
+        def enabled(name, _default):
+            return name == "DAILY_REJECTED_CREATIVE_CLEANUP_ENABLED"
+
+        with (
+            patch.object(
+                daily_service.RoiConfig,
+                "from_env",
+                return_value=RoiConfig(
+                    daily_enabled=False,
+                    internal_test_enabled=False,
+                    report_hour=11,
+                    report_minute=0,
+                ),
+            ),
+            patch.object(daily_service, "initialize_schema"),
+            patch.object(daily_service, "_env_flag", side_effect=enabled),
+            patch.object(
+                daily_service,
+                "BlockingScheduler",
+                return_value=scheduler,
+            ),
+        ):
+            daily_service.main([])
+
+        cleanup_jobs = [
+            call
+            for call in scheduler.add_job.call_args_list
+            if call.kwargs.get("id") == "rejected_creative_cleanup"
+        ]
+        self.assertEqual(len(cleanup_jobs), 1)
+        self.assertIs(
+            cleanup_jobs[0].args[0],
+            daily_service.run_creative_rejection_cleanup,
+        )
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main()
     unittest.main()

+ 112 - 16
examples/auto_put_ad_mini/tools/ad_api.py

@@ -21,7 +21,8 @@ import logging
 import os
 import os
 import time
 import time
 import uuid
 import uuid
-from typing import Any, Dict, List, Optional
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Any, Dict, Iterable, List, Optional
 from urllib.parse import urlencode
 from urllib.parse import urlencode
 
 
 import httpx
 import httpx
@@ -35,7 +36,7 @@ logger = logging.getLogger(__name__)
 
 
 BASE_URL = os.getenv("TENCENT_AD_BASE_URL", "https://api.e.qq.com/v3.0")
 BASE_URL = os.getenv("TENCENT_AD_BASE_URL", "https://api.e.qq.com/v3.0")
 DEFAULT_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "0") or 0)
 DEFAULT_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "0") or 0)
-TIMEOUT = 30  # 秒
+TIMEOUT = int(os.getenv("TENCENT_AD_TIMEOUT_SECONDS", "30"))
 
 
 # Token 获取 API(内部服务,根据 accountId 返回最新 access_token)
 # Token 获取 API(内部服务,根据 accountId 返回最新 access_token)
 TOKEN_API_URL = os.getenv(
 TOKEN_API_URL = os.getenv(
@@ -95,6 +96,64 @@ def _get_access_token(account_id: int = 0) -> str:
     return static_token
     return static_token
 
 
 
 
+def prefetch_access_tokens(
+    account_ids: Iterable[int],
+    max_workers: Optional[int] = None,
+) -> Dict[int, str]:
+    """Fetch distinct account access tokens concurrently and warm the cache."""
+
+    account_set = set()
+    for raw_value in account_ids:
+        try:
+            account_id = int(raw_value)
+        except (TypeError, ValueError):
+            continue
+        if account_id > 0:
+            account_set.add(account_id)
+    accounts = sorted(account_set)
+    if not accounts:
+        return {}
+    if max_workers is None:
+        max_workers = int(os.getenv("TENCENT_AD_TOKEN_PREFETCH_WORKERS", "8"))
+    if max_workers < 1:
+        raise ValueError("TENCENT_AD_TOKEN_PREFETCH_WORKERS must be at least 1")
+    workers = min(max_workers, len(accounts), 32)
+
+    tokens: Dict[int, str] = {}
+    errors: Dict[int, str] = {}
+    with ThreadPoolExecutor(
+        max_workers=workers,
+        thread_name_prefix="tencent-token",
+    ) as executor:
+        futures = {
+            executor.submit(_get_access_token, account_id): account_id
+            for account_id in accounts
+        }
+        for future in as_completed(futures):
+            account_id = futures[future]
+            try:
+                token = future.result()
+                tokens[account_id] = token
+                _token_cache[account_id] = {"token": token, "ts": time.time()}
+            except Exception as exc:
+                errors[account_id] = str(exc)
+
+    if errors:
+        logger.warning(
+            "[TokenAPI] 并发预取部分失败 success=%d failed=%d accounts=%s",
+            len(tokens),
+            len(errors),
+            sorted(errors),
+        )
+    logger.info(
+        "[TokenAPI] 并发预取完成 accounts=%d success=%d workers=%d",
+        len(accounts),
+        len(tokens),
+        workers,
+    )
+    return tokens
+
+
 def _common_params(account_id: int = 0) -> Dict[str, str]:
 def _common_params(account_id: int = 0) -> Dict[str, str]:
     """公共查询参数:access_token / timestamp / nonce"""
     """公共查询参数:access_token / timestamp / nonce"""
     return {
     return {
@@ -109,22 +168,59 @@ def _get(path: str, params: Dict[str, Any]) -> Dict[str, Any]:
     发送 GET 请求。
     发送 GET 请求。
     复杂对象(list/dict)自动 JSON 序列化后作为 query string 参数传递。
     复杂对象(list/dict)自动 JSON 序列化后作为 query string 参数传递。
     """
     """
-    account_id = params.get("account_id", 0)
-    query = dict(_common_params(account_id))
-    for k, v in params.items():
-        if v is None:
-            continue
-        if isinstance(v, (dict, list)):
-            query[k] = json.dumps(v, ensure_ascii=False)
-        else:
-            query[k] = str(v)
+    attempts = int(os.getenv("TENCENT_AD_GET_RETRY_ATTEMPTS", "3"))
+    backoff_seconds = float(
+        os.getenv("TENCENT_AD_GET_RETRY_BACKOFF_SECONDS", "1")
+    )
+    if attempts < 1:
+        raise ValueError("TENCENT_AD_GET_RETRY_ATTEMPTS must be at least 1")
+    if backoff_seconds < 0:
+        raise ValueError(
+            "TENCENT_AD_GET_RETRY_BACKOFF_SECONDS must not be negative"
+        )
 
 
-    url = f"{BASE_URL}{path}?{urlencode(query)}"
-    logger.debug("[TencentAPI] GET %s", url)
+    account_id = params.get("account_id", 0)
+    for attempt in range(1, attempts + 1):
+        query = dict(_common_params(account_id))
+        for k, v in params.items():
+            if v is None:
+                continue
+            if isinstance(v, (dict, list)):
+                query[k] = json.dumps(v, ensure_ascii=False)
+            else:
+                query[k] = str(v)
+
+        url = f"{BASE_URL}{path}?{urlencode(query)}"
+        logger.debug("[TencentAPI] GET %s", url)
+        try:
+            resp = httpx.get(url, timeout=TIMEOUT)
+            resp.raise_for_status()
+            return resp.json()
+        except httpx.RequestError as exc:
+            retryable = True
+            error = exc
+        except httpx.HTTPStatusError as exc:
+            status_code = exc.response.status_code
+            retryable = status_code in {408, 429} or status_code >= 500
+            error = exc
+
+        if not retryable or attempt >= attempts:
+            raise error
+        delay = backoff_seconds * (2 ** (attempt - 1))
+        logger.warning(
+            "[TencentAPI] GET 临时失败,准备重试 "
+            "account=%s path=%s attempt=%d/%d delay=%.1fs error=%s",
+            account_id,
+            path,
+            attempt,
+            attempts,
+            delay,
+            error,
+        )
+        if delay > 0:
+            time.sleep(delay)
 
 
-    resp = httpx.get(url, timeout=TIMEOUT)
-    resp.raise_for_status()
-    return resp.json()
+    raise RuntimeError("Tencent GET retry loop exited unexpectedly")
 
 
 
 
 def _get_user_token_for_account(account_id: int) -> str:
 def _get_user_token_for_account(account_id: int) -> str:

+ 1656 - 0
examples/auto_put_ad_mini/tools/creative_rejection_cleanup.py

@@ -0,0 +1,1656 @@
+"""Apply creative-review cleanup rules and notify agency groups."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import os
+from collections import defaultdict
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from typing import Any, Callable
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+from openpyxl import Workbook
+from openpyxl.styles import Alignment, Font, PatternFill
+from openpyxl.utils import get_column_letter
+
+from db.connection import get_connection
+from roi_control.agency_delivery import publish_agency_reports
+from roi_control.config import AgencyWebhookConfig
+from roi_control.feishu import RoiFeishuPublisher
+from storage import advisory_lock, initialize_schema
+from tools.creative_review import (
+    fetch_dynamic_creative_review_results,
+    parse_review_result,
+    review_granularity_fields,
+    status_desc,
+)
+
+
+logger = logging.getLogger(__name__)
+SHANGHAI = ZoneInfo("Asia/Shanghai")
+REPORT_VERSION = "creative_rejection_cleanup_v7"
+OPERATOR_SUMMARY_ROUTE = "投放调控汇总"
+DENIED_SYSTEM_STATUS = "DYNAMIC_CREATIVE_STATUS_DENIED"
+DELETED_STATUS = "AD_STATUS_DELETED"
+CREATIVE_DENIED_STATUS = "CREATIVE_SET_APPROVAL_STATUS_DENIED"
+CREATIVE_PARTIAL_NORMAL_STATUS = "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+DELETE_CREATIVE = "DELETE_CREATIVE"
+ALERT_ONLY = "ALERT_ONLY"
+DEFAULT_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN = 50.0
+DEFAULT_WECHAT_MINI_PROGRAM_COST_THRESHOLD_YUAN = 100.0
+REPORT_COLUMNS = (
+    "代理名称",
+    "账户ID",
+    "账户名称",
+    "广告ID",
+    "广告名称",
+    "创意ID",
+    "创意名称",
+    "配置状态",
+    "创意审核状态",
+    "元素粒度审核状态",
+    "元素粒度审核不通过原因",
+    "版位粒度审核状态",
+    "版位粒度审核不通过原因",
+    "审核不通过原因",
+    "检查时间",
+    "执行操作",
+)
+
+
+def _json(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False, default=str)
+
+
+def _is_rejected_status(value: Any) -> bool:
+    upper = str(value or "").strip().upper()
+    return "REJECT" in upper or "DENIED" in upper
+
+
+def has_rejected_wechat_mini_program_element(raw_result: dict | None) -> bool:
+    """Return whether a rejected element is explicitly named 微信小程序."""
+
+    raw = raw_result if isinstance(raw_result, dict) else {}
+    for element in raw.get("element_result_list") or []:
+        if not isinstance(element, dict):
+            continue
+        element_name = "".join(str(element.get("element_name") or "").split())
+        if element_name != "微信小程序":
+            continue
+        if any(
+            _is_rejected_status(element.get(field))
+            for field in ("review_status", "system_status")
+        ):
+            return True
+    return False
+
+
+def partial_creative_cost_threshold_fen() -> int:
+    raw = os.getenv(
+        "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN",
+        str(DEFAULT_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN),
+    )
+    try:
+        yuan = float(raw)
+    except (TypeError, ValueError) as exc:
+        raise ValueError(
+            "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN must be numeric"
+        ) from exc
+    if yuan < 0:
+        raise ValueError(
+            "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN must not be negative"
+        )
+    return int(round(yuan * 100))
+
+
+def wechat_mini_program_cost_threshold_fen() -> int:
+    raw = os.getenv(
+        "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN",
+        str(DEFAULT_WECHAT_MINI_PROGRAM_COST_THRESHOLD_YUAN),
+    )
+    try:
+        yuan = float(raw)
+    except (TypeError, ValueError) as exc:
+        raise ValueError(
+            "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN must be numeric"
+        ) from exc
+    if yuan < 0:
+        raise ValueError(
+            "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN must not be negative"
+        )
+    return int(round(yuan * 100))
+
+
+def determine_cleanup_action(
+    creative: dict[str, Any],
+    raw_result: dict | None,
+    *,
+    recent_cost_fen: int | None = None,
+    cost_threshold_fen: int = 5000,
+    wechat_cost_threshold_fen: int = 10000,
+    spend_error: str | None = None,
+) -> dict[str, Any] | None:
+    """Choose one of the three whole-creative delete rules or manual alert."""
+
+    approval_status = str(creative.get("creative_set_approval_status") or "")
+    if approval_status == CREATIVE_DENIED_STATUS:
+        return {
+            "cleanup_action": DELETE_CREATIVE,
+            "component_ids": [],
+            "element_ids": [],
+            "action_reason": "创意审核状态为审核拒绝",
+            "recent_cost_fen": recent_cost_fen,
+        }
+    if approval_status == CREATIVE_PARTIAL_NORMAL_STATUS:
+        if has_rejected_wechat_mini_program_element(raw_result):
+            if spend_error or recent_cost_fen is None:
+                reason = (
+                    "部分投放中且微信小程序元素审核拒绝,"
+                    "近3天历史消耗读取失败,需人工判断"
+                )
+                if spend_error:
+                    reason = f"{reason}:{spend_error}"
+                return {
+                    "cleanup_action": ALERT_ONLY,
+                    "component_ids": [],
+                    "element_ids": [],
+                    "action_reason": reason,
+                    "recent_cost_fen": None,
+                }
+            if recent_cost_fen >= wechat_cost_threshold_fen:
+                return {
+                    "cleanup_action": ALERT_ONLY,
+                    "component_ids": [],
+                    "element_ids": [],
+                    "action_reason": (
+                        "部分投放中且微信小程序元素审核拒绝,近3天历史消耗"
+                        f"{recent_cost_fen / 100:.2f}元不低于"
+                        f"{wechat_cost_threshold_fen / 100:.2f}元,需人工判断是否删除"
+                    ),
+                    "recent_cost_fen": recent_cost_fen,
+                }
+            return {
+                "cleanup_action": DELETE_CREATIVE,
+                "component_ids": [],
+                "element_ids": [],
+                "action_reason": (
+                    "部分投放中且微信小程序元素审核拒绝,近3天历史消耗"
+                    f"{recent_cost_fen / 100:.2f}元低于"
+                    f"{wechat_cost_threshold_fen / 100:.2f}元"
+                ),
+                "recent_cost_fen": recent_cost_fen,
+            }
+        if spend_error or recent_cost_fen is None:
+            reason = "部分投放中,近3天历史消耗读取失败,需人工判断"
+            if spend_error:
+                reason = f"{reason}:{spend_error}"
+            return {
+                "cleanup_action": ALERT_ONLY,
+                "component_ids": [],
+                "element_ids": [],
+                "action_reason": reason,
+                "recent_cost_fen": None,
+            }
+        if recent_cost_fen < cost_threshold_fen:
+            return {
+                "cleanup_action": DELETE_CREATIVE,
+                "component_ids": [],
+                "element_ids": [],
+                "action_reason": (
+                    f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元"
+                    f"低于{cost_threshold_fen / 100:.2f}元"
+                ),
+                "recent_cost_fen": recent_cost_fen,
+            }
+        return {
+            "cleanup_action": ALERT_ONLY,
+            "component_ids": [],
+            "element_ids": [],
+            "action_reason": (
+                f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元,"
+                "需人工判断是否删除"
+            ),
+            "recent_cost_fen": recent_cost_fen,
+        }
+    return None
+
+
+def _as_int(value: Any) -> int | None:
+    try:
+        number = int(value)
+    except (TypeError, ValueError):
+        return None
+    return number if number > 0 else None
+
+
+def _agency_name(value: Any) -> str:
+    if value is None or pd.isna(value):
+        return ""
+    return "".join(str(value or "").split())
+
+
+def _env_flag(name: str, default: bool = False) -> bool:
+    raw = os.getenv(name)
+    if raw is None:
+        return default
+    return raw.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def resolve_end_date(client, requested=None, *, now=None):
+    from roi_control.data_source import resolve_end_date as resolve
+
+    return resolve(client, requested, now=now)
+
+
+def date_window(end_date: str) -> tuple[str, str]:
+    end = datetime.strptime(end_date, "%Y%m%d")
+    return (end - timedelta(days=2)).strftime("%Y%m%d"), end_date
+
+
+def fetch_daily_data(client, start_date: str, end_date: str) -> pd.DataFrame:
+    from roi_control.data_source import fetch_daily_data as fetch
+
+    return fetch(client, start_date, end_date)
+
+
+def fetch_recent_spend_accounts(client, start_date: str, end_date: str) -> list[dict]:
+    from roi_control.data_source import fetch_recent_spend_accounts as fetch
+
+    return fetch(client, start_date, end_date)
+
+
+def fetch_account_agency_fallbacks(client, account_ids: list[int]) -> dict[int, str]:
+    from roi_control.data_source import fetch_account_agency_fallbacks as fetch
+
+    return fetch(client, account_ids)
+
+
+def prefetch_account_access_tokens(account_ids: list[int]) -> dict[int, str]:
+    from tools.ad_api import prefetch_access_tokens
+
+    return prefetch_access_tokens(account_ids)
+
+
+def build_agency_context(daily: pd.DataFrame) -> dict[str, dict]:
+    """Build latest-date creative mappings and unambiguous account fallbacks."""
+
+    creative_agency_sets: dict[tuple[int, int], set[str]] = defaultdict(set)
+    creative_agency_dates: dict[tuple[int, int], str] = {}
+    account_agencies: dict[int, set[str]] = defaultdict(set)
+    account_agency_dates: dict[int, str] = {}
+    account_names: dict[int, str] = {}
+    account_name_dates: dict[int, str] = {}
+    if daily.empty:
+        return {
+            "creative_agencies": {},
+            "account_agencies": {},
+            "fallback_account_agencies": {},
+            "account_names": account_names,
+        }
+
+    miniapp = daily[daily["entity_type"].eq("self")]
+    for _, row in miniapp.iterrows():
+        account_id = _as_int(row.get("账号id"))
+        creative_id = _as_int(row.get("创意id"))
+        agency = _agency_name(row.get("代理名称"))
+        row_date = str(row.get("dt") or "")
+        if account_id is None:
+            continue
+        account_name = str(row.get("账号名称") or "").strip()
+        if account_name and row_date >= account_name_dates.get(account_id, ""):
+            account_names[account_id] = account_name
+            account_name_dates[account_id] = row_date
+        if agency:
+            account_date = account_agency_dates.get(account_id, "")
+            if row_date > account_date:
+                account_agencies[account_id] = {agency}
+                account_agency_dates[account_id] = row_date
+            elif row_date == account_date:
+                account_agencies[account_id].add(agency)
+            if creative_id is not None:
+                key = (account_id, creative_id)
+                current_date = creative_agency_dates.get(key, "")
+                if row_date > current_date:
+                    creative_agency_sets[key] = {agency}
+                    creative_agency_dates[key] = row_date
+                elif row_date == current_date:
+                    creative_agency_sets[key].add(agency)
+
+    return {
+        "creative_agencies": {
+            key: next(iter(agencies))
+            for key, agencies in creative_agency_sets.items()
+            if len(agencies) == 1
+        },
+        "account_agencies": {
+            account_id: next(iter(agencies))
+            for account_id, agencies in account_agencies.items()
+            if len(agencies) == 1
+        },
+        "fallback_account_agencies": {},
+        "account_names": account_names,
+    }
+
+
+def _resolve_agency(
+    context: dict[str, dict],
+    account_id: int,
+    creative_id: int,
+) -> str:
+    return str(
+        context["creative_agencies"].get((account_id, creative_id))
+        or context["account_agencies"].get(account_id)
+        or context.get("fallback_account_agencies", {}).get(account_id)
+        or ""
+    )
+
+
+def upsert_cleanup_candidate(record: dict[str, Any]) -> dict[str, Any]:
+    connection = get_connection()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                INSERT INTO creative_rejection_cleanup_item
+                    (account_id, account_name, agency_name, adgroup_id,
+                     adgroup_name, dynamic_creative_id, dynamic_creative_name,
+                     check_date,
+                     cleanup_action, target_component_ids_json,
+                     target_element_ids_json, recent_cost_fen,
+                     cost_start_date, cost_end_date, action_reason, reject_reason,
+                     review_result_json, pre_state_json, cleanup_status)
+                VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+                ON DUPLICATE KEY UPDATE
+                    account_name=COALESCE(NULLIF(VALUES(account_name),''), account_name),
+                    agency_name=COALESCE(NULLIF(VALUES(agency_name),''), agency_name),
+                    adgroup_id=VALUES(adgroup_id),
+                    adgroup_name=COALESCE(NULLIF(VALUES(adgroup_name),''), adgroup_name),
+                    dynamic_creative_name=COALESCE(
+                        NULLIF(VALUES(dynamic_creative_name),''), dynamic_creative_name
+                    ),
+                    action_reason=VALUES(action_reason),
+                    reject_reason=VALUES(reject_reason),
+                    review_result_json=VALUES(review_result_json),
+                    pre_state_json=VALUES(pre_state_json),
+                    notified_at=CASE
+                        WHEN NOT (cleanup_action <=> VALUES(cleanup_action))
+                          OR NOT (target_component_ids_json <=> VALUES(target_component_ids_json))
+                          OR NOT (target_element_ids_json <=> VALUES(target_element_ids_json))
+                          OR NOT (recent_cost_fen <=> VALUES(recent_cost_fen))
+                          OR NOT (cost_end_date <=> VALUES(cost_end_date))
+                            THEN NULL ELSE notified_at
+                    END,
+                    agency_notified_at=CASE
+                        WHEN NOT (cleanup_action <=> VALUES(cleanup_action))
+                          OR NOT (target_component_ids_json <=> VALUES(target_component_ids_json))
+                          OR NOT (target_element_ids_json <=> VALUES(target_element_ids_json))
+                          OR NOT (recent_cost_fen <=> VALUES(recent_cost_fen))
+                          OR NOT (cost_end_date <=> VALUES(cost_end_date))
+                            THEN NULL ELSE agency_notified_at
+                    END,
+                    operator_notified_at=CASE
+                        WHEN NOT (cleanup_action <=> VALUES(cleanup_action))
+                          OR NOT (target_component_ids_json <=> VALUES(target_component_ids_json))
+                          OR NOT (target_element_ids_json <=> VALUES(target_element_ids_json))
+                          OR NOT (recent_cost_fen <=> VALUES(recent_cost_fen))
+                          OR NOT (cost_end_date <=> VALUES(cost_end_date))
+                            THEN NULL ELSE operator_notified_at
+                    END,
+                    deleted_at=CASE
+                        WHEN NOT (cleanup_action <=> VALUES(cleanup_action))
+                          OR NOT (target_component_ids_json <=> VALUES(target_component_ids_json))
+                          OR NOT (target_element_ids_json <=> VALUES(target_element_ids_json))
+                          OR NOT (recent_cost_fen <=> VALUES(recent_cost_fen))
+                          OR NOT (cost_end_date <=> VALUES(cost_end_date))
+                            THEN NULL ELSE deleted_at
+                    END,
+                    readback_json=CASE
+                        WHEN NOT (cleanup_action <=> VALUES(cleanup_action))
+                          OR NOT (target_component_ids_json <=> VALUES(target_component_ids_json))
+                          OR NOT (target_element_ids_json <=> VALUES(target_element_ids_json))
+                          OR NOT (recent_cost_fen <=> VALUES(recent_cost_fen))
+                          OR NOT (cost_end_date <=> VALUES(cost_end_date))
+                            THEN NULL ELSE readback_json
+                    END,
+                    cleanup_status=CASE
+                        WHEN NOT (cleanup_action <=> VALUES(cleanup_action))
+                          OR NOT (target_component_ids_json <=> VALUES(target_component_ids_json))
+                          OR NOT (target_element_ids_json <=> VALUES(target_element_ids_json))
+                          OR NOT (recent_cost_fen <=> VALUES(recent_cost_fen))
+                          OR NOT (cost_end_date <=> VALUES(cost_end_date))
+                            THEN VALUES(cleanup_status)
+                        WHEN cleanup_status='SKIPPED_REVIEW_NOT_RECONFIRMED'
+                            THEN 'DISCOVERED'
+                        ELSE cleanup_status
+                    END,
+                    target_component_ids_json=VALUES(target_component_ids_json),
+                    target_element_ids_json=VALUES(target_element_ids_json),
+                    recent_cost_fen=VALUES(recent_cost_fen),
+                    cost_start_date=VALUES(cost_start_date),
+                    cost_end_date=VALUES(cost_end_date),
+                    cleanup_action=VALUES(cleanup_action)
+                """,
+                (
+                    record["account_id"],
+                    record.get("account_name"),
+                    record.get("agency_name"),
+                    record["adgroup_id"],
+                    record.get("adgroup_name"),
+                    record["dynamic_creative_id"],
+                    record.get("dynamic_creative_name"),
+                    record["check_date"],
+                    record["cleanup_action"],
+                    _json(record.get("component_ids") or []),
+                    _json(record.get("element_ids") or []),
+                    record.get("recent_cost_fen"),
+                    record.get("cost_start_date"),
+                    record.get("cost_end_date"),
+                    record.get("action_reason") or record["reject_reason"],
+                    record["reject_reason"],
+                    _json(record.get("review_result") or {}),
+                    _json(record.get("pre_state") or {}),
+                    (
+                        "ALERT_PENDING"
+                        if record["cleanup_action"] == ALERT_ONLY
+                        else "DISCOVERED"
+                    ),
+                ),
+            )
+            cursor.execute(
+                """
+                SELECT * FROM creative_rejection_cleanup_item
+                WHERE account_id=%s AND dynamic_creative_id=%s AND check_date=%s
+                """,
+                (
+                    record["account_id"],
+                    record["dynamic_creative_id"],
+                    record["check_date"],
+                ),
+            )
+            return cursor.fetchone()
+    finally:
+        connection.close()
+
+
+def load_retryable_cleanup_items() -> list[dict[str, Any]]:
+    connection = get_connection()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT item.*
+                FROM creative_rejection_cleanup_item item
+                JOIN (
+                    SELECT account_id, dynamic_creative_id, MAX(check_date) AS check_date
+                    FROM creative_rejection_cleanup_item
+                    GROUP BY account_id, dynamic_creative_id
+                ) latest
+                  ON latest.account_id=item.account_id
+                 AND latest.dynamic_creative_id=item.dynamic_creative_id
+                 AND latest.check_date=item.check_date
+                WHERE item.cleanup_status IN
+                    ('DISCOVERED','DEFERRED','FAILED','WRITE_OUTCOME_UNKNOWN')
+                  AND item.cleanup_action='DELETE_CREATIVE'
+                ORDER BY item.id
+                """
+            )
+            return list(cursor.fetchall())
+    finally:
+        connection.close()
+
+
+def update_cleanup_item(item_id: int, **values: Any) -> None:
+    allowed = {
+        "agency_name",
+        "cleanup_action",
+        "target_component_ids_json",
+        "target_element_ids_json",
+        "recent_cost_fen",
+        "cost_start_date",
+        "cost_end_date",
+        "action_reason",
+        "reject_reason",
+        "review_result_json",
+        "cleanup_status",
+        "error_message",
+        "pre_state_json",
+        "readback_json",
+        "deleted_at",
+        "agency_notified_at",
+        "operator_notified_at",
+        "notified_at",
+    }
+    unknown = set(values) - allowed
+    if unknown:
+        raise ValueError(f"Unsupported cleanup fields: {sorted(unknown)}")
+    if not values:
+        return
+    assignments = ", ".join(f"{name}=%s" for name in values)
+    connection = get_connection()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f"UPDATE creative_rejection_cleanup_item SET {assignments} WHERE id=%s",
+                [*values.values(), item_id],
+            )
+    finally:
+        connection.close()
+
+
+def load_pending_notification_items() -> list[dict[str, Any]]:
+    connection = get_connection()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT * FROM creative_rejection_cleanup_item
+                WHERE cleanup_status IN (
+                    'CREATIVE_DELETED','ALERT_PENDING'
+                )
+                  AND (
+                      agency_notified_at IS NULL
+                      OR operator_notified_at IS NULL
+                  )
+                ORDER BY check_date, agency_name, account_id, adgroup_id,
+                         dynamic_creative_id
+                """
+            )
+            return list(cursor.fetchall())
+    finally:
+        connection.close()
+
+
+def load_unnotified_deleted_items() -> list[dict[str, Any]]:
+    """Compatibility entry point for pending cleanup notifications."""
+
+    return load_pending_notification_items()
+
+
+def _mark_cleanup_channel_notified(
+    item_ids: list[int],
+    notified_at: datetime,
+    *,
+    channel: str,
+) -> None:
+    if not item_ids:
+        return
+    placeholders = ",".join(["%s"] * len(item_ids))
+    if channel not in {"agency", "operator"}:
+        raise ValueError(f"Unsupported cleanup notification channel: {channel}")
+    channel_column = f"{channel}_notified_at"
+    other_column = (
+        "operator_notified_at" if channel == "agency" else "agency_notified_at"
+    )
+    connection = get_connection()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f"""
+                UPDATE creative_rejection_cleanup_item
+                SET {channel_column}=%s,
+                    notified_at=CASE
+                        WHEN {other_column} IS NOT NULL THEN %s
+                        ELSE NULL
+                    END
+                WHERE id IN ({placeholders}) AND {channel_column} IS NULL
+                """,
+                [notified_at, notified_at, *item_ids],
+            )
+    finally:
+        connection.close()
+
+
+def mark_cleanup_items_agency_notified(
+    item_ids: list[int], notified_at: datetime
+) -> None:
+    _mark_cleanup_channel_notified(item_ids, notified_at, channel="agency")
+
+
+def mark_cleanup_items_operator_notified(
+    item_ids: list[int], notified_at: datetime
+) -> None:
+    _mark_cleanup_channel_notified(item_ids, notified_at, channel="operator")
+
+
+def mark_cleanup_items_notified(
+    item_ids: list[int], notified_at: datetime
+) -> None:
+    """Compatibility entry point for the agency notification channel."""
+
+    mark_cleanup_items_agency_notified(item_ids, notified_at)
+
+
+def upsert_cleanup_delivery(record: dict[str, Any]) -> dict[str, Any]:
+    connection = get_connection()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                INSERT INTO creative_rejection_delivery
+                    (run_id, agency_name, agency_report_version, file_path,
+                     file_sha256, creative_rows, ad_rows,
+                     route_fingerprint, status)
+                VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'PENDING')
+                ON DUPLICATE KEY UPDATE
+                    status=CASE
+                        WHEN status='SENT' THEN status
+                        WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
+                            THEN 'PENDING'
+                        ELSE status
+                    END,
+                    error_message=CASE
+                        WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
+                            THEN NULL ELSE error_message
+                    END,
+                    file_path=IF(status='SENT', file_path, VALUES(file_path)),
+                    file_sha256=IF(status='SENT', file_sha256, VALUES(file_sha256)),
+                    creative_rows=VALUES(creative_rows),
+                    ad_rows=VALUES(ad_rows),
+                    route_fingerprint=VALUES(route_fingerprint)
+                """,
+                (
+                    record["run_id"],
+                    record["agency_name"],
+                    record["agency_report_version"],
+                    record["file_path"],
+                    record["file_sha256"],
+                    record["creative_rows"],
+                    record["ad_rows"],
+                    record.get("route_fingerprint"),
+                ),
+            )
+            cursor.execute(
+                """
+                SELECT * FROM creative_rejection_delivery
+                WHERE run_id=%s AND agency_name=%s AND agency_report_version=%s
+                """,
+                (
+                    record["run_id"],
+                    record["agency_name"],
+                    record["agency_report_version"],
+                ),
+            )
+            return cursor.fetchone()
+    finally:
+        connection.close()
+
+
+def update_cleanup_delivery(delivery_id: int, **values: Any) -> None:
+    allowed = {
+        "status",
+        "sheet_token",
+        "sheet_url",
+        "response_code",
+        "error_message",
+        "sent_at",
+    }
+    increment_attempt = bool(values.pop("increment_attempt", False))
+    unknown = set(values) - allowed
+    if unknown:
+        raise ValueError(f"Unsupported delivery fields: {sorted(unknown)}")
+    assignments = [f"{name}=%s" for name in values]
+    params = list(values.values())
+    if increment_attempt:
+        assignments.append("attempt_count=attempt_count+1")
+    if not assignments:
+        return
+    connection = get_connection()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f"UPDATE creative_rejection_delivery SET {', '.join(assignments)} WHERE id=%s",
+                [*params, delivery_id],
+            )
+    finally:
+        connection.close()
+
+
+def _file_sha256(path: Path) -> str:
+    digest = hashlib.sha256()
+    with path.open("rb") as handle:
+        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def publish_cleanup_operator_summary(
+    *,
+    run_id: str,
+    report: dict[str, object],
+    chat_id: str,
+    publisher: RoiFeishuPublisher,
+    now: datetime,
+) -> dict[str, object]:
+    """Idempotently upload and send the all-agency report to the operator group."""
+
+    path = Path(str(report["report"]))
+    delivery_id: int | None = None
+    try:
+        if not path.is_file():
+            raise FileNotFoundError(path)
+        target_chat_id = str(chat_id or "").strip()
+        if not target_chat_id:
+            raise RuntimeError("FEISHU_OPERATOR_CHAT_ID 未配置")
+        delivery = upsert_cleanup_delivery(
+            {
+                "run_id": run_id,
+                "agency_name": OPERATOR_SUMMARY_ROUTE,
+                "agency_report_version": str(report["report_version"]),
+                "file_path": str(path),
+                "file_sha256": _file_sha256(path),
+                "creative_rows": int(report.get("creative_rows") or 0),
+                "ad_rows": 0,
+                "route_fingerprint": hashlib.sha256(
+                    target_chat_id.encode("utf-8")
+                ).hexdigest(),
+            }
+        )
+        delivery_id = int(delivery["id"])
+        if delivery.get("status") == "SENT":
+            return {
+                "route": OPERATOR_SUMMARY_ROUTE,
+                "status": "SENT",
+                "sheet_url": str(delivery.get("sheet_url") or ""),
+                "reused": True,
+            }
+        sheet_url = str(delivery.get("sheet_url") or "")
+        sheet_token = str(delivery.get("sheet_token") or "")
+        if not sheet_url or not sheet_token:
+            imported = publisher.upload_workbook(path)
+            sheet_url = imported["url"]
+            sheet_token = imported["sheet_token"]
+            update_cleanup_delivery(
+                delivery_id,
+                status="UPLOADED",
+                sheet_token=sheet_token,
+                sheet_url=sheet_url,
+            )
+        message_id = publisher.send_report_card(
+            title=str(report.get("title") or path.stem),
+            content=(
+                f"本批次共 **{int(report.get('creative_rows') or 0)}** 条创意,"
+                "包含各代理自动删除及需人工判断的完整汇总。"
+            ),
+            sheet_url=sheet_url,
+            chat_id=target_chat_id,
+            button_text="查看全部处理明细",
+        )
+        update_cleanup_delivery(
+            delivery_id,
+            status="SENT",
+            response_code=message_id,
+            sent_at=now,
+            increment_attempt=True,
+        )
+        return {
+            "route": OPERATOR_SUMMARY_ROUTE,
+            "status": "SENT",
+            "sheet_url": sheet_url,
+            "reused": False,
+        }
+    except Exception as exc:
+        if delivery_id is not None:
+            try:
+                update_cleanup_delivery(
+                    delivery_id,
+                    status="FAILED",
+                    error_message=str(exc),
+                    increment_attempt=True,
+                )
+            except Exception as audit_exc:
+                logger.error("operator summary audit failed: %s", audit_exc)
+        logger.error("operator summary delivery failed: %s", exc)
+        return {
+            "route": OPERATOR_SUMMARY_ROUTE,
+            "status": "FAILED",
+            "error": str(exc),
+        }
+
+
+def _reject_reason(raw_result: dict[str, Any] | None, system_status: str) -> str:
+    if raw_result:
+        parsed = parse_review_result(raw_result)
+        reasons = list(parsed.reject_messages)
+        reasons.extend(fact.reason for fact in parsed.rejection_facts)
+        unique = list(dict.fromkeys(reason.strip() for reason in reasons if reason.strip()))
+        if unique:
+            return ";".join(unique)
+    if system_status == DENIED_SYSTEM_STATUS:
+        return "腾讯正式审核未通过(接口未返回具体原因)"
+    return "腾讯正式审核未通过"
+
+
+def _cleanup_reason(
+    action: dict[str, Any],
+    raw_result: dict[str, Any] | None,
+    system_status: str,
+) -> str:
+    if action["cleanup_action"] == DELETE_CREATIVE:
+        return "创意审核状态为 CREATIVE_SET_APPROVAL_STATUS_DENIED"
+    return _reject_reason(raw_result, system_status)
+
+
+def _json_object(value: Any) -> dict[str, Any]:
+    if isinstance(value, dict):
+        return value
+    if not value:
+        return {}
+    try:
+        parsed = json.loads(str(value))
+    except (TypeError, ValueError, json.JSONDecodeError):
+        return {}
+    return parsed if isinstance(parsed, dict) else {}
+
+
+def _display_date(value: Any) -> str:
+    if isinstance(value, (date, datetime)):
+        return value.strftime("%Y-%m-%d")
+    return str(value or "")[:10]
+
+
+def _write_report(path: Path, rows: list[dict[str, Any]]) -> None:
+    workbook = Workbook()
+    sheet = workbook.active
+    sheet.title = "审核不通过创意清理"
+    sheet.append(list(REPORT_COLUMNS))
+    for row in rows:
+        raw_result = _json_object(
+            row.get("review_result") or row.get("review_result_json")
+        )
+        pre_state = _json_object(row.get("pre_state") or row.get("pre_state_json"))
+        granular = review_granularity_fields(raw_result)
+        action = str(row.get("cleanup_action") or "")
+        if action == DELETE_CREATIVE:
+            execution_action = "删除创意"
+        else:
+            execution_action = "需人工判断"
+        checked_at = (
+            row.get("checked_at")
+            or row.get("created_at")
+            or row.get("updated_at")
+            or row.get("deleted_at")
+        )
+        sheet.append(
+            [
+                row.get("agency_name") or "",
+                str(row["account_id"]),
+                row.get("account_name") or "",
+                str(row["adgroup_id"]),
+                row.get("adgroup_name") or "",
+                str(row["dynamic_creative_id"]),
+                row.get("dynamic_creative_name") or "",
+                status_desc(
+                    pre_state.get("configured_status")
+                    or row.get("configured_status")
+                ),
+                status_desc(
+                    pre_state.get("creative_set_approval_status")
+                    or row.get("creative_set_approval_status")
+                ),
+                granular["element_review_status"],
+                granular["element_reject_reason"],
+                granular["site_review_status"],
+                granular["site_reject_reason"],
+                row["reject_reason"],
+                (
+                    checked_at.strftime("%Y-%m-%d %H:%M:%S")
+                    if isinstance(checked_at, (date, datetime))
+                    else str(checked_at or "")
+                ),
+                execution_action,
+            ]
+        )
+    header_fill = PatternFill("solid", fgColor="C65911")
+    for cell in sheet[1]:
+        cell.fill = header_fill
+        cell.font = Font(color="FFFFFF", bold=True)
+        cell.alignment = Alignment(horizontal="center", vertical="center")
+    widths = [22, 14, 22, 14, 30, 16, 30, 20, 24, 40, 60, 40, 60, 60, 20, 16]
+    for index, width in enumerate(widths, start=1):
+        sheet.column_dimensions[get_column_letter(index)].width = width
+    for row in sheet.iter_rows(min_row=2):
+        for cell in row:
+            cell.alignment = Alignment(vertical="top", wrap_text=True)
+        for column_index in (2, 4, 6):
+            row[column_index - 1].number_format = "@"
+    sheet.freeze_panes = "A2"
+    sheet.auto_filter.ref = sheet.dimensions
+    path.parent.mkdir(parents=True, exist_ok=True)
+    workbook.save(path)
+
+
+def write_cleanup_reports(
+    rows: list[dict[str, Any]],
+    output_dir: Path,
+    report_date: str,
+) -> tuple[str, list[dict[str, object]], dict[str, list[int]]]:
+    grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
+    for row in rows:
+        grouped[str(row["agency_name"])].append(row)
+    def digest_for(report_rows: list[dict[str, Any]], destination: str) -> str:
+        digest_input = "|".join(
+            ":".join(
+                [
+                    str(row["id"]),
+                    str(row.get("cleanup_action") or ""),
+                    str(row.get("cleanup_status") or ""),
+                    str(row.get("recent_cost_fen")),
+                    _display_date(row.get("cost_end_date")),
+                    str(row.get("reject_reason") or ""),
+                ]
+            )
+            for row in sorted(report_rows, key=lambda value: int(value["id"]))
+        )
+        return hashlib.sha256(
+            f"{report_date}|{destination}|{digest_input}".encode("utf-8")
+        ).hexdigest()[:12]
+
+    digest = digest_for(rows, "all")
+    run_id = f"reject_{report_date}_{digest}"
+    reports: list[dict[str, object]] = []
+    item_ids: dict[str, list[int]] = {}
+    for agency, agency_rows in sorted(grouped.items()):
+        agency_digest = digest_for(agency_rows, agency)
+        safe_agency = agency.replace("/", "_").replace("\\", "_")
+        path = output_dir / (
+            f"{report_date}_{safe_agency}_创意审核异常处理_{agency_digest}.xlsx"
+        )
+        _write_report(path, agency_rows)
+        reports.append(
+            {
+                "agency_name": agency,
+                "report_version": REPORT_VERSION,
+                "report": str(path),
+                "title": f"{report_date}_{agency}_创意审核异常处理通知",
+                "creative_rows": len(agency_rows),
+                "ad_rows": 0,
+                "notification_type": "creative_rejection_cleanup",
+                "run_id": f"reject_{report_date}_{agency_digest}",
+            }
+        )
+        item_ids[agency] = [int(row["id"]) for row in agency_rows]
+    return run_id, reports, item_ids
+
+
+def write_cleanup_operator_summary(
+    rows: list[dict[str, Any]],
+    output_dir: Path,
+    report_date: str,
+    run_id: str,
+) -> dict[str, object]:
+    digest_input = "|".join(
+        ":".join(
+            [
+                str(row["id"]),
+                str(row.get("cleanup_action") or ""),
+                str(row.get("cleanup_status") or ""),
+                str(row.get("recent_cost_fen")),
+                _display_date(row.get("cost_end_date")),
+                str(row.get("reject_reason") or ""),
+            ]
+        )
+        for row in sorted(rows, key=lambda value: int(value["id"]))
+    )
+    digest = hashlib.sha256(
+        f"{report_date}|{OPERATOR_SUMMARY_ROUTE}|{digest_input}".encode("utf-8")
+    ).hexdigest()[:12]
+    path = output_dir / (
+        f"{report_date}_投放调控_创意审核异常处理汇总_{digest}.xlsx"
+    )
+    _write_report(path, rows)
+    return {
+        "report_version": f"{REPORT_VERSION}_operator_summary",
+        "report": str(path),
+        "title": f"{report_date}_创意审核异常处理汇总通知",
+        "creative_rows": len(rows),
+        "run_id": f"reject_{report_date}_{digest}",
+    }
+
+
+def _chunks(values: list[int], size: int) -> list[list[int]]:
+    return [values[offset : offset + size] for offset in range(0, len(values), size)]
+
+
+def _scan_one_account(
+    account: dict[str, Any],
+    *,
+    tencent,
+    review_fetcher: Callable[[int, list[int]], list[dict]],
+    spend_start_date: date,
+    spend_end_date: date,
+) -> tuple[
+    list[dict],
+    dict[int, dict],
+    dict[int, dict],
+    dict[int, int],
+    str | None,
+    int,
+    str | None,
+]:
+    account_id = int(account["account_id"])
+    try:
+        creatives = tencent.get_dynamic_creatives(account_id)
+        ads = {
+            int(ad["adgroup_id"]): ad
+            for ad in tencent.get_ads(account_id)
+            if _as_int(ad.get("adgroup_id")) is not None
+        }
+        ids = [
+            int(row["dynamic_creative_id"])
+            for row in creatives
+            if _as_int(row.get("dynamic_creative_id")) is not None
+            and row.get("configured_status") != DELETED_STATUS
+        ]
+        raw_by_id: dict[int, dict] = {}
+        for batch in _chunks(ids, 100):
+            for raw in review_fetcher(account_id, batch):
+                creative_id = _as_int(raw.get("dynamic_creative_id"))
+                if creative_id is not None:
+                    raw_by_id[creative_id] = raw
+        cost_by_id: dict[int, int] = {}
+        spend_error = None
+        partial_ids = [
+            int(row["dynamic_creative_id"])
+            for row in creatives
+            if _as_int(row.get("dynamic_creative_id")) is not None
+            and row.get("configured_status") != DELETED_STATUS
+            and row.get("creative_set_approval_status")
+            == CREATIVE_PARTIAL_NORMAL_STATUS
+        ]
+        if partial_ids:
+            try:
+                cost_by_id = tencent.get_dynamic_creative_costs(
+                    account_id,
+                    partial_ids,
+                    spend_start_date,
+                    spend_end_date,
+                )
+            except Exception as exc:
+                spend_error = str(exc)
+                logger.exception(
+                    "creative cost scan failed account=%d start=%s end=%s",
+                    account_id,
+                    spend_start_date,
+                    spend_end_date,
+                )
+        return creatives, ads, raw_by_id, cost_by_id, spend_error, len(ids), None
+    except Exception as exc:
+        return [], {}, {}, {}, None, 0, f"account={account_id} scan failed: {exc}"
+
+
+def cleanup_precondition_failure(
+    item: dict[str, Any],
+    scanned_accounts: set[int],
+    confirmed_actions: dict[tuple[int, int], dict[str, Any]],
+) -> tuple[str, str] | None:
+    """Fail closed unless this run reconfirmed the exact cleanup action."""
+
+    account_id = int(item["account_id"])
+    creative_id = int(item["dynamic_creative_id"])
+    if account_id not in scanned_accounts:
+        return "DEFERRED", "本轮账户审核结果扫描失败,未执行删除"
+    confirmed = confirmed_actions.get((account_id, creative_id))
+    if not confirmed:
+        return (
+            "SKIPPED_REVIEW_NOT_RECONFIRMED",
+            "本轮未按新规则再次确认清理动作",
+        )
+    item_action = str(item.get("cleanup_action") or "")
+    if item_action != DELETE_CREATIVE:
+        return "SKIPPED_REVIEW_NOT_RECONFIRMED", "当前规则不允许删除拒审元素组件"
+    if item_action != confirmed["cleanup_action"]:
+        return "SKIPPED_REVIEW_NOT_RECONFIRMED", "本轮清理动作与候选记录不一致"
+    return None
+
+
+def _same_cleanup_action(
+    expected_action: str,
+    actual: dict[str, Any] | None,
+) -> bool:
+    return bool(
+        expected_action == DELETE_CREATIVE
+        and actual
+        and actual.get("cleanup_action") == DELETE_CREATIVE
+    )
+
+
+def run_rejected_creative_cleanup(
+    *,
+    output_dir: Path,
+    now: datetime | None = None,
+    tencent=None,
+    odps=None,
+    review_fetcher: Callable[[int, list[int]], list[dict]] | None = None,
+    publisher: RoiFeishuPublisher | None = None,
+    notifier=None,
+) -> dict[str, Any]:
+    """Scan recent-spend accounts and apply the current creative-review rules."""
+
+    effective_now = now or datetime.now(SHANGHAI)
+    if effective_now.tzinfo is None:
+        effective_now = effective_now.replace(tzinfo=SHANGHAI)
+    apply_enabled = _env_flag("DAILY_REJECTED_CREATIVE_APPLY_ENABLED")
+    webhook_config = AgencyWebhookConfig.from_env()
+    if apply_enabled and not webhook_config.enabled:
+        raise RuntimeError(
+            "DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1 requires "
+            "ROI_AGENCY_WEBHOOK_ENABLED=1"
+        )
+    if apply_enabled and not os.getenv("FEISHU_OPERATOR_CHAT_ID", "").strip():
+        raise RuntimeError(
+            "DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1 requires "
+            "FEISHU_OPERATOR_CHAT_ID"
+        )
+    initialize_schema()
+    if odps is None:
+        from roi_control.odps_client import ODPSClient
+
+        odps_client = ODPSClient(project=os.getenv("ODPS_PROJECT", "loghubods"))
+    else:
+        odps_client = odps
+    end_date = resolve_end_date(odps_client, now=effective_now)
+    start_date, end_date = date_window(end_date)
+    spend_end_date = effective_now.date() - timedelta(days=1)
+    spend_start_date = spend_end_date - timedelta(days=2)
+    cost_threshold_fen = partial_creative_cost_threshold_fen()
+    wechat_cost_threshold_fen = wechat_mini_program_cost_threshold_fen()
+    daily = fetch_daily_data(odps_client, start_date, end_date)
+    context = build_agency_context(daily)
+    accounts = fetch_recent_spend_accounts(odps_client, start_date, end_date)
+    unresolved_account_ids = [
+        int(account["account_id"])
+        for account in accounts
+        if int(account["account_id"]) not in context["account_agencies"]
+    ]
+    try:
+        context["fallback_account_agencies"] = fetch_account_agency_fallbacks(
+            odps_client,
+            unresolved_account_ids,
+        )
+    except Exception:
+        logger.exception(
+            "account agency fallback query failed accounts=%d",
+            len(unresolved_account_ids),
+        )
+
+    owned_tencent = tencent is None
+    if tencent is None:
+        from tencent_client import TencentClient
+
+        client = TencentClient()
+    else:
+        client = tencent
+    account_ids = [int(account["account_id"]) for account in accounts]
+    prefetched_tokens = prefetch_account_access_tokens(account_ids)
+    seed_tokens = getattr(client, "seed_access_tokens", None)
+    if callable(seed_tokens):
+        seed_tokens(prefetched_tokens)
+    fetch_reviews = review_fetcher or fetch_dynamic_creative_review_results
+    discovered = 0
+    scanned = 0
+    scanned_accounts: set[int] = set()
+    confirmed_actions: dict[tuple[int, int], dict[str, Any]] = {}
+    scan_errors: list[str] = []
+    try:
+        scan_workers = int(os.getenv("TENCENT_AD_ACCOUNT_SCAN_WORKERS", "8"))
+        if scan_workers < 1:
+            raise ValueError("TENCENT_AD_ACCOUNT_SCAN_WORKERS must be at least 1")
+        workers = min(scan_workers, len(accounts), 32) if accounts else 1
+
+        def run_scan(account: dict[str, Any]):
+            if owned_tencent:
+                from tencent_client import TencentClient
+
+                scan_client = TencentClient()
+                scan_client.seed_access_tokens(prefetched_tokens)
+            else:
+                scan_client = client
+            try:
+                return _scan_one_account(
+                    account,
+                    tencent=scan_client,
+                    review_fetcher=fetch_reviews,
+                    spend_start_date=spend_start_date,
+                    spend_end_date=spend_end_date,
+                )
+            finally:
+                if scan_client is not client:
+                    scan_client.session.close()
+
+        logger.info("account scan started accounts=%d workers=%d", len(accounts), workers)
+        scan_results = []
+        with ThreadPoolExecutor(
+            max_workers=workers,
+            thread_name_prefix="creative-scan",
+        ) as executor:
+            futures = {executor.submit(run_scan, account): account for account in accounts}
+            for completed, future in enumerate(as_completed(futures), start=1):
+                account = futures[future]
+                account_id = int(account["account_id"])
+                try:
+                    (
+                        creatives,
+                        ads,
+                        raw_by_id,
+                        cost_by_id,
+                        spend_error,
+                        account_scanned,
+                        error,
+                    ) = future.result()
+                except Exception as exc:
+                    creatives, ads, raw_by_id, cost_by_id = [], {}, {}, {}
+                    spend_error, account_scanned = None, 0
+                    error = f"account={account_id} scan failed: {exc}"
+                scanned += account_scanned
+                if error:
+                    logger.error(error)
+                    scan_errors.append(error)
+                else:
+                    scanned_accounts.add(account_id)
+                    scan_results.append(
+                        (
+                            account,
+                            creatives,
+                            ads,
+                            raw_by_id,
+                            cost_by_id,
+                            spend_error,
+                        )
+                    )
+                logger.info(
+                    "account scan progress=%d/%d account=%d creatives=%d error=%s",
+                    completed,
+                    len(accounts),
+                    account_id,
+                    account_scanned,
+                    bool(error),
+                )
+
+        for (
+            account,
+            creatives,
+            ads,
+            raw_by_id,
+            cost_by_id,
+            spend_error,
+        ) in scan_results:
+            account_id = int(account["account_id"])
+            for creative in creatives:
+                creative_id = _as_int(creative.get("dynamic_creative_id"))
+                adgroup_id = _as_int(creative.get("adgroup_id"))
+                if creative_id is None or adgroup_id is None:
+                    continue
+                raw_result = raw_by_id.get(creative_id)
+                system_status = str(creative.get("system_status") or "")
+                is_partial = (
+                    creative.get("creative_set_approval_status")
+                    == CREATIVE_PARTIAL_NORMAL_STATUS
+                )
+                action = determine_cleanup_action(
+                    creative,
+                    raw_result,
+                    recent_cost_fen=cost_by_id.get(creative_id),
+                    cost_threshold_fen=cost_threshold_fen,
+                    wechat_cost_threshold_fen=wechat_cost_threshold_fen,
+                    spend_error=(spend_error if is_partial else None),
+                )
+                if action is None:
+                    continue
+                confirmed_actions[(account_id, creative_id)] = action
+                ad = ads.get(adgroup_id) or {}
+                upsert_cleanup_candidate(
+                    {
+                        "account_id": account_id,
+                        "account_name": context["account_names"].get(account_id)
+                        or account.get("account_name")
+                        or "",
+                        "agency_name": _resolve_agency(
+                            context, account_id, creative_id
+                        ),
+                        "adgroup_id": adgroup_id,
+                        "adgroup_name": ad.get("adgroup_name") or "",
+                        "dynamic_creative_id": creative_id,
+                        "dynamic_creative_name": creative.get(
+                            "dynamic_creative_name"
+                        )
+                        or "",
+                        "check_date": effective_now.date(),
+                        **action,
+                        "action_reason": action.get("action_reason")
+                        or _cleanup_reason(action, raw_result, system_status),
+                        "reject_reason": _reject_reason(raw_result, system_status),
+                        "cost_start_date": spend_start_date,
+                        "cost_end_date": spend_end_date,
+                        "review_result": raw_result or {},
+                        "pre_state": creative,
+                    }
+                )
+                discovered += 1
+
+        deleted = 0
+        deferred = 0
+        delete_errors: list[str] = []
+        write_lock_name = os.getenv(
+            "RTC_DB_LOCK_NAME", "tencent_realtime_control"
+        )
+        retryable_items = load_retryable_cleanup_items()
+        for item in retryable_items if apply_enabled else []:
+            item_id = int(item["id"])
+            account_id = int(item["account_id"])
+            creative_id = int(item["dynamic_creative_id"])
+            if item.get("cleanup_action") != DELETE_CREATIVE:
+                update_cleanup_item(
+                    item_id,
+                    cleanup_status="SKIPPED_REVIEW_NOT_RECONFIRMED",
+                    error_message="当前规则仅允许删除整条创意",
+                )
+                continue
+            agency = str(item.get("agency_name") or "") or _resolve_agency(
+                context, account_id, creative_id
+            )
+            if agency and agency != item.get("agency_name"):
+                update_cleanup_item(item_id, agency_name=agency)
+            route_key = "".join(agency.split())
+            for prefix in ("小程序-代投-", "小程序-代投"):
+                if route_key.startswith(prefix):
+                    route_key = route_key[len(prefix) :]
+                    break
+            if not agency or route_key not in (webhook_config.webhooks or {}):
+                reason = (
+                    "代理商归属为空,禁止自动删除"
+                    if not agency
+                    else f"代理商 {agency} 未配置通知群,禁止自动删除"
+                )
+                update_cleanup_item(
+                    item_id,
+                    cleanup_status="DEFERRED",
+                    error_message=reason,
+                )
+                deferred += 1
+                continue
+            outcome_unknown = item.get("cleanup_status") == "WRITE_OUTCOME_UNKNOWN"
+            if not outcome_unknown:
+                precondition_failure = cleanup_precondition_failure(
+                    item,
+                    scanned_accounts,
+                    confirmed_actions,
+                )
+                if precondition_failure:
+                    status, reason = precondition_failure
+                    update_cleanup_item(
+                        item_id,
+                        cleanup_status=status,
+                        error_message=reason,
+                    )
+                    if status == "DEFERRED":
+                        deferred += 1
+                    continue
+            with advisory_lock(write_lock_name) as acquired:
+                if not acquired:
+                    update_cleanup_item(
+                        item_id,
+                        cleanup_status="DEFERRED",
+                        error_message="腾讯写锁被实时调控占用",
+                    )
+                    deferred += 1
+                    continue
+                try:
+                    try:
+                        before = client.get_dynamic_creative(account_id, creative_id)
+                    except Exception as read_exc:
+                        if (
+                            item.get("cleanup_action") == DELETE_CREATIVE
+                            and item.get("cleanup_status") == "WRITE_OUTCOME_UNKNOWN"
+                            and str(read_exc).startswith("Dynamic creative not found:")
+                        ):
+                            update_cleanup_item(
+                                item_id,
+                                cleanup_status="CREATIVE_DELETED",
+                                error_message=None,
+                                readback_json=_json({"deleted_from_listing": True}),
+                                deleted_at=effective_now,
+                            )
+                            deleted += 1
+                            continue
+                        raise
+                    if outcome_unknown:
+                        precondition_failure = cleanup_precondition_failure(
+                            item,
+                            scanned_accounts,
+                            confirmed_actions,
+                        )
+                        if precondition_failure:
+                            status, reason = precondition_failure
+                            update_cleanup_item(
+                                item_id,
+                                cleanup_status=status,
+                                error_message=reason,
+                            )
+                            if status == "DEFERRED":
+                                deferred += 1
+                            continue
+                    action = str(item.get("cleanup_action") or "")
+                    approval_status = str(
+                        before.get("creative_set_approval_status") or ""
+                    )
+                    fresh_raw = None
+                    if approval_status == CREATIVE_DENIED_STATUS:
+                        fresh_action = determine_cleanup_action(before, None)
+                    else:
+                        fresh_results = fetch_reviews(account_id, [creative_id])
+                        fresh_raw = next(
+                            (
+                                result
+                                for result in fresh_results
+                                if _as_int(result.get("dynamic_creative_id")) == creative_id
+                            ),
+                            None,
+                        )
+                        fresh_cost_fen = None
+                        fresh_spend_error = None
+                        if approval_status == CREATIVE_PARTIAL_NORMAL_STATUS:
+                            try:
+                                fresh_cost_fen = client.get_dynamic_creative_costs(
+                                    account_id,
+                                    [creative_id],
+                                    spend_start_date,
+                                    spend_end_date,
+                                ).get(creative_id, 0)
+                            except Exception as spend_exc:
+                                fresh_spend_error = str(spend_exc)
+                        fresh_action = determine_cleanup_action(
+                            before,
+                            fresh_raw,
+                            recent_cost_fen=fresh_cost_fen,
+                            cost_threshold_fen=cost_threshold_fen,
+                            wechat_cost_threshold_fen=wechat_cost_threshold_fen,
+                            spend_error=fresh_spend_error,
+                        )
+                    if not _same_cleanup_action(
+                        action,
+                        fresh_action,
+                    ):
+                        if (
+                            fresh_action
+                            and fresh_action.get("cleanup_action") == ALERT_ONLY
+                        ):
+                            update_cleanup_item(
+                                item_id,
+                                cleanup_action=ALERT_ONLY,
+                                target_component_ids_json="[]",
+                                target_element_ids_json="[]",
+                                recent_cost_fen=fresh_action.get("recent_cost_fen"),
+                                cost_start_date=spend_start_date,
+                                cost_end_date=spend_end_date,
+                                action_reason=fresh_action["action_reason"],
+                                reject_reason=_reject_reason(
+                                    fresh_raw,
+                                    str(before.get("system_status") or ""),
+                                ),
+                                review_result_json=_json(fresh_raw or {}),
+                                cleanup_status="ALERT_PENDING",
+                                error_message=None,
+                                pre_state_json=_json(before),
+                                readback_json=None,
+                                deleted_at=None,
+                                notified_at=None,
+                            )
+                            continue
+                        update_cleanup_item(
+                            item_id,
+                            cleanup_status="SKIPPED_REVIEW_NOT_RECONFIRMED",
+                            error_message="写锁内回读发现整创意删除条件已变化",
+                            pre_state_json=_json(before),
+                        )
+                        continue
+
+                    if action == DELETE_CREATIVE:
+                        readback = client.delete_dynamic_creative(
+                            account_id, creative_id
+                        )
+                        update_cleanup_item(
+                            item_id,
+                            cleanup_status="CREATIVE_DELETED",
+                            error_message=None,
+                            pre_state_json=_json(before),
+                            readback_json=_json(readback),
+                            deleted_at=effective_now,
+                        )
+                        deleted += 1
+                        continue
+                except Exception as exc:
+                    from tencent_client import (
+                        PostWriteVerificationError,
+                        TencentWriteOutcomeUnknownError,
+                    )
+
+                    outcome_unknown = isinstance(
+                        exc,
+                        (
+                            TencentWriteOutcomeUnknownError,
+                            PostWriteVerificationError,
+                        ),
+                    )
+                    update_cleanup_item(
+                        item_id,
+                        cleanup_status=(
+                            "WRITE_OUTCOME_UNKNOWN" if outcome_unknown else "FAILED"
+                        ),
+                        error_message=str(exc)[:4000],
+                    )
+                    delete_errors.append(
+                        f"account={account_id} creative={creative_id}: {exc}"
+                    )
+
+        pending_notifications = load_unnotified_deleted_items()
+        deliveries: list[dict[str, object]] = []
+        operator_deliveries: list[dict[str, object]] = []
+        notification_errors: list[str] = []
+        if pending_notifications and webhook_config.enabled:
+            owned_publisher = publisher is None
+            sheet_publisher = publisher or RoiFeishuPublisher(require_chat_ids=False)
+            try:
+                rows_by_date: dict[str, list[dict[str, Any]]] = defaultdict(list)
+                for row in pending_notifications:
+                    rows_by_date[_display_date(row.get("check_date"))].append(row)
+                for check_date, daily_rows in sorted(rows_by_date.items()):
+                    report_date = check_date.replace("-", "")
+                    report_dir = output_dir / report_date
+                    agency_rows = [
+                        row
+                        for row in daily_rows
+                        if row.get("agency_notified_at") is None
+                        and str(row.get("agency_name") or "").strip()
+                    ]
+                    run_id = f"reject_{report_date}_{REPORT_VERSION}"
+                    if agency_rows:
+                        run_id, reports, report_item_ids = write_cleanup_reports(
+                            agency_rows,
+                            report_dir,
+                            report_date,
+                        )
+                        for report in reports:
+                            daily_deliveries = publish_agency_reports(
+                                run_id=str(report.get("run_id") or run_id),
+                                reports=[report],
+                                config=webhook_config,
+                                publisher=sheet_publisher,
+                                notifier=notifier,
+                                now=effective_now,
+                                upsert_delivery=upsert_cleanup_delivery,
+                                update_delivery=update_cleanup_delivery,
+                            )
+                            deliveries.extend(daily_deliveries)
+                            for outcome in daily_deliveries:
+                                agency_name = str(outcome["agency_name"])
+                                if outcome.get("status") == "SENT":
+                                    mark_cleanup_items_notified(
+                                        report_item_ids.get(agency_name, []),
+                                        effective_now,
+                                    )
+                                else:
+                                    notification_errors.append(
+                                        f"agency={agency_name}: "
+                                        f"{outcome.get('error') or outcome.get('reason') or outcome.get('status')}"
+                                    )
+                    operator_rows = [
+                        row
+                        for row in daily_rows
+                        if row.get("operator_notified_at") is None
+                    ]
+                    if operator_rows:
+                        operator_report = write_cleanup_operator_summary(
+                            operator_rows,
+                            report_dir,
+                            report_date,
+                            run_id,
+                        )
+                        operator_outcome = publish_cleanup_operator_summary(
+                            run_id=str(operator_report["run_id"]),
+                            report=operator_report,
+                            chat_id=os.getenv("FEISHU_OPERATOR_CHAT_ID", ""),
+                            publisher=sheet_publisher,
+                            now=effective_now,
+                        )
+                        operator_deliveries.append(operator_outcome)
+                        if operator_outcome.get("status") == "SENT":
+                            mark_cleanup_items_operator_notified(
+                                [int(row["id"]) for row in operator_rows],
+                                effective_now,
+                            )
+                        else:
+                            notification_errors.append(
+                                f"operator={check_date}: "
+                                f"{operator_outcome.get('error') or operator_outcome.get('status')}"
+                            )
+            finally:
+                if owned_publisher:
+                    sheet_publisher.close()
+
+        return {
+            "apply_enabled": apply_enabled,
+            "account_scope": "opengid_recent_3d_spend",
+            "account_scope_start_date": start_date,
+            "account_scope_end_date": end_date,
+            "accounts": len(accounts),
+            "account_ids": account_ids,
+            "tokens_prefetched": len(prefetched_tokens),
+            "creatives_scanned": scanned,
+            "rejected_discovered": discovered,
+            "pending_cleanup": len(retryable_items) if not apply_enabled else 0,
+            "deleted": deleted,
+            "deferred": deferred,
+            "scan_errors": scan_errors,
+            "delete_errors": delete_errors,
+            "notification_errors": notification_errors,
+            "deliveries": deliveries,
+            "operator_deliveries": operator_deliveries,
+        }
+    finally:
+        if owned_tencent:
+            client.session.close()

+ 148 - 0
examples/auto_put_ad_mini/tools/creative_review.py

@@ -83,6 +83,25 @@ CREATE TABLE IF NOT EXISTS creative_rejection_fact (
 FINAL_REVIEW_STATUSES = {"approved", "rejected"}
 FINAL_REVIEW_STATUSES = {"approved", "rejected"}
 PENDING_REVIEW_STATUSES = {"submitted", "pending", "unknown"}
 PENDING_REVIEW_STATUSES = {"submitted", "pending", "unknown"}
 
 
+# Tencent API enum descriptions used by business-facing reports. Keep unknown
+# values unchanged so newly introduced API enums remain visible for diagnosis.
+STATUS_DESCRIPTIONS = {
+    "AD_STATUS_NORMAL": "投放中",
+    "AD_STATUS_SUSPEND": "暂停",
+    "AD_STATUS_DELETED": "已删除",
+    "AD_STATUS_DENIED": "审核拒绝",
+    "CREATIVE_SET_APPROVAL_STATUS_UNKNOWN": "未知",
+    "CREATIVE_SET_APPROVAL_STATUS_PENDING": "审核中",
+    "CREATIVE_SET_APPROVAL_STATUS_NORMAL": "投放中",
+    "CREATIVE_SET_APPROVAL_STATUS_DENIED": "审核拒绝",
+    "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL": "部分投放中",
+    "DYNAMIC_CREATIVE_STATUS_DENIED": "审核拒绝",
+    "DYNAMIC_CREATIVE_STATUS_PENDING": "审核中",
+    "REVIEW_STATUS_REJECTED": "审核拒绝",
+    "REVIEW_STATUS_APPROVED": "审核通过",
+    "REVIEW_STATUS_PENDING": "审核中",
+}
+
 
 
 @dataclass
 @dataclass
 class RejectionFact:
 class RejectionFact:
@@ -104,6 +123,129 @@ class ParsedReviewResult:
     rejection_facts: list[RejectionFact] = field(default_factory=list)
     rejection_facts: list[RejectionFact] = field(default_factory=list)
 
 
 
 
+def _display_key(value: object, fallback: str) -> str:
+    key = str(value or "").strip()
+    return key or fallback
+
+
+def _review_item_label(
+    item: dict,
+    fallback: str,
+    *,
+    site: bool = False,
+    include_ids: bool = False,
+) -> str:
+    if site:
+        base = _display_key(item.get("site_set"), fallback)
+        id_keys = ("site_set_id", "site_id", "id")
+    else:
+        base = _display_key(
+            item.get("element_name")
+            or item.get("element_type"),
+            fallback,
+        )
+        id_keys = ("element_id", "image_id", "video_id", "id")
+    if not include_ids:
+        return base
+    identifiers = []
+    for key in id_keys:
+        value = str(item.get(key) or "").strip()
+        if value and value not in identifiers:
+            identifiers.append(value)
+    return f"{base}(id={','.join(identifiers)})" if identifiers else base
+
+
+def _is_normal_review_status(value: object) -> bool:
+    return str(value or "").strip() == "AD_STATUS_NORMAL"
+
+
+def status_desc(value: object) -> str:
+    """Return the Chinese API description, preserving unknown enum values."""
+
+    raw = str(value or "").strip()
+    return STATUS_DESCRIPTIONS.get(raw, raw)
+
+
+def _labeled_values(
+    values: Iterable[tuple[str, object]],
+    *,
+    describe_status: bool = False,
+) -> str:
+    rendered = []
+    for label, raw_value in values:
+        value = str(raw_value or "").strip()
+        if value:
+            if describe_status:
+                value = status_desc(value)
+            rendered.append(f"{label}: {value}")
+    return "\n".join(_dedupe(rendered))
+
+
+def review_granularity_fields(result: dict | None) -> dict[str, str]:
+    """Render Tencent element/site review results for business reports."""
+
+    raw = result if isinstance(result, dict) else {}
+    element_statuses: list[tuple[str, object]] = []
+    element_reasons: list[tuple[str, object]] = []
+    for index, element in enumerate(raw.get("element_result_list") or [], start=1):
+        if not isinstance(element, dict):
+            continue
+        status = element.get("review_status") or element.get("system_status")
+        if _is_normal_review_status(status):
+            continue
+        details = element.get("element_reject_detail_info") or []
+        reasons = [
+            detail.get("reason")
+            for detail in details
+            if isinstance(detail, dict) and detail.get("reason")
+        ]
+        if not reasons and element.get("reason"):
+            reasons = [element.get("reason")]
+        label = _review_item_label(
+            element,
+            f"元素{index}",
+            include_ids=_status_is_rejected(str(status or "")) or bool(reasons),
+        )
+        element_statuses.append((label, status))
+        element_reasons.extend((label, reason) for reason in reasons)
+
+    site_statuses: list[tuple[str, object]] = []
+    site_reasons: list[tuple[str, object]] = []
+    for index, site in enumerate(raw.get("site_set_result_list") or [], start=1):
+        if not isinstance(site, dict):
+            continue
+        status = site.get("system_status") or site.get("review_status")
+        if _is_normal_review_status(status):
+            continue
+        reasons = []
+        if site.get("reject_message"):
+            reasons.append(site.get("reject_message"))
+        for detail in site.get("element_reject_detail_info") or []:
+            if isinstance(detail, dict) and detail.get("reason"):
+                reasons.append(detail.get("reason"))
+        label = _review_item_label(
+            site,
+            f"版位{index}",
+            site=True,
+            include_ids=_status_is_rejected(str(status or "")) or bool(reasons),
+        )
+        site_statuses.append((label, status))
+        site_reasons.extend((label, reason) for reason in reasons)
+
+    return {
+        "element_review_status": _labeled_values(
+            element_statuses,
+            describe_status=True,
+        ),
+        "element_reject_reason": _labeled_values(element_reasons),
+        "site_review_status": _labeled_values(
+            site_statuses,
+            describe_status=True,
+        ),
+        "site_reject_reason": _labeled_values(site_reasons),
+    }
+
+
 def _dedupe(values: Iterable[str]) -> list[str]:
 def _dedupe(values: Iterable[str]) -> list[str]:
     """字符串列表去重,保留首次出现顺序,跳过空字符串。"""
     """字符串列表去重,保留首次出现顺序,跳过空字符串。"""
     out: list[str] = []
     out: list[str] = []
@@ -547,9 +689,15 @@ def scan_pending_reviews(
 ) -> dict:
 ) -> dict:
     """Scan pending submitted creatives and persist official review results."""
     """Scan pending submitted creatives and persist official review results."""
     tasks = load_pending_review_tasks(lookback_hours=lookback_hours, limit=limit)
     tasks = load_pending_review_tasks(lookback_hours=lookback_hours, limit=limit)
+    from tools.ad_api import prefetch_access_tokens
+
+    prefetched_tokens = prefetch_access_tokens(
+        int(task["account_id"]) for task in tasks
+    )
     summary = {
     summary = {
         "run_started": datetime.now(timezone.utc).isoformat(),
         "run_started": datetime.now(timezone.utc).isoformat(),
         "tasks": len(tasks),
         "tasks": len(tasks),
+        "tokens_prefetched": len(prefetched_tokens),
         "batches": 0,
         "batches": 0,
         "results": 0,
         "results": 0,
         "approved": 0,
         "approved": 0,

+ 59 - 0
examples/tencent_realtime_control/schema.sql

@@ -225,6 +225,65 @@ CREATE TABLE IF NOT EXISTS roi_agency_delivery (
     KEY idx_roi_agency_delivery_status (run_id, status)
     KEY idx_roi_agency_delivery_status (run_id, status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ROI代理表上传与机器人通知审计';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ROI代理表上传与机器人通知审计';
 
 
+CREATE TABLE IF NOT EXISTS creative_rejection_cleanup_item (
+    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+    account_id BIGINT NOT NULL,
+    account_name VARCHAR(255) DEFAULT NULL,
+    agency_name VARCHAR(255) DEFAULT NULL,
+    adgroup_id BIGINT NOT NULL,
+    adgroup_name VARCHAR(255) DEFAULT NULL,
+    dynamic_creative_id BIGINT NOT NULL,
+    dynamic_creative_name VARCHAR(255) DEFAULT NULL,
+    check_date DATE NOT NULL,
+    cleanup_action VARCHAR(32) DEFAULT NULL,
+    target_component_ids_json LONGTEXT DEFAULT NULL,
+    target_element_ids_json LONGTEXT DEFAULT NULL,
+    recent_cost_fen BIGINT DEFAULT NULL,
+    cost_start_date DATE DEFAULT NULL,
+    cost_end_date DATE DEFAULT NULL,
+    action_reason TEXT DEFAULT NULL,
+    reject_reason TEXT NOT NULL,
+    review_result_json LONGTEXT DEFAULT NULL,
+    pre_state_json LONGTEXT DEFAULT NULL,
+    readback_json LONGTEXT DEFAULT NULL,
+    cleanup_status VARCHAR(32) NOT NULL DEFAULT 'DISCOVERED',
+    error_message TEXT DEFAULT NULL,
+    deleted_at DATETIME DEFAULT NULL,
+    agency_notified_at DATETIME DEFAULT NULL,
+    operator_notified_at DATETIME DEFAULT NULL,
+    notified_at DATETIME DEFAULT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_creative_rejection_cleanup
+        (account_id, dynamic_creative_id, check_date),
+    KEY idx_creative_rejection_status (cleanup_status, notified_at),
+    KEY idx_creative_rejection_agency (agency_name, notified_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拒审创意清理及部分投放创意报警审计';
+
+CREATE TABLE IF NOT EXISTS creative_rejection_delivery (
+    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+    run_id VARCHAR(64) NOT NULL,
+    agency_name VARCHAR(255) NOT NULL,
+    agency_report_version VARCHAR(64) NOT NULL,
+    file_path TEXT NOT NULL,
+    file_sha256 CHAR(64) NOT NULL,
+    creative_rows INT NOT NULL DEFAULT 0,
+    ad_rows INT NOT NULL DEFAULT 0,
+    route_fingerprint CHAR(64) DEFAULT NULL,
+    sheet_token VARCHAR(128) DEFAULT NULL,
+    sheet_url TEXT DEFAULT NULL,
+    status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
+    attempt_count INT NOT NULL DEFAULT 0,
+    response_code VARCHAR(64) DEFAULT NULL,
+    error_message TEXT DEFAULT NULL,
+    sent_at DATETIME DEFAULT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_creative_rejection_delivery
+        (run_id, agency_name, agency_report_version),
+    KEY idx_creative_rejection_delivery_status (run_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审核异常创意清理及人工报警通知审计';
+
 CREATE TABLE IF NOT EXISTS realtime_control_action_log (
 CREATE TABLE IF NOT EXISTS realtime_control_action_log (
     id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
     id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
     run_id VARCHAR(36) NOT NULL,
     run_id VARCHAR(36) NOT NULL,

+ 71 - 1
examples/tencent_realtime_control/storage.py

@@ -174,6 +174,18 @@ def initialize_schema() -> None:
                     "creative_rows": "INT NOT NULL DEFAULT 0",
                     "creative_rows": "INT NOT NULL DEFAULT 0",
                     "ad_rows": "INT NOT NULL DEFAULT 0",
                     "ad_rows": "INT NOT NULL DEFAULT 0",
                 },
                 },
+                "creative_rejection_cleanup_item": {
+                    "check_date": "DATE DEFAULT NULL",
+                    "cleanup_action": "VARCHAR(32) DEFAULT NULL",
+                    "target_component_ids_json": "LONGTEXT DEFAULT NULL",
+                    "target_element_ids_json": "LONGTEXT DEFAULT NULL",
+                    "recent_cost_fen": "BIGINT DEFAULT NULL",
+                    "cost_start_date": "DATE DEFAULT NULL",
+                    "cost_end_date": "DATE DEFAULT NULL",
+                    "action_reason": "TEXT DEFAULT NULL",
+                    "agency_notified_at": "DATETIME DEFAULT NULL",
+                    "operator_notified_at": "DATETIME DEFAULT NULL",
+                },
                 "revenue_forecast_result": {
                 "revenue_forecast_result": {
                     "cost_reserve_date": "DATE DEFAULT NULL",
                     "cost_reserve_date": "DATE DEFAULT NULL",
                     "channel_costs_json": "TEXT DEFAULT NULL",
                     "channel_costs_json": "TEXT DEFAULT NULL",
@@ -224,6 +236,63 @@ def initialize_schema() -> None:
                             f"ALTER TABLE {table_name} "
                             f"ALTER TABLE {table_name} "
                             f"ADD COLUMN {column} {definition}"
                             f"ADD COLUMN {column} {definition}"
                         )
                         )
+            cursor.execute(
+                """
+                UPDATE creative_rejection_cleanup_item
+                SET check_date=DATE(created_at)
+                WHERE check_date IS NULL
+                """
+            )
+            cursor.execute(
+                """
+                SELECT IS_NULLABLE
+                FROM information_schema.COLUMNS
+                WHERE TABLE_SCHEMA=%s
+                  AND TABLE_NAME='creative_rejection_cleanup_item'
+                  AND COLUMN_NAME='check_date'
+                """,
+                (os.environ["DB_NAME"],),
+            )
+            check_date_column = cursor.fetchone()
+            if check_date_column and check_date_column["IS_NULLABLE"] == "YES":
+                cursor.execute(
+                    """
+                    ALTER TABLE creative_rejection_cleanup_item
+                    MODIFY COLUMN check_date DATE NOT NULL
+                    """
+                )
+            cursor.execute(
+                """
+                SELECT COLUMN_NAME
+                FROM information_schema.STATISTICS
+                WHERE TABLE_SCHEMA=%s
+                  AND TABLE_NAME='creative_rejection_cleanup_item'
+                  AND INDEX_NAME='uk_creative_rejection_cleanup'
+                ORDER BY SEQ_IN_INDEX
+                """,
+                (os.environ["DB_NAME"],),
+            )
+            cleanup_unique_columns = [
+                row["COLUMN_NAME"] for row in cursor.fetchall()
+            ]
+            expected_cleanup_unique = [
+                "account_id", "dynamic_creative_id", "check_date"
+            ]
+            if cleanup_unique_columns != expected_cleanup_unique:
+                if cleanup_unique_columns:
+                    cursor.execute(
+                        """
+                        ALTER TABLE creative_rejection_cleanup_item
+                        DROP INDEX uk_creative_rejection_cleanup
+                        """
+                    )
+                cursor.execute(
+                    """
+                    CREATE UNIQUE INDEX uk_creative_rejection_cleanup
+                    ON creative_rejection_cleanup_item
+                       (account_id, dynamic_creative_id, check_date)
+                    """
+                )
             cursor.execute(
             cursor.execute(
                 """
                 """
                 SELECT COLUMN_NAME, IS_NULLABLE
                 SELECT COLUMN_NAME, IS_NULLABLE
@@ -508,7 +577,8 @@ def load_managed_accounts() -> list[dict[str, Any]]:
         with connection.cursor() as cursor:
         with connection.cursor() as cursor:
             cursor.execute(
             cursor.execute(
                 """
                 """
-                SELECT c.account_id, c.audience_name, c.bid_scene, c.enabled
+                SELECT c.account_id, w.account_name,
+                       c.audience_name, c.bid_scene, c.enabled
                 FROM ad_creation_account_config c
                 FROM ad_creation_account_config c
                 JOIN account_whitelist w ON w.account_id = c.account_id
                 JOIN account_whitelist w ON w.account_id = c.account_id
                 WHERE w.enabled = TRUE
                 WHERE w.enabled = TRUE

+ 274 - 1
examples/tencent_realtime_control/tencent_client.py

@@ -7,7 +7,7 @@ import os
 import time
 import time
 import uuid
 import uuid
 from datetime import date
 from datetime import date
-from typing import Any
+from typing import Any, Mapping
 
 
 import requests
 import requests
 
 
@@ -16,6 +16,7 @@ from storage import connect
 
 
 ACTIVE_STATUS = "AD_STATUS_NORMAL"
 ACTIVE_STATUS = "AD_STATUS_NORMAL"
 SUSPEND_STATUS = "AD_STATUS_SUSPEND"
 SUSPEND_STATUS = "AD_STATUS_SUSPEND"
+DELETED_STATUS = "AD_STATUS_DELETED"
 AD_FIELDS = [
 AD_FIELDS = [
     "adgroup_id",
     "adgroup_id",
     "adgroup_name",
     "adgroup_name",
@@ -31,10 +32,13 @@ AD_FIELDS = [
 ]
 ]
 DYNAMIC_CREATIVE_FIELDS = [
 DYNAMIC_CREATIVE_FIELDS = [
     "dynamic_creative_id",
     "dynamic_creative_id",
+    "dynamic_creative_name",
     "adgroup_id",
     "adgroup_id",
     "configured_status",
     "configured_status",
+    "creative_set_approval_status",
     "system_status",
     "system_status",
 ]
 ]
+COMPONENT_FIELDS = ["component_id", "component_type", "is_deleted"]
 
 
 
 
 class PostWriteVerificationError(RuntimeError):
 class PostWriteVerificationError(RuntimeError):
@@ -97,6 +101,15 @@ class TencentClient:
         self._user_tokens: dict[int, str] = {}
         self._user_tokens: dict[int, str] = {}
         self.session = requests.Session()
         self.session = requests.Session()
 
 
+    def seed_access_tokens(self, tokens: Mapping[int, str]) -> None:
+        """Warm account access tokens fetched by the shared concurrent loader."""
+
+        for raw_account_id, raw_token in tokens.items():
+            account_id = int(raw_account_id)
+            token = str(raw_token or "").strip()
+            if account_id > 0 and len(token) > 10:
+                self._access_tokens[account_id] = token
+
     def _access_token(self, account_id: int) -> str:
     def _access_token(self, account_id: int) -> str:
         if account_id not in self._access_tokens:
         if account_id not in self._access_tokens:
             response = self.session.get(
             response = self.session.get(
@@ -345,6 +358,34 @@ class TencentClient:
             f"account={account_id} creative={dynamic_creative_id}"
             f"account={account_id} creative={dynamic_creative_id}"
         )
         )
 
 
+    def get_dynamic_creatives(self, account_id: int) -> list[dict[str, Any]]:
+        """Return every non-deleted dynamic creative visible to the account."""
+
+        creatives: list[dict[str, Any]] = []
+        page = 1
+        while True:
+            params = {
+                **self._common_params(account_id),
+                "account_id": account_id,
+                "fields": json.dumps(DYNAMIC_CREATIVE_FIELDS, ensure_ascii=False),
+                "page": page,
+                "page_size": 100,
+            }
+            response = self.session.get(
+                f"{self.base_url}/dynamic_creatives/get",
+                params=params,
+                timeout=self.timeout,
+            )
+            response.raise_for_status()
+            data = self._check(response.json(), "get_dynamic_creatives")
+            rows = data.get("list") or []
+            creatives.extend(rows)
+            page_info = data.get("page_info") or {}
+            if page >= int(page_info.get("total_page") or 1):
+                break
+            page += 1
+        return creatives
+
     def update_dynamic_creative_status(
     def update_dynamic_creative_status(
         self,
         self,
         account_id: int,
         account_id: int,
@@ -411,6 +452,172 @@ class TencentClient:
             actual=last_actual,
             actual=last_actual,
         )
         )
 
 
+    def delete_dynamic_creative(
+        self,
+        account_id: int,
+        dynamic_creative_id: int,
+    ) -> dict[str, Any]:
+        """Delete a creative through Tencent's dedicated delete endpoint."""
+
+        try:
+            params = {
+                **self._common_params(account_id),
+                "user_token": self._user_token(account_id),
+            }
+        except Exception as exc:
+            raise TencentWriteNotSentError(str(exc)) from exc
+        try:
+            response = self.session.post(
+                f"{self.base_url}/dynamic_creatives/delete",
+                params=params,
+                json={
+                    "account_id": account_id,
+                    "dynamic_creative_id": dynamic_creative_id,
+                },
+                timeout=self.timeout,
+            )
+        except requests.RequestException as exc:
+            raise TencentWriteOutcomeUnknownError(str(exc)) from exc
+        if response.status_code == 408 or response.status_code >= 500:
+            raise TencentWriteOutcomeUnknownError(
+                f"Tencent HTTP {response.status_code}: {response.text[:500]}"
+            )
+        try:
+            response.raise_for_status()
+            data = self._check(response.json(), "delete_dynamic_creative")
+        except requests.HTTPError as exc:
+            raise TencentWriteRejectedError(str(exc)) from exc
+        except (ValueError, TypeError) as exc:
+            raise TencentWriteOutcomeUnknownError(
+                f"Tencent returned invalid JSON: {response.text[:500]}"
+            ) from exc
+        except RuntimeError as exc:
+            raise TencentWriteRejectedError(str(exc)) from exc
+
+        last_actual: dict[str, Any] = {}
+        for attempt in range(1, self.verify_attempts + 1):
+            try:
+                creative = self.get_dynamic_creative(account_id, dynamic_creative_id)
+                last_actual = {
+                    "configured_status": creative.get("configured_status")
+                }
+            except Exception as exc:
+                if str(exc).startswith("Dynamic creative not found:"):
+                    return {
+                        **data,
+                        "dynamic_creative_id": dynamic_creative_id,
+                        "configured_status": DELETED_STATUS,
+                        "deleted_from_listing": True,
+                    }
+                last_actual = {"verification_error": str(exc)}
+            if attempt < self.verify_attempts:
+                time.sleep(self.verify_delay_seconds)
+        raise PostWriteVerificationError(
+            account_id=account_id,
+            adgroup_id=dynamic_creative_id,
+            expected={"deleted_from_listing": True},
+            actual=last_actual,
+        )
+
+    def get_creative_component(
+        self,
+        account_id: int,
+        component_id: int,
+        *,
+        include_deleted: bool = True,
+    ) -> dict[str, Any]:
+        params = {
+            **self._common_params(account_id),
+            "account_id": account_id,
+            "filtering": json.dumps(
+                [{
+                    "field": "component_id",
+                    "operator": "EQUALS",
+                    "values": [str(component_id)],
+                }]
+            ),
+            "fields": json.dumps(COMPONENT_FIELDS, ensure_ascii=False),
+            "is_deleted": str(include_deleted).lower(),
+            "page": 1,
+            "page_size": 10,
+        }
+        response = self.session.get(
+            f"{self.base_url}/components/get",
+            params=params,
+            timeout=self.timeout,
+        )
+        response.raise_for_status()
+        data = self._check(response.json(), "get_creative_component")
+        for row in data.get("list") or []:
+            if int(row.get("component_id") or 0) == component_id:
+                return row
+        raise RuntimeError(
+            "Creative component not found: "
+            f"account={account_id} component={component_id}"
+        )
+
+    def delete_creative_component(
+        self,
+        account_id: int,
+        component_id: int,
+    ) -> dict[str, Any]:
+        """Force-delete one rejected creative component and verify its state."""
+
+        try:
+            params = self._common_params(account_id)
+        except Exception as exc:
+            raise TencentWriteNotSentError(str(exc)) from exc
+        try:
+            response = self.session.post(
+                f"{self.base_url}/components/delete",
+                params=params,
+                json={
+                    "account_id": account_id,
+                    "component_id": component_id,
+                    "delete_strategy": "DELETE_STRATEGY_FORCE",
+                },
+                timeout=self.timeout,
+            )
+        except requests.RequestException as exc:
+            raise TencentWriteOutcomeUnknownError(str(exc)) from exc
+        if response.status_code == 408 or response.status_code >= 500:
+            raise TencentWriteOutcomeUnknownError(
+                f"Tencent HTTP {response.status_code}: {response.text[:500]}"
+            )
+        try:
+            response.raise_for_status()
+            data = self._check(response.json(), "delete_creative_component")
+        except requests.HTTPError as exc:
+            raise TencentWriteRejectedError(str(exc)) from exc
+        except (ValueError, TypeError) as exc:
+            raise TencentWriteOutcomeUnknownError(
+                f"Tencent returned invalid JSON: {response.text[:500]}"
+            ) from exc
+        except RuntimeError as exc:
+            raise TencentWriteRejectedError(str(exc)) from exc
+
+        last_actual: dict[str, Any] = {}
+        for attempt in range(1, self.verify_attempts + 1):
+            try:
+                component = self.get_creative_component(
+                    account_id,
+                    component_id,
+                    include_deleted=True,
+                )
+                last_actual = {"is_deleted": component.get("is_deleted")}
+                if component.get("is_deleted") is True:
+                    return {**data, **component}
+            except Exception as exc:
+                last_actual = {"verification_error": str(exc)}
+            if attempt < self.verify_attempts:
+                time.sleep(self.verify_delay_seconds)
+        raise PostWriteVerificationError(
+            account_id=account_id,
+            adgroup_id=component_id,
+            expected={"is_deleted": True},
+            actual=last_actual,
+        )
+
     def update_ad_begin_dates(
     def update_ad_begin_dates(
         self,
         self,
         account_id: int,
         account_id: int,
@@ -575,6 +782,72 @@ class TencentClient:
             page += 1
             page += 1
         return metrics
         return metrics
 
 
+    def get_dynamic_creative_costs(
+        self,
+        account_id: int,
+        dynamic_creative_ids: list[int],
+        start_date: date,
+        end_date: date,
+    ) -> dict[int, int]:
+        """Return Tencent creative-level cost totals in fen for a date range."""
+
+        creative_ids = list(
+            dict.fromkeys(
+                int(value) for value in dynamic_creative_ids if int(value) > 0
+            )
+        )
+        costs = {creative_id: 0 for creative_id in creative_ids}
+        for offset in range(0, len(creative_ids), 100):
+            batch = creative_ids[offset : offset + 100]
+            page = 1
+            while batch:
+                params = {
+                    **self._common_params(account_id),
+                    "account_id": account_id,
+                    "level": "REPORT_LEVEL_DYNAMIC_CREATIVE",
+                    "date_range": json.dumps(
+                        {
+                            "start_date": start_date.isoformat(),
+                            "end_date": end_date.isoformat(),
+                        }
+                    ),
+                    "group_by": json.dumps(["dynamic_creative_id", "date"]),
+                    "fields": json.dumps(
+                        ["account_id", "dynamic_creative_id", "date", "cost"]
+                    ),
+                    "filtering": json.dumps(
+                        [
+                            {
+                                "field": "dynamic_creative_id",
+                                "operator": "IN",
+                                "values": [str(value) for value in batch],
+                            }
+                        ]
+                    ),
+                    "page": page,
+                    "page_size": 100,
+                    "time_line": "REQUEST_TIME",
+                }
+                response = self.session.get(
+                    f"{self.base_url}/daily_reports/get",
+                    params=params,
+                    timeout=self.timeout,
+                )
+                response.raise_for_status()
+                data = self._check(
+                    response.json(),
+                    "get_dynamic_creative_costs",
+                )
+                for row in data.get("list") or []:
+                    creative_id = int(row.get("dynamic_creative_id") or 0)
+                    if creative_id in costs:
+                        costs[creative_id] += int(row.get("cost") or 0)
+                page_info = data.get("page_info") or {}
+                if page >= int(page_info.get("total_page") or 1):
+                    break
+                page += 1
+        return costs
+
     def get_today_account_metrics(
     def get_today_account_metrics(
         self,
         self,
         account_id: int,
         account_id: int,

+ 17 - 1
runtime.env.example

@@ -22,6 +22,10 @@ TENCENT_AD_USER_TOKEN_API=https://api.piaoquantv.com/ad/put/tencent/getUserToken
 TENCENT_AD_ACCESS_TOKEN=
 TENCENT_AD_ACCESS_TOKEN=
 TENCENT_AD_USER_TOKEN=
 TENCENT_AD_USER_TOKEN=
 TENCENT_AD_TIMEOUT_SECONDS=30
 TENCENT_AD_TIMEOUT_SECONDS=30
+TENCENT_AD_TOKEN_PREFETCH_WORKERS=8
+TENCENT_AD_ACCOUNT_SCAN_WORKERS=8
+TENCENT_AD_GET_RETRY_ATTEMPTS=3
+TENCENT_AD_GET_RETRY_BACKOFF_SECONDS=1
 # 仅作兼容默认账户;生产创建账户以飞书/数据库为准。
 # 仅作兼容默认账户;生产创建账户以飞书/数据库为准。
 TENCENT_AD_ACCOUNT_ID=
 TENCENT_AD_ACCOUNT_ID=
 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID=
 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID=
@@ -30,6 +34,7 @@ TENCENT_AUDIENCE_GRANT_BUSINESS_ID=
 # 飞书应用与生产群
 # 飞书应用与生产群
 FEISHU_APP_ID=
 FEISHU_APP_ID=
 FEISHU_APP_SECRET=
 FEISHU_APP_SECRET=
+# 投放调控通知群;同时接收创意审核异常处理全量汇总。
 FEISHU_OPERATOR_CHAT_ID=
 FEISHU_OPERATOR_CHAT_ID=
 FEISHU_OPERATOR_OPEN_ID=
 FEISHU_OPERATOR_OPEN_ID=
 FEISHU_AD_PROJECT_CHAT_ID=
 FEISHU_AD_PROJECT_CHAT_ID=
@@ -102,7 +107,7 @@ EXTERNAL_RECALL_CANDIDATE_LIMIT=300
 EXTERNAL_RECALL_EDIT_LIMIT_PER_LANDING=3
 EXTERNAL_RECALL_EDIT_LIMIT_PER_LANDING=3
 EXTERNAL_RECALL_UV_WINDOW_DAYS=90
 EXTERNAL_RECALL_UV_WINDOW_DAYS=90
 
 
-# ad-daily-service:创建 10:30、审核每 2 小时。所有开关默认关闭。
+# ad-daily-service:创建 10:30、审核每 2 小时、拒审清理默认 11:00。所有开关默认关闭。
 DAILY_CREATION_ENABLED=0
 DAILY_CREATION_ENABLED=0
 DAILY_CREATION_HOUR=10
 DAILY_CREATION_HOUR=10
 DAILY_CREATION_MINUTE=30
 DAILY_CREATION_MINUTE=30
@@ -114,6 +119,17 @@ DAILY_REVIEW_LOCK_NAME=ad_creative_review_scan
 CREATIVE_REVIEW_LOOKBACK_HOURS=72
 CREATIVE_REVIEW_LOOKBACK_HOURS=72
 CREATIVE_REVIEW_SCAN_LIMIT=1000
 CREATIVE_REVIEW_SCAN_LIMIT=1000
 CREATIVE_REVIEW_BATCH_SIZE=100
 CREATIVE_REVIEW_BATCH_SIZE=100
+
+DAILY_REJECTED_CREATIVE_CLEANUP_ENABLED=0
+DAILY_REJECTED_CREATIVE_APPLY_ENABLED=0
+DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN=50
+DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN=100
+DAILY_REJECTED_CREATIVE_CLEANUP_HOUR=11
+DAILY_REJECTED_CREATIVE_CLEANUP_MINUTE=0
+DAILY_REJECTED_CREATIVE_LOCK_NAME=ad_rejected_creative_cleanup
+DAILY_REJECTED_CREATIVE_MISFIRE_GRACE_SECONDS=3600
+DAILY_REJECTED_CREATIVE_RUN_ON_STARTUP=0
+
 DAILY_RUN_ON_STARTUP=0
 DAILY_RUN_ON_STARTUP=0
 DAILY_REVIEW_RUN_ON_STARTUP=0
 DAILY_REVIEW_RUN_ON_STARTUP=0
 DAILY_SYNC_DELIVERY_TEMPLATE=0
 DAILY_SYNC_DELIVERY_TEMPLATE=0