Просмотр исходного кода

创意审核删除,限制创建时间

wangyunpeng 1 день назад
Родитель
Сommit
3ca6655102

+ 13 - 6
examples/auto_put_ad_mini/docs/unified_services_deployment.md

@@ -114,6 +114,7 @@ DAILY_CLEANUP_CANDIDATE_BATCH_SIZE=100
 DAILY_CLEANUP_CANDIDATE_STORE_WORKERS=4
 TENCENT_AD_AD_PROCESS_WORKERS=8
 DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN=50
+DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS=3
 DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN=100
 DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES=30
 DAILY_REJECTED_CREATIVE_NOTIFICATION_LOCK_NAME=ad_rejected_creative_notification
@@ -181,13 +182,17 @@ T-3 至 T-1 三个完整日内 `SUM(成本)>0` 的 `账号id`,枚举这些账
 `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` 层级实时汇总任务执行日前三个自然日
-(昨天及前两天,不含今天)的创意消耗。历史三天消耗严格低于
+(昨天及前两天)的历史消耗,并保留这段独立查询;随后另查任务执行日当日消耗。
+创意搭建时间读取 `loghubods.ad_put_tencent_creative_day.create_time`。部分投放中的创意从创建时刻起
+未超过 `DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS`(默认 3)个完整 24 小时时一律不自动删除,
+只发送提醒交由人工处理;搭建时间缺失或异常时同样只提醒。
+超过该保护天数后,以“历史三天消耗 + 当日消耗”的合计值判断:合计严格低于
 `DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN`(默认 50 元)时删除整条创意,达到 50 元
-不会自动删除。部分投放中创意如果元素粒度存在名称为“微信小程序”且状态为拒绝的记录,历史三天消耗
+不会自动删除。部分投放中创意如果元素粒度存在名称为“微信小程序”且状态为拒绝的记录,合计消耗
 严格低于 `DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN`(默认 100 元)时删除,达到 100 元
-则进入代理人工判断;实时消耗接口
-失败时同样只报警,禁止把失败结果当作 0 消耗。除此之外,普通创意的元素或版位审核拒绝均不触发
-任何自动删除;系统不再提供删除拒审元素组件的流程。
+则进入代理人工判断。历史或当日任一实时消耗接口失败时都只报警,禁止把失败结果当作 0 消耗。
+代理和内部审核报表分别展示历史三天、当日及两者合计消耗。除此之外,普通创意的元素或版位审核拒绝
+均不触发任何自动删除;系统不再提供删除拒审元素组件的流程。
 
 `DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=1` 会在同一个任务中追加长期未起量判断,
 不会注册第二个调度任务。未起量范围独立读取
@@ -255,7 +260,9 @@ T-3 至 T-1 三个完整自然日消耗,并另查 T 日当日消耗;两者
 整个批次失败时会回滚并按单条幂等路径重试,单条失败不会中断同批其他候选。广告候选的纯内存判断按
 `TENCENT_AD_AD_PROCESS_WORKERS`(默认 8,最大 32)并发。删除前先原子认领数据库记录,再在
 `RTC_DB_LOCK_NAME` 写锁内并发回读、复审和删除;只有认领成功且整创意删除动作保持一致才执行,
-历史遗留的组件删除候选不会被加载执行。认领后仅允许持有 `DELETING` 状态的进程回写结果;
+历史遗留的组件删除候选不会被加载执行。审核异常创意复用本轮扫描已查询并落库的历史及当日消耗,
+写锁内只回读创意状态和正式审核结果,不重复查询消耗;未起量创意和零消耗广告仍在写锁内重新查询
+各自的历史及当日指标。认领后仅允许持有 `DELETING` 状态的进程回写结果;
 超过 `DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES`(默认 30 分钟)的认领可由下次任务恢复,
 并先回读腾讯状态,避免进程异常退出造成永久卡住或重复删除。删除并发数由
 `TENCENT_AD_DELETE_WORKERS` 控制(默认 4,最大 16);所有 worker 共享

+ 160 - 21
examples/auto_put_ad_mini/test_creative_review_scan.py

@@ -441,12 +441,18 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
             partial,
             denied_element,
             recent_cost_fen=4999,
+            current_day_cost_fen=0,
+            creative_created_at="2026-08-10 11:00:00",
+            as_of_datetime="2026-08-20 11:00:00",
         )
         self.assertEqual(low_cost["cleanup_action"], DELETE_CREATIVE)
         at_threshold = determine_cleanup_action(
             partial,
             denied_element,
             recent_cost_fen=5000,
+            current_day_cost_fen=0,
+            creative_created_at="2026-08-10 11:00:00",
+            as_of_datetime="2026-08-20 11:00:00",
         )
         self.assertEqual(at_threshold["cleanup_action"], ALERT_ONLY)
 
@@ -472,6 +478,9 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
                 ]
             },
             recent_cost_fen=9999,
+            current_day_cost_fen=0,
+            creative_created_at="2026-08-10 11:00:00",
+            as_of_datetime="2026-08-20 11:00:00",
         )
 
         self.assertEqual(action["cleanup_action"], DELETE_CREATIVE)
@@ -493,6 +502,9 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
                 }]
             },
             recent_cost_fen=10001,
+            current_day_cost_fen=0,
+            creative_created_at="2026-08-10 11:00:00",
+            as_of_datetime="2026-08-20 11:00:00",
         )
 
         self.assertEqual(action["cleanup_action"], ALERT_ONLY)
@@ -508,12 +520,104 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
                 )
             },
             {},
+            creative_created_at="2026-08-10 11:00:00",
+            as_of_datetime="2026-08-20 11:00:00",
             spend_error="timeout",
         )
 
         self.assertEqual(action["cleanup_action"], ALERT_ONLY)
         self.assertIn("消耗读取失败", action["action_reason"])
 
+    def test_partial_creative_not_older_than_three_days_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"
+                )
+            },
+            {},
+            recent_cost_fen=0,
+            current_day_cost_fen=0,
+            creative_created_at="2026-08-17 11:00:00",
+            as_of_datetime="2026-08-20 11:00:00",
+        )
+
+        self.assertEqual(action["cleanup_action"], ALERT_ONLY)
+        self.assertEqual(action["creative_age_days"], 3)
+        self.assertIn("未超过3天", action["action_reason"])
+
+    def test_partial_creative_uses_historical_plus_current_day_cost(self):
+        from tools.creative_rejection_cleanup import (
+            ALERT_ONLY,
+            DELETE_CREATIVE,
+            determine_cleanup_action,
+        )
+
+        common = {
+            "creative_set_approval_status": (
+                "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
+            )
+        }
+        protected_by_total = determine_cleanup_action(
+            common,
+            {},
+            recent_cost_fen=4000,
+            current_day_cost_fen=1000,
+            creative_created_at="2026-08-10 11:00:00",
+            as_of_datetime="2026-08-20 11:00:00",
+        )
+        still_below_total = determine_cleanup_action(
+            common,
+            {},
+            recent_cost_fen=4000,
+            current_day_cost_fen=999,
+            creative_created_at="2026-08-10 11:00:00",
+            as_of_datetime="2026-08-20 11:00:00",
+        )
+
+        self.assertEqual(protected_by_total["cleanup_action"], ALERT_ONLY)
+        self.assertEqual(protected_by_total["total_cost_fen"], 5000)
+        self.assertEqual(still_below_total["cleanup_action"], DELETE_CREATIVE)
+        self.assertEqual(still_below_total["total_cost_fen"], 4999)
+
+    def test_partial_creative_missing_creation_time_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"
+                )
+            },
+            {},
+            recent_cost_fen=0,
+            current_day_cost_fen=0,
+            creative_created_at=None,
+            as_of_datetime="2026-08-20 11:00:00",
+        )
+
+        self.assertEqual(action["cleanup_action"], ALERT_ONLY)
+        self.assertIn("搭建时间缺失", action["action_reason"])
+
+    def test_partial_creative_protection_days_config_is_validated(self):
+        from tools.creative_rejection_cleanup import partial_creative_protection_days
+
+        with patch.dict(
+            os.environ,
+            {"DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS": "5"},
+            clear=False,
+        ):
+            self.assertEqual(partial_creative_protection_days(), 5)
+        with patch.dict(
+            os.environ,
+            {"DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS": "-1"},
+            clear=False,
+        ):
+            with self.assertRaises(ValueError):
+                partial_creative_protection_days()
+
     def test_cleanup_candidate_insert_has_one_value_per_column(self):
         from tools import creative_rejection_cleanup as cleanup
 
@@ -1476,6 +1580,8 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
                 "创意ID",
                 "创意名称",
                 "近3天累计历史消耗(元)",
+                "当日消耗(元)",
+                "近3天及当日累计消耗(元)",
                 "配置状态",
                 "创意审核状态",
                 "审核不通过原因",
@@ -2004,10 +2110,12 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
                 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
-                }
+                costs = (
+                    {3: 2999, 4: 3000}
+                    if start_date == date(2026, 8, 9)
+                    else {3: 0, 4: 0}
+                )
+                return {creative_id: costs[creative_id] for creative_id in creative_ids}
 
             def delete_dynamic_creative(self, account_id, creative_id):
                 self.deleted.append((account_id, creative_id))
@@ -2033,6 +2141,7 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
             {
                 "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
                 "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN": "30",
+                "DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS": "3",
                 "ROI_AGENCY_WEBHOOK_ENABLED": "1",
                 "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
                 "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
@@ -2064,6 +2173,20 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
             ),
         ), patch.object(
             cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[
+                {
+                    "account_id": 1,
+                    "adgroup_id": creative_id + 10,
+                    "creative_id": creative_id,
+                    "create_time": datetime(
+                        2026, 8, 1, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")
+                    ),
+                }
+                for creative_id in (3, 4)
+            ],
         ), patch.object(
             cleanup, "fetch_account_agency_fallbacks", return_value={}
         ), patch.object(
@@ -2100,9 +2223,13 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
         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.assertEqual(
+            [(start.isoformat(), end.isoformat()) for _, _, start, end in tencent.cost_requests],
+            [
+                ("2026-08-09", "2026-08-11"),
+                ("2026-08-12", "2026-08-12"),
+            ],
+        )
         self.assertTrue(
             any(
                 values.get("cleanup_status") == "CREATIVE_DELETED"
@@ -2110,7 +2237,7 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
             )
         )
 
-    def test_lock_time_spend_failure_downgrades_delete_to_manual_alert(self):
+    def test_review_delete_does_not_query_spend_again_inside_write_lock(self):
         from tools import creative_rejection_cleanup as cleanup
 
         @contextmanager
@@ -2143,9 +2270,9 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
 
             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")
+                if self.cost_call_count <= 2:
+                    return {3: 2999 if self.cost_call_count == 1 else 0}
+                raise AssertionError("review delete must not query spend inside write lock")
 
             def delete_dynamic_creative(self, account_id, creative_id):
                 self.deleted.append((account_id, creative_id))
@@ -2168,6 +2295,7 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
             os.environ,
             {
                 "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS": "3",
                 "ROI_AGENCY_WEBHOOK_ENABLED": "1",
                 "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
                 "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
@@ -2186,6 +2314,19 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
             cleanup, "fetch_daily_data", return_value=pd.DataFrame()
         ), patch.object(
             cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[
+                {
+                    "account_id": 1,
+                    "adgroup_id": 13,
+                    "creative_id": 3,
+                    "create_time": datetime(
+                        2026, 8, 1, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")
+                    ),
+                }
+            ],
         ), patch.object(
             cleanup, "fetch_account_agency_fallbacks", return_value={}
         ), patch.object(
@@ -2213,17 +2354,15 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
                 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(tencent.cost_call_count, 2)
+        self.assertEqual(tencent.deleted, [(1, 3)])
+        self.assertEqual(summary["deleted"], 1)
+        self.assertTrue(
+            any(
+                values.get("cleanup_status") == "CREATIVE_DELETED"
+                for _, values in updates
+            )
         )
-        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

+ 218 - 72
examples/auto_put_ad_mini/tools/creative_rejection_cleanup.py

@@ -36,7 +36,7 @@ from tools.creative_review import (
 
 logger = logging.getLogger(__name__)
 SHANGHAI = ZoneInfo("Asia/Shanghai")
-REPORT_VERSION = "creative_rejection_cleanup_v13"
+REPORT_VERSION = "creative_rejection_cleanup_v14"
 OPERATOR_SUMMARY_ROUTE = "投放调控汇总"
 PERFORMANCE_SUMMARY_ROUTE = "长期未起量清理汇总"
 PERFORMANCE_REPORT_VERSION = (
@@ -60,6 +60,7 @@ PERFORMANCE_RULE_PREFIX = "PERFORMANCE_"
 DELETE_AD = "DELETE_AD"
 DEFAULT_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN = 50.0
 DEFAULT_WECHAT_MINI_PROGRAM_COST_THRESHOLD_YUAN = 100.0
+DEFAULT_PARTIAL_CREATIVE_PROTECTION_DAYS = 3
 DEFAULT_DELETE_CLAIM_STALE_MINUTES = 30
 DEFAULT_PERFORMANCE_NEW_MIN_AGE_DAYS = 5
 DEFAULT_PERFORMANCE_NEW_MAX_AGE_DAYS = 7
@@ -79,6 +80,8 @@ AGENCY_REPORT_COLUMNS = (
     "创意ID",
     "创意名称",
     "近3天累计历史消耗(元)",
+    "当日消耗(元)",
+    "近3天及当日累计消耗(元)",
     "配置状态",
     "创意审核状态",
     "审核不通过原因",
@@ -100,6 +103,8 @@ OPERATOR_REPORT_COLUMNS = (
     "规则窗口累计消耗(元)",
     "规则指标日期范围",
     "近3天累计历史消耗(元)",
+    "当日消耗(元)",
+    "近3天及当日累计消耗(元)",
     "消耗日期范围",
     "配置状态",
     "创意审核状态",
@@ -205,6 +210,24 @@ def partial_creative_cost_threshold_fen() -> int:
     return int(round(yuan * 100))
 
 
+def partial_creative_protection_days() -> int:
+    raw = os.getenv(
+        "DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS",
+        str(DEFAULT_PARTIAL_CREATIVE_PROTECTION_DAYS),
+    )
+    try:
+        days = int(raw)
+    except (TypeError, ValueError) as exc:
+        raise ValueError(
+            "DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS must be an integer"
+        ) from exc
+    if days < 0:
+        raise ValueError(
+            "DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS must not be negative"
+        )
+    return days
+
+
 def wechat_mini_program_cost_threshold_fen() -> int:
     raw = os.getenv(
         "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN",
@@ -638,6 +661,10 @@ def determine_cleanup_action(
     raw_result: dict | None,
     *,
     recent_cost_fen: int | None = None,
+    current_day_cost_fen: int | None = None,
+    creative_created_at: Any = None,
+    as_of_datetime: Any = None,
+    protection_days: int = DEFAULT_PARTIAL_CREATIVE_PROTECTION_DAYS,
     cost_threshold_fen: int = 5000,
     wechat_cost_threshold_fen: int = 10000,
     spend_error: str | None = None,
@@ -655,81 +682,109 @@ def determine_cleanup_action(
             "recent_cost_fen": recent_cost_fen,
         }
     if approval_status == CREATIVE_PARTIAL_NORMAL_STATUS:
+        common = {
+            "cleanup_rule_type": REVIEW_PARTIAL_RULE,
+            "component_ids": [],
+            "element_ids": [],
+            "recent_cost_fen": recent_cost_fen,
+            "current_day_cost_fen": current_day_cost_fen,
+        }
+        created_at = _as_shanghai_datetime(creative_created_at)
+        evaluated_at = _as_shanghai_datetime(as_of_datetime)
+        if evaluated_at is None:
+            evaluated_at = datetime.now(SHANGHAI)
+        if created_at is None:
+            return {
+                **common,
+                "cleanup_action": ALERT_ONLY,
+                "action_reason": "部分投放中且创意搭建时间缺失,需人工判断",
+            }
+        elapsed = evaluated_at - created_at
+        age_days = elapsed.days
+        age_context = {
+            **common,
+            "creative_created_at": created_at,
+            "creative_age_days": age_days,
+        }
+        if elapsed < timedelta(0):
+            return {
+                **age_context,
+                "cleanup_action": ALERT_ONLY,
+                "action_reason": "部分投放中且创意搭建时间晚于检查时间,需人工判断",
+            }
+        if elapsed <= timedelta(days=protection_days):
+            return {
+                **age_context,
+                "cleanup_action": ALERT_ONLY,
+                "action_reason": (
+                    f"部分投放中且创意搭建时间未超过{protection_days}天,"
+                    "本轮不删除,需人工处理审核异常"
+                ),
+            }
+
+        if (
+            spend_error
+            or recent_cost_fen is None
+            or current_day_cost_fen is None
+        ):
+            reason = "部分投放中,历史或当日消耗读取失败,需人工判断"
+            if spend_error:
+                reason = f"{reason}:{spend_error}"
+            return {
+                **age_context,
+                "cleanup_action": ALERT_ONLY,
+                "action_reason": reason,
+            }
+
+        total_cost_fen = recent_cost_fen + current_day_cost_fen
+        spend_context = {
+            **age_context,
+            "total_cost_fen": total_cost_fen,
+        }
         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}"
+            if total_cost_fen >= wechat_cost_threshold_fen:
                 return {
+                    **spend_context,
                     "cleanup_action": ALERT_ONLY,
-                    "cleanup_rule_type": REVIEW_PARTIAL_RULE,
-                    "component_ids": [],
-                    "element_ids": [],
-                    "action_reason": reason,
-                    "recent_cost_fen": None,
-                }
-            if recent_cost_fen >= wechat_cost_threshold_fen:
-                return {
-                    "cleanup_action": ALERT_ONLY,
-                    "cleanup_rule_type": REVIEW_PARTIAL_RULE,
-                    "component_ids": [],
-                    "element_ids": [],
                     "action_reason": (
                         "部分投放中且微信小程序元素审核拒绝,近3天历史消耗"
-                        f"{recent_cost_fen / 100:.2f}元不低于"
+                        f"{recent_cost_fen / 100:.2f}元、当日消耗"
+                        f"{current_day_cost_fen / 100:.2f}元,合计"
+                        f"{total_cost_fen / 100:.2f}元不低于"
                         f"{wechat_cost_threshold_fen / 100:.2f}元,需人工判断是否删除"
                     ),
-                    "recent_cost_fen": recent_cost_fen,
                 }
             return {
+                **spend_context,
                 "cleanup_action": DELETE_CREATIVE,
-                "cleanup_rule_type": REVIEW_PARTIAL_RULE,
-                "component_ids": [],
-                "element_ids": [],
                 "action_reason": (
                     "部分投放中且微信小程序元素审核拒绝,近3天历史消耗"
-                    f"{recent_cost_fen / 100:.2f}元低于"
+                    f"{recent_cost_fen / 100:.2f}元、当日消耗"
+                    f"{current_day_cost_fen / 100:.2f}元,合计"
+                    f"{total_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,
-                "cleanup_rule_type": REVIEW_PARTIAL_RULE,
-                "component_ids": [],
-                "element_ids": [],
-                "action_reason": reason,
-                "recent_cost_fen": None,
             }
-        if recent_cost_fen < cost_threshold_fen:
+        if total_cost_fen < cost_threshold_fen:
             return {
+                **spend_context,
                 "cleanup_action": DELETE_CREATIVE,
-                "cleanup_rule_type": REVIEW_PARTIAL_RULE,
-                "component_ids": [],
-                "element_ids": [],
                 "action_reason": (
-                    f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元"
+                    f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元、"
+                    f"当日消耗{current_day_cost_fen / 100:.2f}元,合计"
+                    f"{total_cost_fen / 100:.2f}元"
                     f"低于{cost_threshold_fen / 100:.2f}元"
                 ),
-                "recent_cost_fen": recent_cost_fen,
             }
         return {
+            **spend_context,
             "cleanup_action": ALERT_ONLY,
-            "cleanup_rule_type": REVIEW_PARTIAL_RULE,
-            "component_ids": [],
-            "element_ids": [],
             "action_reason": (
-                f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元,"
+                f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元、"
+                f"当日消耗{current_day_cost_fen / 100:.2f}元,合计"
+                f"{total_cost_fen / 100:.2f}元,"
                 "需人工判断是否删除"
             ),
-            "recent_cost_fen": recent_cost_fen,
         }
     return None
 
@@ -808,6 +863,7 @@ def _token_skipped_scan_result(account_id: int, error: Any):
             "missing_source_ad_ids": 0,
             "missing_create_time": 0,
         },
+        {},
     )
 
 
@@ -1216,6 +1272,7 @@ def upsert_cleanup_candidate(
             )
             extended_values = (
                 cleanup_rule_type,
+                record.get("current_day_cost_fen"),
                 record.get("account_name"),
                 record.get("agent_name"),
                 record.get("creative_created_at"),
@@ -1234,6 +1291,7 @@ def upsert_cleanup_candidate(
                 UPDATE creative_rejection_cleanup_item AS item
                 JOIN (
                     SELECT %s AS cleanup_rule_type,
+                           %s AS current_day_cost_fen,
                            %s AS account_name,
                            %s AS agent_name,
                            %s AS creative_created_at,
@@ -1252,6 +1310,7 @@ def upsert_cleanup_candidate(
                  AND incoming.check_date=item.check_date
                 SET item.notified_at=CASE
                         WHEN NOT (item.cleanup_rule_type <=> incoming.cleanup_rule_type)
+                          OR NOT (item.current_day_cost_fen <=> incoming.current_day_cost_fen)
                           OR NOT (item.account_name <=> COALESCE(NULLIF(incoming.account_name,''), item.account_name))
                           OR NOT (item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name))
                           OR NOT (item.creative_created_at <=> incoming.creative_created_at)
@@ -1268,6 +1327,7 @@ def upsert_cleanup_candidate(
                                 NOW()
                             )
                         WHEN NOT (item.cleanup_rule_type <=> incoming.cleanup_rule_type)
+                          OR NOT (item.current_day_cost_fen <=> incoming.current_day_cost_fen)
                           OR NOT (item.account_name <=> COALESCE(NULLIF(incoming.account_name,''), item.account_name))
                           OR NOT (item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name))
                           OR NOT (item.creative_created_at <=> incoming.creative_created_at)
@@ -1278,6 +1338,7 @@ def upsert_cleanup_candidate(
                     END,
                     item.operator_notified_at=CASE
                         WHEN NOT (item.cleanup_rule_type <=> incoming.cleanup_rule_type)
+                          OR NOT (item.current_day_cost_fen <=> incoming.current_day_cost_fen)
                           OR NOT (item.account_name <=> COALESCE(NULLIF(incoming.account_name,''), item.account_name))
                           OR NOT (item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name))
                           OR NOT (item.creative_created_at <=> incoming.creative_created_at)
@@ -1288,6 +1349,7 @@ def upsert_cleanup_candidate(
                     END,
                     item.cleanup_status=CASE
                         WHEN NOT (item.cleanup_rule_type <=> incoming.cleanup_rule_type)
+                          OR NOT (item.current_day_cost_fen <=> incoming.current_day_cost_fen)
                           OR NOT (item.account_name <=> COALESCE(NULLIF(incoming.account_name,''), item.account_name))
                           OR NOT (item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name))
                           OR NOT (item.creative_created_at <=> incoming.creative_created_at)
@@ -1298,6 +1360,7 @@ def upsert_cleanup_candidate(
                         ELSE item.cleanup_status
                     END,
                     item.cleanup_rule_type=incoming.cleanup_rule_type,
+                    item.current_day_cost_fen=incoming.current_day_cost_fen,
                     item.account_name=COALESCE(
                         NULLIF(incoming.account_name,''), item.account_name
                     ),
@@ -1536,6 +1599,7 @@ def update_cleanup_item(item_id: int, **values: Any) -> bool:
         "target_component_ids_json",
         "target_element_ids_json",
         "recent_cost_fen",
+        "current_day_cost_fen",
         "cost_start_date",
         "cost_end_date",
         "action_reason",
@@ -2041,6 +2105,17 @@ def _write_report(
             if recent_cost_fen is None
             else f"{int(recent_cost_fen) / 100:.2f}"
         )
+        current_day_cost_fen = row.get("current_day_cost_fen")
+        current_day_cost_yuan = (
+            ""
+            if current_day_cost_fen is None
+            else f"{int(current_day_cost_fen) / 100:.2f}"
+        )
+        total_review_cost_yuan = (
+            ""
+            if recent_cost_fen is None or current_day_cost_fen is None
+            else f"{(int(recent_cost_fen) + int(current_day_cost_fen)) / 100:.2f}"
+        )
         rule_type = str(row.get("cleanup_rule_type") or REVIEW_DENIED_RULE)
         is_performance = _is_performance_rule(rule_type)
         cost_start = _display_date(row.get("cost_start_date"))
@@ -2098,6 +2173,12 @@ def _write_report(
             "近3天累计历史消耗(元)": (
                 "" if is_performance else recent_cost_yuan
             ),
+            "当日消耗(元)": (
+                "" if is_performance else current_day_cost_yuan
+            ),
+            "近3天及当日累计消耗(元)": (
+                "" if is_performance else total_review_cost_yuan
+            ),
             "消耗日期范围": (
                 f"{cost_start} ~ {cost_end}"
                 if not is_performance and cost_start and cost_end else ""
@@ -2152,6 +2233,8 @@ def _write_report(
         "广告近3日日均消耗(元)": 24,
         "规则指标日期范围": 24,
         "近3天累计历史消耗(元)": 22,
+        "当日消耗(元)": 16,
+        "近3天及当日累计消耗(元)": 24,
         "消耗日期范围": 24,
         "执行操作": 16,
         "操作判断原因": 60,
@@ -2194,6 +2277,7 @@ def write_cleanup_reports(
                     str(row.get("cleanup_rule_type") or ""),
                     str(row.get("cleanup_status") or ""),
                     str(row.get("recent_cost_fen")),
+                    str(row.get("current_day_cost_fen")),
                     str(row.get("metric_impressions")),
                     str(row.get("metric_daily_avg_impressions")),
                     _display_date(row.get("cost_end_date")),
@@ -2259,6 +2343,7 @@ def write_cleanup_operator_summary(
                 str(row.get("cleanup_rule_type") or ""),
                 str(row.get("cleanup_status") or ""),
                 str(row.get("recent_cost_fen")),
+                str(row.get("current_day_cost_fen")),
                 str(row.get("metric_impressions")),
                 str(row.get("metric_daily_avg_impressions")),
                 _display_date(row.get("cost_end_date")),
@@ -2471,6 +2556,7 @@ def _scan_one_account(
     str | None,
     str | None,
     dict[str, int],
+    dict[int, int],
 ]:
     account_id = int(account["account_id"])
     empty_source_diagnostics = {
@@ -2549,6 +2635,7 @@ def _scan_one_account(
                     "creative review scan failed account=%d", account_id
                 )
         cost_by_id: dict[int, int] = {}
+        current_day_cost_by_id: dict[int, int] = {}
         spend_error = None
         partial_ids = [
             int(row["dynamic_creative_id"])
@@ -2579,6 +2666,32 @@ def _scan_one_account(
                     spend_start_date,
                     spend_end_date,
                 )
+            if spend_error is None:
+                spend_current_day = (
+                    current_day_date
+                    if current_day_date is not None
+                    else spend_end_date + timedelta(days=1)
+                )
+                try:
+                    current_day_cost_by_id = tencent.get_dynamic_creative_costs(
+                        account_id,
+                        partial_ids,
+                        spend_current_day,
+                        spend_current_day,
+                    )
+                except Exception as exc:
+                    spend_error = str(exc)
+                    if _is_access_token_unavailable_error(exc):
+                        logger.warning(
+                            "account scan skipped: access token unavailable account=%d",
+                            account_id,
+                        )
+                        return _token_skipped_scan_result(account_id, exc)
+                    logger.exception(
+                        "creative current-day cost scan failed account=%d date=%s",
+                        account_id,
+                        spend_current_day,
+                    )
         performance_metrics: dict[int, dict[str, Any]] = {}
         performance_error = None
         source_creatives = performance_source_creatives or {}
@@ -2878,6 +2991,7 @@ def _scan_one_account(
             ad_metric_error,
             review_error,
             source_diagnostics,
+            current_day_cost_by_id,
         )
     except Exception as exc:
         return (
@@ -2894,6 +3008,7 @@ def _scan_one_account(
             None,
             None,
             empty_source_diagnostics,
+            {},
         )
 
 
@@ -3095,6 +3210,7 @@ def run_rejected_creative_cleanup(
         days=ad_window_days - 1
     )
     cost_threshold_fen = partial_creative_cost_threshold_fen()
+    protection_days = partial_creative_protection_days()
     wechat_cost_threshold_fen = wechat_mini_program_cost_threshold_fen()
 
     # 审核范围/代理上下文与未起量 ODPS 范围相互隔离。审核数据异常时,
@@ -3151,7 +3267,10 @@ def run_rejected_creative_cleanup(
     performance_account_metadata: dict[int, dict[str, str]] = {}
     performance_account_metadata_error: str | None = None
     underperformance_enabled = performance_enabled or ad_cleanup_enabled
-    if underperformance_enabled:
+    # 部分投放审核异常也必须读取创意搭建时间。该库存查询与未起量规则共用,
+    # 但仅未起量规则可以据此扩展账户扫描范围。
+    inventory_required = underperformance_enabled or bool(review_account_ids)
+    if inventory_required:
         try:
             performance_inventory = fetch_active_creative_inventory(odps_client)
         except Exception as exc:
@@ -3161,12 +3280,18 @@ def run_rejected_creative_cleanup(
             for source in performance_inventory:
                 account_id = int(source["account_id"])
                 creative_id = int(source["creative_id"])
+                if (
+                    not underperformance_enabled
+                    and account_id not in review_account_ids
+                ):
+                    continue
                 performance_sources_by_account[account_id][creative_id] = source
-                accounts_by_id.setdefault(
-                    account_id,
-                    {"account_id": account_id, "account_name": ""},
-                )
-        if performance_sources_by_account:
+                if underperformance_enabled:
+                    accounts_by_id.setdefault(
+                        account_id,
+                        {"account_id": account_id, "account_name": ""},
+                    )
+        if underperformance_enabled and performance_sources_by_account:
             try:
                 performance_account_metadata = fetch_tencent_account_metadata(
                     odps_client
@@ -3359,9 +3484,11 @@ def run_rejected_creative_cleanup(
                         ad_metric_error,
                         review_error,
                         source_diagnostics,
+                        current_day_cost_by_id,
                     ) = future.result()
                 except Exception as exc:
                     creatives, ads, raw_by_id, cost_by_id = [], {}, {}, {}
+                    current_day_cost_by_id = {}
                     spend_error, account_scanned = None, 0
                     error = f"account={account_id} scan failed: {exc}"
                     performance_metrics, performance_error = {}, None
@@ -3424,6 +3551,7 @@ def run_rejected_creative_cleanup(
                             ads,
                             raw_by_id,
                             cost_by_id,
+                            current_day_cost_by_id,
                             spend_error,
                             performance_metrics,
                             ad_metrics,
@@ -3457,6 +3585,7 @@ def run_rejected_creative_cleanup(
                 ads,
                 raw_by_id,
                 cost_by_id,
+                current_day_cost_by_id,
                 spend_error,
                 performance_metrics,
                 _ad_metrics,
@@ -3478,6 +3607,10 @@ def run_rejected_creative_cleanup(
                     creative,
                     raw_result,
                     recent_cost_fen=cost_by_id.get(creative_id),
+                    current_day_cost_fen=current_day_cost_by_id.get(creative_id),
+                    creative_created_at=performance_creation_times.get(key),
+                    as_of_datetime=effective_now,
+                    protection_days=protection_days,
                     cost_threshold_fen=cost_threshold_fen,
                     wechat_cost_threshold_fen=wechat_cost_threshold_fen,
                     spend_error=(spend_error if is_partial else None),
@@ -3579,6 +3712,7 @@ def run_rejected_creative_cleanup(
                 ads,
                 raw_by_id,
                 cost_by_id,
+                current_day_cost_by_id,
                 spend_error,
                 performance_metrics,
                 ad_metrics,
@@ -3589,6 +3723,7 @@ def run_rejected_creative_cleanup(
                 ads,
                 raw_by_id,
                 cost_by_id,
+                current_day_cost_by_id,
                 spend_error,
                 performance_metrics,
                 ad_metrics,
@@ -3765,6 +3900,7 @@ def run_rejected_creative_cleanup(
                     ads,
                     _raw_by_id,
                     _cost_by_id,
+                    _current_day_cost_by_id,
                     _spend_error,
                     _performance_metrics,
                     ad_metrics,
@@ -4242,27 +4378,28 @@ def run_rejected_creative_cleanup(
                                 ),
                                 None,
                             )
-                            fresh_cost_fen = None
-                            fresh_spend_error = None
-                            if approval_status == CREATIVE_PARTIAL_NORMAL_STATUS:
-                                try:
-                                    fresh_cost_fen = (
-                                        delete_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)
+                            # 审核异常删除复用本轮账户扫描已经查询并落库的消耗,
+                            # 写锁内只回读创意状态和正式审核结果,不重复请求消耗报表。
+                            confirmed_review_action = confirmed_actions.get(
+                                (account_id, creative_id),
+                                {},
+                            )
                             fresh_action = determine_cleanup_action(
                                 before,
                                 fresh_raw,
-                                recent_cost_fen=fresh_cost_fen,
+                                recent_cost_fen=confirmed_review_action.get(
+                                    "recent_cost_fen"
+                                ),
+                                current_day_cost_fen=confirmed_review_action.get(
+                                    "current_day_cost_fen"
+                                ),
+                                creative_created_at=performance_creation_times.get(
+                                    (account_id, creative_id)
+                                ),
+                                as_of_datetime=effective_now,
+                                protection_days=protection_days,
                                 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,
@@ -4282,9 +4419,18 @@ def run_rejected_creative_cleanup(
                                 target_component_ids_json="[]",
                                 target_element_ids_json="[]",
                                 recent_cost_fen=fresh_action.get("recent_cost_fen"),
+                                current_day_cost_fen=fresh_action.get(
+                                    "current_day_cost_fen"
+                                ),
                                 cost_start_date=spend_start_date,
                                 cost_end_date=spend_end_date,
                                 action_reason=fresh_action["action_reason"],
+                                creative_created_at=fresh_action.get(
+                                    "creative_created_at"
+                                ),
+                                creative_age_days=fresh_action.get(
+                                    "creative_age_days"
+                                ),
                                 reject_reason=_reject_reason(
                                     fresh_raw,
                                     str(before.get("system_status") or ""),

+ 1 - 0
examples/tencent_realtime_control/schema.sql

@@ -241,6 +241,7 @@ CREATE TABLE IF NOT EXISTS creative_rejection_cleanup_item (
     target_component_ids_json LONGTEXT DEFAULT NULL,
     target_element_ids_json LONGTEXT DEFAULT NULL,
     recent_cost_fen BIGINT DEFAULT NULL,
+    current_day_cost_fen BIGINT DEFAULT NULL,
     cost_start_date DATE DEFAULT NULL,
     cost_end_date DATE DEFAULT NULL,
     action_reason TEXT DEFAULT NULL,

+ 1 - 0
examples/tencent_realtime_control/storage.py

@@ -219,6 +219,7 @@ def initialize_schema() -> None:
                     "target_component_ids_json": "LONGTEXT DEFAULT NULL",
                     "target_element_ids_json": "LONGTEXT DEFAULT NULL",
                     "recent_cost_fen": "BIGINT DEFAULT NULL",
+                    "current_day_cost_fen": "BIGINT DEFAULT NULL",
                     "cost_start_date": "DATE DEFAULT NULL",
                     "cost_end_date": "DATE DEFAULT NULL",
                     "action_reason": "TEXT DEFAULT NULL",

+ 1 - 0
runtime.env.example

@@ -146,6 +146,7 @@ DAILY_CLEANUP_CANDIDATE_BATCH_SIZE=100
 DAILY_CLEANUP_CANDIDATE_STORE_WORKERS=4
 TENCENT_AD_AD_PROCESS_WORKERS=8
 DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN=50
+DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS=3
 DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN=100
 DAILY_REJECTED_CREATIVE_CLEANUP_HOUR=11
 DAILY_REJECTED_CREATIVE_CLEANUP_MINUTE=0