Quellcode durchsuchen

长期未起量创意及广告清理

wangyunpeng vor 1 Tag
Ursprung
Commit
3ed24e4a21
85 geänderte Dateien mit 4538 neuen und 516 gelöschten Zeilen
  1. 30 1
      examples/auto_put_ad_mini/.env.example
  2. 5 5
      examples/auto_put_ad_mini/analyze_snapshot.py
  3. 77 10
      examples/auto_put_ad_mini/cleanup_rejected_creatives.py
  4. 2 2
      examples/auto_put_ad_mini/config.py
  5. 1 1
      examples/auto_put_ad_mini/configure_creation_accounts.py
  6. 3 4
      examples/auto_put_ad_mini/debug_select_creative_patterns.py
  7. 127 9
      examples/auto_put_ad_mini/docs/unified_services_deployment.md
  8. 5 5
      examples/auto_put_ad_mini/execute_creation_once.py
  9. 2 3
      examples/auto_put_ad_mini/import_material_strategy_learning.py
  10. 1 1
      examples/auto_put_ad_mini/k8s/secret.yaml
  11. 42 36
      examples/auto_put_ad_mini/logging_setup.py
  12. 1 1
      examples/auto_put_ad_mini/refresh_roi_fission_multiplier.py
  13. 13 13
      examples/auto_put_ad_mini/requirements.txt
  14. 1 1
      examples/auto_put_ad_mini/roi_control/__init__.py
  15. 3 3
      examples/auto_put_ad_mini/roi_control/agency_delivery.py
  16. 2 2
      examples/auto_put_ad_mini/roi_control/config.py
  17. 6 6
      examples/auto_put_ad_mini/roi_control/data_source.py
  18. 4 4
      examples/auto_put_ad_mini/roi_control/execution.py
  19. 3 3
      examples/auto_put_ad_mini/roi_control/feishu.py
  20. 6 8
      examples/auto_put_ad_mini/roi_control/fission_multiplier.py
  21. 6 7
      examples/auto_put_ad_mini/roi_control/fission_multiplier_refresh.py
  22. 3 4
      examples/auto_put_ad_mini/roi_control/metrics.py
  23. 3 4
      examples/auto_put_ad_mini/roi_control/policy.py
  24. 2 2
      examples/auto_put_ad_mini/roi_control/reporting.py
  25. 4 4
      examples/auto_put_ad_mini/roi_control/repository.py
  26. 4 6
      examples/auto_put_ad_mini/roi_control/rules.py
  27. 3 3
      examples/auto_put_ad_mini/roi_control/service.py
  28. 1 1
      examples/auto_put_ad_mini/roi_control/sheet_approval.py
  29. 4 4
      examples/auto_put_ad_mini/run_daily_roi.py
  30. 94 23
      examples/auto_put_ad_mini/run_daily_service.py
  31. 3 3
      examples/auto_put_ad_mini/scan_creative_reviews.py
  32. 1 1
      examples/auto_put_ad_mini/seed_material_creative_patterns.py
  33. 2151 0
      examples/auto_put_ad_mini/test_creative_performance_cleanup.py
  34. 358 8
      examples/auto_put_ad_mini/test_creative_review_scan.py
  35. 25 0
      examples/auto_put_ad_mini/test_daily_roi_polling.py
  36. 4 5
      examples/auto_put_ad_mini/tools/account_material_strategy.py
  37. 3 3
      examples/auto_put_ad_mini/tools/ad_api.py
  38. 1 1
      examples/auto_put_ad_mini/tools/ad_decision.py
  39. 14 16
      examples/auto_put_ad_mini/tools/ai_generated_material.py
  40. 3 4
      examples/auto_put_ad_mini/tools/ai_material_review.py
  41. 11 11
      examples/auto_put_ad_mini/tools/creative_material_usage.py
  42. 867 34
      examples/auto_put_ad_mini/tools/creative_rejection_cleanup.py
  43. 10 12
      examples/auto_put_ad_mini/tools/creative_review.py
  44. 1 1
      examples/auto_put_ad_mini/tools/creative_roi_calculator.py
  45. 2 2
      examples/auto_put_ad_mini/tools/delivery_config.py
  46. 4 5
      examples/auto_put_ad_mini/tools/external_recalled_material.py
  47. 1 1
      examples/auto_put_ad_mini/tools/im_approval_ad_creation.py
  48. 2 2
      examples/auto_put_ad_mini/tools/im_approval_creation.py
  49. 4 5
      examples/auto_put_ad_mini/tools/material_recall.py
  50. 17 24
      examples/auto_put_ad_mini/tools/material_strategy_learning.py
  51. 2 3
      examples/auto_put_ad_mini/tools/report_generator.py
  52. 1 1
      examples/auto_put_ad_mini/tools/roi_calculator.py
  53. 13 14
      examples/auto_put_ad_mini/tools/video_feature_query.py
  54. 2 3
      examples/auto_put_ad_mini/tools/video_recall.py
  55. 2 2
      examples/auto_put_ad_mini/utils/log_capture.py
  56. 1 1
      examples/auto_put_ad_mini/verify_decision.py
  57. 1 1
      examples/tencent_realtime_control/__init__.py
  58. 1 1
      examples/tencent_realtime_control/backtest_revenue_speed_forecast.py
  59. 1 1
      examples/tencent_realtime_control/build_revenue_speed_parameters.py
  60. 1 1
      examples/tencent_realtime_control/command_intent_parser.py
  61. 2 2
      examples/tencent_realtime_control/export_historical_hourly_roi.py
  62. 1 1
      examples/tencent_realtime_control/feishu_command_service.py
  63. 2 2
      examples/tencent_realtime_control/feishu_notifier.py
  64. 1 1
      examples/tencent_realtime_control/fetch_daily_hourly_cpm.py
  65. 1 1
      examples/tencent_realtime_control/init_db.py
  66. 1 1
      examples/tencent_realtime_control/odps_source.py
  67. 3 3
      examples/tencent_realtime_control/operator_commands.py
  68. 1 1
      examples/tencent_realtime_control/operator_control.py
  69. 1 1
      examples/tencent_realtime_control/realtime_config.py
  70. 1 1
      examples/tencent_realtime_control/revenue_forecast.py
  71. 1 1
      examples/tencent_realtime_control/revenue_forecast_config.py
  72. 1 1
      examples/tencent_realtime_control/revenue_forecast_job.py
  73. 1 1
      examples/tencent_realtime_control/revenue_forecast_repository.py
  74. 26 4
      examples/tencent_realtime_control/revenue_forecast_service.py
  75. 2 2
      examples/tencent_realtime_control/revenue_forecast_source.py
  76. 2 2
      examples/tencent_realtime_control/revenue_speed_forecast.py
  77. 31 12
      examples/tencent_realtime_control/run_control_service.py
  78. 29 10
      examples/tencent_realtime_control/run_once.py
  79. 29 8
      examples/tencent_realtime_control/run_revenue_forecast.py
  80. 32 6
      examples/tencent_realtime_control/run_scheduler.py
  81. 9 2
      examples/tencent_realtime_control/schema.sql
  82. 56 8
      examples/tencent_realtime_control/storage.py
  83. 316 102
      examples/tencent_realtime_control/tencent_client.py
  84. 1 1
      examples/tencent_realtime_control/today_spend_query.py
  85. 23 0
      runtime.env.example

+ 30 - 1
examples/auto_put_ad_mini/.env.example

@@ -123,7 +123,7 @@ ROI_FISSION_PARAMETER_VERSION=20260712_A0-A15_v2
 ROI_FEISHU_CHAT_ID=
 # 日级ROI定时任务失败告警群;为空时回退FEISHU_OPERATOR_CHAT_ID。
 ROI_FAILURE_FEISHU_CHAT_ID=
-# 拒审创意自动删除任务失败告警;为空时回退ROI_FAILURE_FEISHU_CHAT_ID /
+# 创意审核异常/长期未起量清理任务失败告警;为空时回退ROI_FAILURE_FEISHU_CHAT_ID /
 # FEISHU_OPERATOR_CHAT_ID。
 CREATIVE_CLEANUP_FAILURE_FEISHU_CHAT_ID=
 # 代理表由飞书应用上传后,通过各代理自定义机器人发送在线表卡片。
@@ -388,6 +388,35 @@ EXECUTION_ENABLED=false
 # RTC_DB_LOCK_NAME=tencent_realtime_control
 
 # -- 拒审创意自动删除限频 --
+# 长期未起量规则在全部审核异常规则之后执行,并跳过已删除或本轮审核已命中的创意;
+# 范围只取 loghubods.ad_put_tencent_creative_day 每个创意的最新一行且 is_delete=0;
+# 账户名称和代理商昵称取 loghubods.ad_put_tencent_account 最新 is_delete=0 记录。
+# 新素材从创建日累计到今天(含今日曝光/消耗);老素材曝光窗口为 T-7~T-1,
+# 另查当日消耗;当日有消耗时不删除。
+# 两类规则共用原清理任务和腾讯写锁,但在内部群单独发一条汇总;
+# 未起量明细绝不进入代理商群或代理商报表。
+# 扫描和真实删除使用独立开关;默认均关闭,先预演再开启真实删除。
+# DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=0
+# DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED=0
+# 新素材:5<搭建天数<=7,累计曝光<100且累计消耗=0。
+# DAILY_UNDERPERFORMING_CREATIVE_NEW_MIN_AGE_DAYS=5
+# DAILY_UNDERPERFORMING_CREATIVE_NEW_MAX_AGE_DAYS=7
+# DAILY_UNDERPERFORMING_CREATIVE_NEW_IMPRESSIONS_THRESHOLD=100
+# 老素材:搭建天数>7,近7日消耗=0且日均曝光严格<100或严格>100。
+# DAILY_UNDERPERFORMING_CREATIVE_OLD_DAILY_IMPRESSIONS_THRESHOLD=100
+# DAILY_UNDERPERFORMING_CREATIVE_WINDOW_DAYS=7
+# 创意判断完成后,再判断创建时间>5天的广告;近3个完整自然日及当日消耗都为0时删除。
+# 广告扫描、真实删除使用独立开关,默认均关闭;广告扫描要求先开启创意未起量扫描。
+# DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED=0
+# DAILY_UNDERPERFORMING_AD_APPLY_ENABLED=0
+# DAILY_UNDERPERFORMING_AD_WINDOW_DAYS=3
+# DAILY_UNDERPERFORMING_AD_MIN_AGE_DAYS=5
+# 命中候选达到该数量后切换为批量落库;批大小及批次并发均可调。
+# DAILY_CLEANUP_CANDIDATE_BATCH_MIN_SIZE=20
+# DAILY_CLEANUP_CANDIDATE_BATCH_SIZE=100
+# DAILY_CLEANUP_CANDIDATE_STORE_WORKERS=4
+# 广告候选纯内存判断并发数。
+# TENCENT_AD_AD_PROCESS_WORKERS=8
 # 删除并发 worker,默认 4,代码最大限制 16
 # TENCENT_AD_DELETE_WORKERS=4
 # 所有删除 worker 共享的请求启动最小间隔(秒)

+ 5 - 5
examples/auto_put_ad_mini/analyze_snapshot.py

@@ -334,7 +334,7 @@ def assess(report: Dict) -> Tuple[str, List[str]]:
         elif ra > 0.4:
             flags.append(f"override 率 {ra}(> 40%,signal_scores 可能不准)")
 
-    # reasoning_chain
+    # 推理链
     rc_rate = report["reasoning_chain"]["reasoning_chain_rate"]
     if rc_rate < 0.95:
         flags.append(f"reasoning_chain 缺失率 {round(1 - rc_rate, 3)}(LLM 未遵循 schema)")
@@ -344,7 +344,7 @@ def assess(report: Dict) -> Tuple[str, List[str]]:
     if max_ent > 1.2:
         flags.append(f"同桶决策熵最大 {max_ent}(> 1.2,稳定性不足)")
 
-    # token
+    # 令牌统计
     max_tok = report["context_size_est"]["max_tier_tokens"]
     if max_tok > 100_000:
         flags.append(f"最大 tier token {max_tok}(> 100K,需细分桶)")
@@ -397,7 +397,7 @@ def print_report(report: Dict, assessment: Tuple[str, List[str]]) -> None:
         print(f"  推荐幅度: mean={bd['pct_stats']['mean']}, "
               f"p25/50/75={bd['pct_stats']['p25']}/{bd['pct_stats']['p50']}/{bd['pct_stats']['p75']}")
 
-    # 3. rule_alignment
+    # 3. 规则对齐
     print("\n【3. rule_alignment(LLM 是否推翻规则)】")
     ra = report["rule_alignment"]
     print(f"  分布: {ra['counts']}")
@@ -407,7 +407,7 @@ def print_report(report: Dict, assessment: Tuple[str, List[str]]) -> None:
         for i, r in enumerate(ra["override_reason_samples"][:3], 1):
             print(f"    [{i}] {r[:80]}")
 
-    # 4. reasoning_chain
+    # 4. 推理链
     print("\n【4. reasoning_chain 质量】")
     rc = report["reasoning_chain"]
     print(f"  LLM 决策数: {rc['llm_decisions']}")
@@ -467,7 +467,7 @@ def print_report(report: Dict, assessment: Tuple[str, List[str]]) -> None:
 
 
 # ═══════════════════════════════════════════
-# Entry
+# 执行入口
 # ═══════════════════════════════════════════
 
 def main() -> None:

+ 77 - 10
examples/auto_put_ad_mini/cleanup_rejected_creatives.py

@@ -1,8 +1,9 @@
 #!/usr/bin/env python
-"""Run one rejected-creative cleanup and agency notification pass."""
+"""按创意优先、广告随后顺序执行清理,并完成对应通知。"""
 
 from __future__ import annotations
 
+import argparse
 import json
 import logging
 import os
@@ -33,15 +34,62 @@ from tools.creative_rejection_cleanup import run_rejected_creative_cleanup  # no
 logger = logging.getLogger("auto_put_ad_mini.creative_rejection_cleanup")
 
 
-def main() -> int:
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--underperformance-preview-only",
+        action="store_true",
+        help=(
+            "仅扫描未起量创意和广告,强制禁止腾讯删除,"
+            "只生成并发送当日内部群 Excel"
+        ),
+    )
+    parser.add_argument(
+        "--force-notification",
+        action="store_true",
+        help=(
+            "同日手动重发当日未起量内部群通知;"
+            "仅允许与 --underperformance-preview-only 一起使用"
+        ),
+    )
+    parser.add_argument(
+        "--output-dir",
+        type=Path,
+        default=HERE / "outputs" / "creative_rejection_cleanup",
+        help="Excel 本地输出目录",
+    )
+    args = parser.parse_args(argv)
+    if args.force_notification and not args.underperformance_preview_only:
+        parser.error(
+            "--force-notification 只能与 "
+            "--underperformance-preview-only 一起使用"
+        )
+    return args
+
+
+def main(argv: list[str] | None = None) -> int:
     from logging_setup import setup_logging
 
+    args = parse_args([] if argv is None else argv)
     setup_logging(level=os.getenv("LOG_LEVEL", "INFO"))
     started = time.monotonic()
-    logger.info("event=creative_rejection_cleanup stage=run result=started")
+    run_mode = (
+        "underperformance_preview_only"
+        if args.underperformance_preview_only
+        else "scheduled_full"
+    )
+    logger.info(
+        "event=creative_rejection_cleanup stage=run result=started "
+        "mode=%s force_notification=%s output_dir=%s",
+        run_mode,
+        args.force_notification,
+        args.output_dir,
+    )
     try:
         summary = run_rejected_creative_cleanup(
-            output_dir=HERE / "outputs" / "creative_rejection_cleanup"
+            output_dir=args.output_dir,
+            underperformance_preview_only=args.underperformance_preview_only,
+            force_notification=args.force_notification,
         )
     except Exception:
         logger.exception(
@@ -49,11 +97,30 @@ def main() -> int:
             int((time.monotonic() - started) * 1000),
         )
         raise
-    has_errors = bool(
-        summary["scan_errors"]
-        or summary["delete_errors"]
-        or summary["notification_errors"]
-    )
+    error_groups = {
+        "scan": list(summary.get("scan_errors") or []),
+        "delete": list(summary.get("delete_errors") or []),
+        "notification": list(summary.get("notification_errors") or []),
+    }
+    has_errors = any(error_groups.values())
+    if has_errors:
+        bounded_details = {
+            group: [str(error)[:1000] for error in errors[:20]]
+            for group, errors in error_groups.items()
+            if errors
+        }
+        logger.error(
+            "event=creative_rejection_cleanup stage=run "
+            "result=partial_failure deleted=%s deferred=%s "
+            "scan_error_count=%d delete_error_count=%d "
+            "notification_error_count=%d error_details=%s",
+            summary.get("deleted", 0),
+            summary.get("deferred", 0),
+            len(error_groups["scan"]),
+            len(error_groups["delete"]),
+            len(error_groups["notification"]),
+            json.dumps(bounded_details, ensure_ascii=False, default=str),
+        )
     logger.info(
         "event=creative_rejection_cleanup stage=run result=%s duration_ms=%d summary=%s",
         "partial" if has_errors else "succeeded",
@@ -64,4 +131,4 @@ def main() -> int:
 
 
 if __name__ == "__main__":
-    sys.exit(main())
+    sys.exit(main(sys.argv[1:]))

+ 2 - 2
examples/auto_put_ad_mini/config.py

@@ -526,7 +526,7 @@ MARKETING_ASSET_OUTER_SPEC = {
 # 短期:本字典占位;长期:迁到 DB(扩展 account_whitelist 表加列)
 # 设计接口:get_account_feedback_id(account_id) → 先查 DB,fallback 本字典
 ACCOUNT_FEEDBACK_ID_MAPPING = {
-    # account_id : feedback_id
+    # 账户 ID:监测链接 feedback_id
     83846793: 6700703,            # 用户 2026-06-05 提供(非人群包账户)
     83846804: 6700002,            # 用户 2026-06-05 提供(R330+ 人群包账户)
 }
@@ -887,7 +887,7 @@ DEFAULT_DAILY_BUDGET_FEN = DEFAULT_DAILY_BUDGET_YUAN * 100   # 元 → 分
 # --- 出价区间表(按 audience tier label 索引)---
 # 来源:用户提供的投放 SOP 出价区间表
 AUDIENCE_BID_RANGES = {
-    # tier_label                : (min_yuan, max_yuan)
+    # 档位名称:最低金额(元)、最高金额(元)
     "R50_泛惊奇_奇观技艺":         (0.35, 0.45),
     "R50_泛知识_生活科普":         (0.35, 0.45),
     "R50_泛知识_时政历史":         (0.35, 0.45),

+ 1 - 1
examples/auto_put_ad_mini/configure_creation_accounts.py

@@ -95,7 +95,7 @@ def parse_budget_fen(raw: object) -> int | None:
 
 
 def ensure_account_delivery_config_columns() -> None:
-    """Add account-level delivery override columns when deploying old DBs."""
+    """部署到旧数据库时补充账户级投放覆盖字段。"""
     from db.connection import get_connection
 
     conn = get_connection()

+ 3 - 4
examples/auto_put_ad_mini/debug_select_creative_patterns.py

@@ -1,8 +1,7 @@
-"""Debug creative pattern selection for one video.
+"""调试单个视频的创意模式选择。
 
-This script is read-only except the video feature cache may be populated when
-ODPS lookup is enabled. It does not generate images, upload OSS files, or create
-Tencent ads.
+除启用 ODPS 查询时可能填充视频特征缓存外,本脚本只读;不会生成图片、上传
+OSS 文件或创建腾讯广告。
 """
 
 from __future__ import annotations

+ 127 - 9
examples/auto_put_ad_mini/docs/unified_services_deployment.md

@@ -1,5 +1,16 @@
 # 自动投放双服务部署
 
+## 代码注释与任务日志约定
+
+生产源代码的自然语言注释和文档字符串统一使用中文;API 字段、环境变量、SQL、
+枚举值和第三方接口名称保留原始英文,避免翻译后与真实协议不一致。复杂业务规则、
+外部副作用、幂等边界、失败保守策略和兼容逻辑必须说明“为什么”;名称已经完整表达
+行为的简单取值函数不重复添加无信息量注释。
+
+所有生产任务入口必须使用统一日志初始化,并至少记录任务名、阶段、开始/成功/失败、
+耗时和 trace_id。调度子进程额外记录 child_trace_id,常驻服务的周期任务额外记录
+cycle_id;捕获异常时必须使用异常堆栈日志,不能只记录 `str(exc)` 后静默返回。
+
 ## 1. Jenkins 构建
 
 构建统一完整镜像,不再构建 `tencent-realtime-control` 独立镜像:
@@ -87,6 +98,21 @@ DAILY_REVIEW_ENABLED=0
 DAILY_REVIEW_INTERVAL_HOURS=2
 DAILY_REJECTED_CREATIVE_CLEANUP_ENABLED=0
 DAILY_REJECTED_CREATIVE_APPLY_ENABLED=0
+DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=0
+DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED=0
+DAILY_UNDERPERFORMING_CREATIVE_NEW_MIN_AGE_DAYS=5
+DAILY_UNDERPERFORMING_CREATIVE_NEW_MAX_AGE_DAYS=7
+DAILY_UNDERPERFORMING_CREATIVE_NEW_IMPRESSIONS_THRESHOLD=100
+DAILY_UNDERPERFORMING_CREATIVE_OLD_DAILY_IMPRESSIONS_THRESHOLD=100
+DAILY_UNDERPERFORMING_CREATIVE_WINDOW_DAYS=7
+DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED=0
+DAILY_UNDERPERFORMING_AD_APPLY_ENABLED=0
+DAILY_UNDERPERFORMING_AD_WINDOW_DAYS=3
+DAILY_UNDERPERFORMING_AD_MIN_AGE_DAYS=5
+DAILY_CLEANUP_CANDIDATE_BATCH_MIN_SIZE=20
+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_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN=100
 DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES=30
@@ -148,7 +174,7 @@ ROI_AGENCY_WEBHOOKS_JSON={}
 需人工判断项一起发送预演通知。任务从 `loghubods.opengid_base_data` 查询
 T-3 至 T-1 三个完整日内 `SUM(成本)>0` 的 `账号id`,枚举这些账户的全部未删除创意;
 账户范围不附加 `usersharedepth`、`videoid` 或 `hotsencetype` 条件。
-账户范围不依赖 MySQL 白名单或自动创建配置。任务批量读取腾讯正式审核结果:
+审核异常规则的账户范围不依赖 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` 层级实时汇总任务执行日前三个自然日
@@ -158,13 +184,71 @@ T-3 至 T-1 三个完整日内 `SUM(成本)>0` 的 `账号id`,枚举这些账
 严格低于 `DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN`(默认 100 元)时删除,达到 100 元
 则进入代理人工判断;实时消耗接口
 失败时同样只报警,禁止把失败结果当作 0 消耗。除此之外,普通创意的元素或版位审核拒绝均不触发
-任何自动删除;系统不再提供删除拒审元素组件的流程。逐账户扫描前会按
+任何自动删除;系统不再提供删除拒审元素组件的流程。
+
+`DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=1` 会在同一个任务中追加长期未起量判断,
+不会注册第二个调度任务。未起量范围独立读取
+`loghubods.ad_put_tencent_creative_day`:先按 `account_id + creative_id` 取最新一行,再仅保留
+`is_delete=0` 的创意。近 3 日有消耗账户仍只用于审核异常规则;仅因 ODPS 有效创意补入的账户
+不会进入审核异常规则。任务先完成全部审核异常判断;腾讯已返回删除状态的创意在任务入口即过滤,
+本轮命中审核异常规则的创意也会加入排除集,不再进入未起量判断。真实删除同样严格按
+“审核异常创意 → 未起量创意 → 零消耗广告”三个阶段串行执行,防止已删除或待删除创意被重复检测和删除。
+审核账户范围或该账户审核接口读取失败时,该账户未起量创意也会保守跳过;广告判断与审核/创意接口隔离,
+仍可按广告自身条件继续执行。
+搭建天数按上海时区从创建时刻起已完整经过的 24 小时天数计算,创意搭建时间读取
+`loghubods.ad_put_tencent_creative_day.create_time`。新素材的“累计”指标通过腾讯
+`REPORT_LEVEL_DYNAMIC_CREATIVE` 从创意创建日汇总至 T 日,包含当日曝光和消耗;老素材的近 7 日指标仍严格使用
+T-7 至 T-1 七个完整自然日,并另查 T 日当日消耗。只判断配置状态为正常、
+审核状态为投放中或部分投放中、且所属广告配置状态也为正常的创意;搭建时间缺失、腾讯指标读取失败、
+创意或所属广告已暂停/停止、审核中或审核拒绝时一律不按未起量规则删除。规则为:新素材
+`5 < 搭建天数 <= 7` 时,累计曝光严格小于 100 且累计消耗等于 0 才删除;老素材搭建天数大于 7 时,
+近 7 天累计消耗等于 0,且日均曝光严格小于 100 或严格大于 100 才删除。新老素材只要当日已有消耗就不删除。按需求保留严格不等号,
+日均曝光恰好等于 100 不命中。阈值分别由
+`DAILY_UNDERPERFORMING_CREATIVE_NEW_MIN_AGE_DAYS`、
+`DAILY_UNDERPERFORMING_CREATIVE_NEW_MAX_AGE_DAYS`、
+`DAILY_UNDERPERFORMING_CREATIVE_NEW_IMPRESSIONS_THRESHOLD`、
+`DAILY_UNDERPERFORMING_CREATIVE_OLD_DAILY_IMPRESSIONS_THRESHOLD` 和
+`DAILY_UNDERPERFORMING_CREATIVE_WINDOW_DAYS` 管理。
+`DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED` 是独立真实删除开关,默认关闭;即使拒审创意已经开启
+真实删除,新规则也必须先单独预演后才能开启。启用新规则扫描或删除必须配置
+`FEISHU_AD_PROJECT_CHAT_ID`。
+
+未起量候选的账户名称和代理商昵称统一读取 `loghubods.ad_put_tencent_account`:先按 `account_id`
+及 `update_time/create_time/id` 取最新一行,再保留 `is_delete=0` 的当前账户;`account_name` 写入审计及
+Excel“账户名称”,`agent_name` 写入审计及长期未起量内部 Excel“代理商昵称”。该昵称只用于内部展示,
+不参与代理群路由,也不会使未起量明细进入代理商通知。
+
+全部创意判断完成后,任务才进入广告判断阶段。开启
+`DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED=1` 后,系统只从上述 ODPS 有效创意的 `ad_id` 派生广告范围,
+不依赖创意审核结果或腾讯创意列表是否读取成功;若腾讯创意实时归属与 ODPS `ad_id` 不一致,只记录诊断,
+不阻断广告按自身条件判断。仅判断配置状态正常、当前处于投放日期范围内,且腾讯
+`created_time` 显示创建后已完整超过 5 天的广告。系统通过 `REPORT_LEVEL_ADGROUP` 实时汇总
+T-3 至 T-1 三个完整自然日消耗,并另查 T 日当日消耗;两者都等于 0 时才进入广告删除候选。
+指标接口异常、广告暂停/停止、尚未到开始日期或已经超过结束日期时均不删除。真实删除由独立开关
+`DAILY_UNDERPERFORMING_AD_APPLY_ENABLED` 控制,默认关闭;删除前在全局腾讯写锁内重新读取广告状态和
+近 3 日及当日消耗,删除后再次回读确认。广告删除状态统一通过 `/v3.0/adgroups/get` 按
+`adgroup_id` 精确查询;`is_deleted`(兼容内部字段名 `is_delete`)为真,或 `system_status` 为
+`ADGROUP_STATUS_DELETED`/`SMART_ADGROUP_STATUS_DELETED`,任一条件成立即视为已删除。普通范围查询未返回
+广告时会再显式查询已删除范围;两次都未返回只表示状态未知,会延后处理,不能仅凭列表缺失推断广告已删除。
+广告扫描要求创意未起量扫描已开启,不能跳过创意阶段单独运行;
+窗口由 `DAILY_UNDERPERFORMING_AD_WINDOW_DAYS` 管理,默认 3 天;最小年龄由
+`DAILY_UNDERPERFORMING_AD_MIN_AGE_DAYS` 管理,默认 5(严格要求年龄大于该值)。任务若在删除批次中跨过
+上海自然日,会把剩余项置为延后,禁止沿用上一日窗口删除。
+
+腾讯当日实时报表存在平台延迟,当日消耗复核只能降低、不能完全消除最近一段时间起量但尚未入报表的风险;
+因此两类未起量真实删除开关默认关闭,生产必须先核对预演结果和实际数据延迟,再决定启用时段。
+
+逐账户扫描前会按
 `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;
-候选并发落库时单条失败不会中断其他创意。删除前先原子认领数据库记录,再在
+创意命中判断只在内存完成,达到 `DAILY_CLEANUP_CANDIDATE_BATCH_MIN_SIZE`(默认 20)后,候选按
+`DAILY_CLEANUP_CANDIDATE_BATCH_SIZE`(默认 100)复用一个 MySQL 事务连接,并由
+`DAILY_CLEANUP_CANDIDATE_STORE_WORKERS`(默认 4,最大 16)并发写入;不再为每条候选单独建连和回读。
+整个批次失败时会回滚并按单条幂等路径重试,单条失败不会中断同批其他候选。广告候选的纯内存判断按
+`TENCENT_AD_AD_PROCESS_WORKERS`(默认 8,最大 32)并发。删除前先原子认领数据库记录,再在
 `RTC_DB_LOCK_NAME` 写锁内并发回读、复审和删除;只有认领成功且整创意删除动作保持一致才执行,
 历史遗留的组件删除候选不会被加载执行。认领后仅允许持有 `DELETING` 状态的进程回写结果;
 超过 `DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES`(默认 30 分钟)的认领可由下次任务恢复,
@@ -179,7 +263,7 @@ T-3 至 T-1 三个完整日内 `SUM(成本)>0` 的 `账号id`,枚举这些账
 腾讯写操作期间持有。每条写前状态、回读结果、失败状态和通知状态均持久化;超时导致写结果未知时,
 下次运行先回读确认,不会盲目重复写入。
 
-自动清理成功项和待人工判断报警项按日级 ROI 数据中的代理归属生成一代理一份
+审核异常自动清理成功项和待人工判断报警项按日级 ROI 数据中的代理归属生成一代理一份
 `YYYYMMDD_代理名称_创意审核异常处理_批次摘要.xlsx`。代理报表仅展示代理、账户、广告、创意、
 近 3 天历史消耗、执行操作、配置状态、创意审核状态 (`creative_set_approval_status`) 和审核不通过原因。
 投放调控内部汇总表额外展示消耗日期范围、操作判断原因、元素和版位粒度审核状态/原因及检查时间。
@@ -191,13 +275,47 @@ Excel 中
 原始枚举值。报表通过现有
 `ROI_AGENCY_WEBHOOKS_JSON` 精确路由到代理群;同批次还会生成包含全部代理创意的汇总表,通过飞书应用
 发送到 `FEISHU_AD_PROJECT_CHAT_ID` 投放调控通知群。代理通知与投放调控汇总通知分别记录成功状态;
+长期未起量创意和近 3 日零消耗广告绝不进入代理商报表或代理商群;系统为这部分单独生成
+`YYYYMMDD_投放调控_长期未起量创意及广告清理汇总_批次摘要.xlsx`,并在同一次任务中向
+`FEISHU_AD_PROJECT_CHAT_ID` 另发一条“长期未起量创意及广告清理汇总通知”。未起量删除不依赖代理归属或
+代理 webhook;Excel 会展示账户表中的“代理商昵称”,但仍要求内部群、数据库审计、全局腾讯写锁、
+写前回读和写后校验全部成功。
+审核异常代理通知与两类内部汇总通知分别记录成功状态;
 单一目标失败时只重试失败目标,已成功目标不会重复发送。上传或机器人通知失败时复用已有在线表,
 只重试通知,不重复删除。每天的检查使用独立日级审计记录,同一需人工判断创意如果次日仍命中规则,
 会按当天新检查再次提醒。代理归属为空,或已解析代理但没有配置对应代理群时,记录置为 `DEFERRED`,
-禁止执行腾讯删除,待归属和路由完整后再处理。该任务要求同时设置
-`ROI_AGENCY_WEBHOOK_ENABLED=1` 和 `FEISHU_AD_PROJECT_CHAT_ID`;启用真实删除但缺少任一配置时,
-任务会在腾讯写操作前拒绝启动。生产启用顺序为:先只开启任务完成只读审核结果核对,再开启
-`ROI_AGENCY_WEBHOOK_ENABLED=1` 和 `DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1`。
+禁止执行腾讯删除,待归属和路由完整后再处理。启用长期未起量扫描必须设置
+`FEISHU_AD_PROJECT_CHAT_ID`;这类通知和删除均不要求 `ROI_AGENCY_WEBHOOK_ENABLED=1`。
+审核异常真实删除仍要求代理 webhook 和内部群均已配置,缺少相应配置时任务会在
+腾讯写操作前拒绝启动。生产启用顺序为:先只开启任务完成只读审核结果核对;审核异常链路再开启
+`ROI_AGENCY_WEBHOOK_ENABLED=1` 和 `DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1`;长期未起量链路先开启
+`DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=1` 预演并核对内部群的独立消息,确认后再开启
+`DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED=1`;广告链路同样先开启
+`DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED=1` 预演,确认后再开启
+`DAILY_UNDERPERFORMING_AD_APPLY_ENABLED=1`。
+
+本地只预演未起量规则时使用:
+
+```bash
+PYTHONPATH=examples/auto_put_ad_mini:examples/tencent_realtime_control \
+  .venv/bin/python examples/auto_put_ad_mini/cleanup_rejected_creatives.py \
+  --underperformance-preview-only
+```
+
+该入口不读取审核异常账户范围、不生成审核异常候选,且无论环境变量中的真实删除开关为何值,都会强制关闭
+审核异常、未起量创意和零消耗广告的腾讯删除。它只处理当日未起量候选,在本地输出 Excel,并复用
+`FEISHU_AD_PROJECT_CHAT_ID` 发送一条内部群通知;不会读取或发送代理商待通知记录。预览 Excel 只包含
+本轮重新确认仍满足规则且成功写入审计表的候选,不会带出当天旧版本误判或本轮已不再满足规则的残留记录。
+
+同一天已经成功发送过通知、需要人工重新生成 Excel 并再次发送内部群时,增加
+`--force-notification`。该参数只能与预演模式一起使用,不会开启任何腾讯删除:
+
+```bash
+PYTHONPATH=examples/auto_put_ad_mini:examples/tencent_realtime_control \
+  .venv/bin/python examples/auto_put_ad_mini/cleanup_rejected_creatives.py \
+  --underperformance-preview-only \
+  --force-notification
+```
 
 ## 3. ECS 部署脚本
 
@@ -342,7 +460,7 @@ ROI 表格使用获得链接者可编辑权限。默认隐藏的黄色【审批
 
 `ROI_INTERNAL_TEST_ENABLED=1` 时,服务每天北京时间15:30注册独立内部任务。该任务复算后使用空管理账户集合,不生成可审批腾讯动作,只把“自动化投放”分表发送到 `ROI_AGENCY_WEBHOOKS_JSON` 的“内部”路由,不发送完整主表、正式总群或代理群;缺少该分表时任务失败并告警。它不注册startup任务,服务重启不会主动补发。正式任务始终去重;内部测试由 `ROI_INTERNAL_TEST_DEDUP_ENABLED` 控制,生产默认`1`,测试环境显式设为`0`时每次触发生成独立测试run并重新发送。
 
-正式轮询的数据就绪检查不会把来源延迟当成任务失败,也不会每30分钟发送失败告警。18:00仍未就绪时只发送一次截止告警。正式任务实际启动后或15:30内部测试的子进程启动失败、非零退出时,调度服务通过飞书应用向 `ROI_FAILURE_FEISHU_CHAT_ID` 发送红色失败告警;该变量为空时回退 `FEISHU_OPERATOR_CHAT_ID`。失败告警本身发送失败只记录错误日志,不会吞掉或替换原任务异常
+正式轮询的数据就绪检查不会把来源延迟当成任务失败,也不会每30分钟发送失败告警。18:00仍未就绪时只发送一次截止告警。正式任务实际启动后或15:30内部测试的子进程启动失败、非零退出时,调度服务通过飞书应用向 `ROI_FAILURE_FEISHU_CHAT_ID` 发送红色失败告警;该变量为空时回退 `FEISHU_OPERATOR_CHAT_ID`。失败告警本身发送失败会记录完整异常堆栈,不会吞掉或替换原任务异常。每次子任务启动、成功或失败日志都包含唯一 `child_trace_id`;子进程内的业务日志沿用该值,可在 SLS 中直接检索同一次执行的完整日志。APScheduler 的执行异常、错过执行和并发上限事件额外记录 `job_id`、计划执行时间、异常对象及 traceback;主线程和后台线程的未捕获异常由统一日志钩子兜底。实时调控与收入预测的每一轮分别记录 `cycle_id`、耗时和执行结果。创意清理存在部分成功时会保留已完成的删除和通知,同时以 `partial_failure` 记录扫描、删除和通知三类错误数量及明细,并返回非零退出码提示人工检查,不能把部分遗漏静默成成功
 
 1. 首次部署先执行下方参数发布命令并完成回读校验。
 2. 设置 `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=0`、`ROI_SHEET_APPROVAL_ENABLED=0`,观察09:00起每30分钟的T-1来源校验;数据就绪后应只生成一份数据库快照和可编辑飞书表,18:00未就绪应只收到一次截止告警。

+ 5 - 5
examples/auto_put_ad_mini/execute_creation_once.py

@@ -107,7 +107,7 @@ def _env_csv_set(name: str) -> set[str]:
 
 
 def _parse_account_id_arg(raw: str) -> int:
-    """Parse one explicit CLI account id and fail closed on malformed input."""
+    """解析一个显式 CLI 账户 ID,格式异常时按保守策略拒绝执行。"""
     try:
         account_id = int(raw)
     except (TypeError, ValueError) as exc:
@@ -120,7 +120,7 @@ def _parse_account_id_arg(raw: str) -> int:
 def _normalize_requested_account_ids(
     account_ids: list[int] | None,
 ) -> set[int] | None:
-    """Normalize CLI scope; reject the removed fail-open environment variable."""
+    """标准化 CLI 范围,并拒绝已废弃的放开式环境变量。"""
     legacy_scope = os.getenv("CREATION_ONLY_ACCOUNT_IDS", "").strip()
     if legacy_scope:
         raise ValueError(
@@ -139,7 +139,7 @@ def _validate_requested_accounts(
     eligible_accounts: list[int],
     config_date: date | None,
 ) -> None:
-    """Ensure every explicitly requested account is enabled for this config date."""
+    """确认显式指定的所有账户在当前配置日期均处于启用状态。"""
     if requested is None:
         return
     rejected = sorted(requested - set(eligible_accounts))
@@ -155,7 +155,7 @@ def _filter_creation_accounts(
     phase: str,
     only_account_ids: set[int] | None = None,
 ) -> list[int]:
-    """Apply explicit CLI account scope and optional crowd-package filter."""
+    """应用显式 CLI 账户范围及可选人群包过滤。"""
     account_filter = only_account_ids
     crowd_filter = _env_csv_set("CREATION_ONLY_CROWD_PACKAGES")
     if account_filter is None and not crowd_filter:
@@ -915,7 +915,7 @@ def _write_pending_records(records: list[dict], output_dir: Path) -> Path:
 def phase2_approval(records: list[dict], xlsx_output_dir: Path) -> dict[int, str]:
     """Phase 2:飞书审批(若需要)。
 
-    Returns: actions {row_idx: action}, 1-based row_idx
+    返回 actions {row_idx: action},其中 row_idx 从 1 开始。
     """
     from tools.im_approval_creation import run_approval_workflow
 

+ 2 - 3
examples/auto_put_ad_mini/import_material_strategy_learning.py

@@ -1,7 +1,6 @@
-"""Import local material strategy learning analysis into DB.
+"""将本地素材策略学习分析导入数据库。
 
-This script is idempotent for the default run_id. It only writes internal
-learning tables and has no Tencent/Feishu side effects.
+使用默认 run_id 时本脚本幂等;只写内部学习表,不产生腾讯或飞书副作用。
 """
 
 from __future__ import annotations

+ 1 - 1
examples/auto_put_ad_mini/k8s/secret.yaml

@@ -25,7 +25,7 @@ stringData:
   ODPS_ACCESS_SECRET: "xxxxx"
   ODPS_PROJECT: "loghubods"
 
-  # LLM API Key
+  # 大模型 API 密钥
   OPEN_ROUTER_API_KEY: "sk-xxxxx"
   # QWEN_API_KEY: "sk-xxxxx"
 

+ 42 - 36
examples/auto_put_ad_mini/logging_setup.py

@@ -1,9 +1,8 @@
-"""Logging setup owned by the auto_put_ad_mini example.
+"""auto_put_ad_mini 生产任务共用的日志配置。
 
-The example is also run as standalone scripts, so this module deliberately
-does not depend on ``agent.utils.logging``.  Logs go to the console and,
-when configured, to separate Alibaba Cloud SLS Logstores (INFO/WARNING and
-ERROR/CRITICAL).  No local file logging is performed.
+这些任务也会作为独立脚本执行,因此本模块不依赖 ``agent.utils.logging``。
+日志始终输出到控制台;配置阿里云 SLS 后,普通日志与错误日志分别写入对应
+Logstore,不再额外写本地日志文件。
 """
 
 from __future__ import annotations
@@ -28,10 +27,11 @@ _CONFIGURED = False
 _EXCEPTION_HOOKS_INSTALLED = False
 _ATEXIT_REGISTERED = False
 _TRACE_ID: str | None = None
+_TRACE_ID_ENV = "AUTO_PUT_TRACE_ID"
 
 
 def get_trace_id() -> str | None:
-    """Return the trace_id of the current session, or None if not yet initialised."""
+    """返回当前进程的 trace_id;日志尚未初始化时返回 None。"""
     return _TRACE_ID
 
 
@@ -53,10 +53,10 @@ class _ExcludeCapturedOutput(logging.Filter):
 
 
 class _TraceIdFilter(logging.Filter):
-    """Inject ``trace_id`` into every LogRecord for structured SLS queries.
+    """向每条日志注入 ``trace_id``,供 SLS 按一次任务执行链路检索。
 
-    The filter reads from the module-level ``_TRACE_ID`` so that it works even
-    when ``setup_logging()`` is called after the filter is added.
+    过滤器从模块级 ``_TRACE_ID`` 读取值,因此先挂载过滤器、后执行
+    ``setup_logging()`` 的调用顺序也能正常工作。
     """
 
     def filter(self, record: logging.LogRecord) -> bool:
@@ -65,19 +65,17 @@ class _TraceIdFilter(logging.Filter):
 
 
 class _SafeQueuedLogHandler:
-    """Create an SDK handler whose emit failures cannot break the app logger,
-    and whose timestamps are forced to second-level precision.
+    """创建不会因 SLS 发送失败而中断业务的 SDK handler。
 
-    The SLS protobuf schema includes ``optional fixed32 Time_ns`` — when set
-    (even to 0) the console renders ``.000000000``.  We strip it so only
-    integer-second timestamps appear.
+    同时移除 SLS protobuf 中的 ``Time_ns`` 可选字段,避免控制台时间戳显示
+    无意义的九位纳秒尾数,只保留秒级时间。
     """
 
     @staticmethod
     def create(handler_cls, **kwargs) -> logging.Handler:
         handler = handler_cls(**kwargs)
 
-        # -- safe emit ----------------------------------------------------
+        # 包装 emit,SLS 网络异常只进入 logging 自身错误处理,不影响业务线程。
         original_emit = handler.emit
 
         def safe_emit(record):
@@ -88,14 +86,10 @@ class _SafeQueuedLogHandler:
 
         handler.emit = safe_emit
 
-        # -- strip Time_ns from protobuf before serialization -------------
-        # The SDK populates LogItem.time_nano_part from ``time.time()``
-        # rather than ``record.created``, injecting a random 9-digit value.
-        # Even when forced to 0 the optional protobuf field *is* serialised
-        # (fixed32 0 ≠ absent) and the console renders ``.000000000``.
-        # Monkey-patching ``LogGroup.SerializeToString`` (called once per
-        # batch inside ``LogClient.put_logs``) clears the field right before
-        # encoding without duplicating the ~40-line SDK method.
+        # SDK 使用 ``time.time()`` 填充 LogItem.time_nano_part,而不是使用
+        # ``record.created``,会生成随机的九位尾数。即使强制设为 0,该可选字段
+        # 仍会被序列化并显示 ``.000000000``。这里在每批日志序列化前临时清除
+        # ``Time_ns``,避免复制 SDK 内部约 40 行的发送实现。
         import aliyun.log.log_logs_pb2 as _sls_pb
 
         _orig_serialize = _sls_pb.LogGroup.SerializeToString
@@ -119,7 +113,7 @@ class _SafeQueuedLogHandler:
 
 
 class _CapturedStream:
-    """Mirror a process stream into logging while preserving interactive output."""
+    """保留终端原始输出,同时把 stdout/stderr 镜像到统一日志。"""
 
     def __init__(self, original, level: int, logger_name: str):
         self._original = original
@@ -199,9 +193,8 @@ def _configured_sls_handlers(log_level: int) -> Iterable[logging.Handler]:
         )
         return []
 
-    # SLS uses its own __time__ field, and level/name/func/file/line/thread are
-    # already sent as separate fields via the ``fields`` list. Keep only the
-    # message body to avoid redundant data cluttering the SLS console.
+    # SLS 自带 __time__ 字段,级别、模块、文件、行号和线程也已通过 fields
+    # 单独上报,因此正文只保留 message,避免控制台出现重复字段。
     sls_formatter = logging.Formatter(_SLS_LOG_FORMAT)
     common = dict(
         end_point=sls_endpoint,
@@ -232,7 +225,7 @@ def _configured_sls_handlers(log_level: int) -> Iterable[logging.Handler]:
 
 
 def attach_sls_handler(root_logger: Optional[logging.Logger] = None) -> bool:
-    """Attach the example-owned SLS handlers once, returning whether enabled."""
+    """幂等挂载本项目的 SLS handlers,并返回是否已启用 SLS。"""
     global _SLS_HANDLERS
     root_logger = root_logger or logging.getLogger()
     if _SLS_HANDLERS and all(handler in root_logger.handlers for handler in _SLS_HANDLERS):
@@ -302,12 +295,12 @@ def setup_logging(
     level: str = "INFO",
     capture_output: bool = True,
 ) -> None:
-    """Configure root logging: console + optional SLS, no local file."""
+    """配置根日志:控制台加可选 SLS,不写本地文件。"""
     global _CAPTURE_INSTALLED, _CONFIGURED, _TRACE_ID
 
-    # Generate a per-session trace_id once per process lifetime.
+    # 调度器启动的子进程继承任务专属 trace_id;独立运行的脚本自行生成。
     if _TRACE_ID is None:
-        _TRACE_ID = _generate_trace_id()
+        _TRACE_ID = _inherited_trace_id() or _generate_trace_id()
 
     log_level = getattr(logging, str(level).upper(), logging.INFO)
     stdout = sys.__stdout__
@@ -321,8 +314,7 @@ def setup_logging(
     console.setFormatter(formatter)
 
     root_logger = logging.getLogger()
-    # Handler failures (for example an SLS network outage) must never write
-    # recursive logging tracebacks to stderr or interrupt the business flow.
+    # handler 失败(例如 SLS 网络中断)不能递归向 stderr 打印异常,更不能中断业务。
     logging.raiseExceptions = False
     if not _CONFIGURED:
         logging.basicConfig(
@@ -350,12 +342,26 @@ def setup_logging(
 
 
 def _generate_trace_id() -> str:
-    """Generate a compact, sortable, unique trace_id for one process invocation.
+    """为一次进程执行生成紧凑、可排序且近似唯一的 trace_id。
 
-    Format: ``YYYYMMDD-HHMMSS-{8 random hex chars}``.
-    ~16M combinations per second; sufficient for cron-triggered processes.
+    格式为 ``YYYYMMDD-HHMMSS-{8位随机字符}``,足以区分定时任务的并发执行。
     """
     now = datetime.now()
     ts = now.strftime("%Y%m%d-%H%M%S")
     suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
     return f"{ts}-{suffix}"
+
+
+def _inherited_trace_id() -> str | None:
+    """读取并校验调度器传入的 trace_id;不存在或不安全时返回 None。"""
+
+    raw = os.getenv(_TRACE_ID_ENV, "").strip()
+    if not raw or len(raw) > 160:
+        return None
+    allowed_punctuation = frozenset("-_.:")
+    if not all(
+        character.isalnum() or character in allowed_punctuation
+        for character in raw
+    ):
+        return None
+    return raw

+ 1 - 1
examples/auto_put_ad_mini/refresh_roi_fission_multiplier.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""Generate propagation-fission drafts or publish an audited DB release."""
+"""生成传播裂变参数草稿,或发布已审计的数据库版本。"""
 
 from __future__ import annotations
 

+ 13 - 13
examples/auto_put_ad_mini/requirements.txt

@@ -1,47 +1,47 @@
-# Agent Framework (local installation)
-# Install from parent directory: pip install -e ../../
+# Agent 框架(本地安装)
+# 在父目录执行安装:pip install -e ../../
 
-# Core Dependencies
+# 核心依赖
 pandas>=2.0.0
 numpy>=1.24.0
 httpx>=0.24.0
 requests>=2.31.0
 python-dotenv>=1.0.0
 
-# Excel/Report Generation
+# Excel 与报表生成
 openpyxl>=3.1.0
 xlsxwriter>=3.1.0
 
-# ODPS/Data Processing
+# ODPS 与数据处理
 pyodps>=0.11.0
 
-# Async Support
+# 异步支持
 asyncio>=3.4.3
 aiohttp>=3.8.0
 
-# LLM / API
+# 大模型与 API
 openai>=1.0.0
 
-# Utilities
+# 通用工具
 pydantic>=2.0.0
 loguru>=0.7.0
 tenacity>=8.2.0
 docstring-parser>=0.15
 Pillow>=10.0.0
 
-# For FastAPI + APScheduler (if using server.py)
+# FastAPI 与 APScheduler(仅使用 server.py 时需要)
 fastapi>=0.104.0
 uvicorn[standard]>=0.24.0
 apscheduler>=3.10.0
 
-# Database (MySQL)
+# 数据库(MySQL)
 pymysql>=1.1.0
 
-# Feishu SDK
+# 飞书 SDK
 lark-oapi>=1.5.0
 
-# Alibaba Cloud Simple Log Service (SLS)
+# 阿里云日志服务(SLS)
 aliyun-log-python-sdk>=0.7.16
 
-# Optional: Prometheus metrics
+# 可选:Prometheus 指标
 prometheus-client>=0.19.0

+ 1 - 1
examples/auto_put_ad_mini/roi_control/__init__.py

@@ -1 +1 @@
-"""Versioned daily ROI metrics, policy, reporting, and approved execution."""
+"""版本化日级 ROI 指标、策略、报表及审批执行。"""

+ 3 - 3
examples/auto_put_ad_mini/roi_control/agency_delivery.py

@@ -1,4 +1,4 @@
-"""Upload per-agency ROI workbooks and notify exact Feishu bot routes."""
+"""上传分代理商 ROI 报表,并通知精确匹配的飞书机器人路由。"""
 
 from __future__ import annotations
 
@@ -21,7 +21,7 @@ logger = logging.getLogger("auto_put_ad_mini.roi_control.agency_delivery")
 
 
 def agency_route_key(agency_name: str) -> str:
-    """Resolve the configured short agency key without fuzzy matching."""
+    """不使用模糊匹配,解析配置中的代理商简称。"""
 
     normalized = "".join(str(agency_name).split())
     for prefix in ("小程序-代投-", "小程序-代投", "代投公众号-", "代投公众号"):
@@ -152,7 +152,7 @@ def publish_agency_reports(
     upsert_delivery=None,
     update_delivery=None,
 ) -> list[dict[str, object]]:
-    """Publish configured agency reports independently and return safe outcomes."""
+    """独立发布各代理商报表,并返回不泄露敏感信息的结果。"""
 
     if not config.enabled:
         return []

+ 2 - 2
examples/auto_put_ad_mini/roi_control/config.py

@@ -1,4 +1,4 @@
-"""Configuration for daily ROI computation and approved execution."""
+"""日级 ROI 计算与审批执行配置。"""
 
 from __future__ import annotations
 
@@ -23,7 +23,7 @@ AGENCY_WEBHOOK_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/"
 
 @dataclass(frozen=True)
 class AgencyWebhookConfig:
-    """Secret-backed routing for per-agency ROI report notifications."""
+    """由密钥配置支持的分代理商 ROI 报表通知路由。"""
 
     enabled: bool = False
     webhooks: Mapping[str, str] | None = None

+ 6 - 6
examples/auto_put_ad_mini/roi_control/data_source.py

@@ -17,7 +17,7 @@ SHANGHAI = ZoneInfo("Asia/Shanghai")
 
 
 class SourceDataNotReadyError(RuntimeError):
-    """The exact ROI source partition required for this run is not ready."""
+    """本轮所需的精确 ROI 源分区尚未就绪。"""
 
 
 def parse_yyyymmdd(value: str) -> datetime:
@@ -48,7 +48,7 @@ WHERE dt = '{end_date}'
 
 
 def validate_source_ready(client: ODPSClient, end_date: str) -> dict[str, int]:
-    """Require an exact, non-empty T-1 partition for every reported channel."""
+    """要求每个报表渠道都存在精确且非空的 T-1 分区。"""
 
     max_partition = client.odps.get_table("opengid_base_data").get_max_partition()
     if max_partition is None:
@@ -88,7 +88,7 @@ def resolve_end_date(
     *,
     now: datetime | None = None,
 ) -> str:
-    """Resolve exactly the requested date or T-1, then require source readiness."""
+    """精确解析指定日期或 T-1,并校验数据源就绪状态。"""
 
     if requested:
         parse_yyyymmdd(requested)
@@ -204,7 +204,7 @@ GROUP BY dt, channel, 合作方名, 公众号名
 
 
 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)
@@ -251,7 +251,7 @@ def fetch_recent_spend_accounts(
 
 
 def build_account_agency_fallback_sql(account_ids: list[int]) -> str:
-    """Build the account-level agency fallback query for known numeric IDs."""
+    """为已知数字账户 ID 构建账户级代理商兜底查询。"""
 
     normalized_ids: set[int] = set()
     for value in account_ids:
@@ -293,7 +293,7 @@ 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 {}

+ 4 - 4
examples/auto_put_ad_mini/roi_control/execution.py

@@ -1,4 +1,4 @@
-"""Execute an approved ROI batch with Tencent read-back and state coordination."""
+"""通过腾讯回读和状态协同执行已审批的 ROI 批次。"""
 
 from __future__ import annotations
 
@@ -73,7 +73,7 @@ def plan_scale_bid(
     scale_ratio: Decimal,
     max_base_ratio: Decimal,
 ) -> dict[str, Any]:
-    """Plan a permanent base increase without reapplying a restored boost."""
+    """规划永久基础出价上调,避免重复应用已恢复的日内扩量。"""
 
     boosted_date_today = boosted_date == control_date
     expected_boosted = _round_fen(Decimal(base_bid_fen) * intraday_ratio)
@@ -196,7 +196,7 @@ def _execute_pause_ad(
     client: TencentClient,
     now: datetime,
 ) -> None:
-    """Pause the entire ad after an ad-level ROI row is approved."""
+    """广告级 ROI 行审批通过后暂停整条广告。"""
 
     account_id = int(item["account_id"])
     adgroup_id = int(item["adgroup_id"])
@@ -530,7 +530,7 @@ def execute_approved_roi_actions(
     lock_name: str,
     tencent: TencentClient | None = None,
 ) -> dict[str, Any]:
-    """Execute only explicitly approved sheet rows without claiming the batch."""
+    """不认领整个批次,仅执行表格中明确审批通过的行。"""
 
     config = RoiConfig.from_env()
     if not config.apply_enabled:

+ 3 - 3
examples/auto_put_ad_mini/roi_control/feishu.py

@@ -1,4 +1,4 @@
-"""Publish editable ROI workbooks and row-level approval instructions."""
+"""发布可编辑 ROI 报表及逐行审批说明。"""
 
 from __future__ import annotations
 
@@ -308,7 +308,7 @@ class RoiFeishuPublisher:
         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:
@@ -379,7 +379,7 @@ class RoiFeishuPublisher:
         }
 
     def upload_workbook(self, path: Path) -> dict[str, str]:
-        """Upload one workbook as an editable online sheet without notifying chats."""
+        """将报表上传为可编辑在线表格,但不发送群通知。"""
 
         if not path.is_file():
             raise FileNotFoundError(path)

+ 6 - 8
examples/auto_put_ad_mini/roi_control/fission_multiplier.py

@@ -1,8 +1,7 @@
-"""Versioned T15 propagation-fission multipliers relative to T0.
+"""相对 T0 的版本化 T15 传播裂变系数。
 
-This module is deliberately independent from ODPS, policy, persistence,
-approval, and Tencent writes. Daily ROI consumes a published parameter release;
-it never recomputes cohort parameters during a production run.
+本模块刻意与 ODPS、策略、持久化、审批及腾讯写操作解耦。日级 ROI 只消费
+已发布的参数版本,生产运行期间绝不现场重算 cohort 参数。
 """
 
 from __future__ import annotations
@@ -129,8 +128,7 @@ class FissionMultiplierParameters:
         if self.release.includes_total_to_first:
             snapshot["includes_total_to_first"] = True
             snapshot["source_version"] = self.release.source_version
-        # Keep legacy release hashes stable until a release explicitly carries
-        # audited WeCom parameters.
+        # 在发布版本明确携带已审计企微参数前,保持旧发布版本哈希稳定。
         if self.qiwei_by_partner:
             snapshot.update(
                 {
@@ -263,7 +261,7 @@ class FissionMultiplierParameters:
 def parameter_values(
     parameters: FissionMultiplierParameters,
 ) -> list[dict[str, object]]:
-    """Serialize one validated release into database lookup rows."""
+    """将一个校验通过的发布版本序列化为数据库查询行。"""
 
     rows: list[dict[str, object]] = []
 
@@ -337,7 +335,7 @@ def parameters_from_database(
     release_row: Mapping[str, object],
     value_rows: list[Mapping[str, object]],
 ) -> FissionMultiplierParameters:
-    """Build runtime parameters from one published database version."""
+    """根据一个已发布数据库版本构建运行参数。"""
 
     metadata = json.loads(str(release_row.get("metadata_json") or "{}"))
     file_sha256 = metadata.get("file_sha256") or {}

+ 6 - 7
examples/auto_put_ad_mini/roi_control/fission_multiplier_refresh.py

@@ -1,8 +1,7 @@
-"""Offline T15 cohort parameter recomputation.
+"""离线重算 T15 cohort 参数。
 
-The functions here are read-only DataFrame/SQL transformations. Generated
-parameters are drafts; publishing requires adding a reviewed release to
-``fission_multiplier.RELEASES`` with file checksums.
+本文件函数仅执行只读 DataFrame/SQL 转换。生成结果均为草稿;发布前必须将
+已审核版本及文件校验和加入 ``fission_multiplier.RELEASES``。
 """
 
 from __future__ import annotations
@@ -28,7 +27,7 @@ def build_miniapp_activity_curve_sql(
     cohort_date: str,
     horizon: int = HORIZON,
 ) -> str:
-    """Build the audited miniapp package + conversion-goal cohort SQL."""
+    """构建已审计的小程序人群包加转化目标 cohort SQL。"""
 
     start = _parse_date(cohort_date)
     if horizon < 0:
@@ -138,7 +137,7 @@ def build_gzh_activity_curve_sql(
     horizon: int = HORIZON,
     min_first_uv: int = 500,
 ) -> str:
-    """Build the audited official-account partner + account cohort SQL."""
+    """构建已审计的公众号合作方加账号 cohort SQL。"""
 
     start = _parse_date(cohort_date)
     if horizon < 0:
@@ -252,7 +251,7 @@ def build_qiwei_activity_curve_sql(
     horizon: int = HORIZON,
     min_first_uv: int = 500,
 ) -> str:
-    """Build the audited WeCom partner-level cohort SQL."""
+    """构建已审计的企微合作方级 cohort SQL。"""
 
     start = _parse_date(cohort_date)
     if horizon < 0:

+ 3 - 4
examples/auto_put_ad_mini/roi_control/metrics.py

@@ -1,7 +1,6 @@
-"""Versioned north-star ROI metric computation.
+"""版本化北极星 ROI 指标计算。
 
-This module only transforms pandas DataFrames. It does not know about policy,
-approval, Tencent writes, storage, scheduling, or Feishu.
+本模块只转换 pandas DataFrame,不感知策略、审批、腾讯写操作、存储、调度或飞书。
 """
 
 from __future__ import annotations
@@ -352,7 +351,7 @@ def compute_roi_summary(
     *,
     fission_parameters: FissionMultiplierParameters,
 ) -> tuple[pd.DataFrame, list[str]]:
-    """Return three-day entity ROI snapshots and normalized dates."""
+    """返回三日实体 ROI 快照及标准化日期。"""
 
     dates = _expected_three_days(expected_dates)
     daily = prepare_daily_metrics(raw_daily, fission_parameters)

+ 3 - 4
examples/auto_put_ad_mini/roi_control/policy.py

@@ -1,4 +1,4 @@
-"""Translate versioned ROI metrics into auditable recommendations and actions."""
+"""将版本化 ROI 指标转换为可审计的建议与动作。"""
 
 from __future__ import annotations
 
@@ -68,10 +68,9 @@ def annotate_execution(
     managed_account_ids: set[int],
     run_id: str,
 ) -> tuple[pd.DataFrame, list[dict[str, Any]], list[dict[str, Any]]]:
-    """Keep policy recommendations separate from Tencent eligibility.
+    """保持策略建议与腾讯执行资格相互独立。
 
-    Returns annotated summary, complete metric snapshots, and deduplicated
-    executable action items.
+    返回带注释的汇总、完整指标快照和去重后的可执行动作项。
     """
 
     annotated = summary.copy()

+ 2 - 2
examples/auto_put_ad_mini/roi_control/reporting.py

@@ -1,4 +1,4 @@
-"""Three-day ROI Excel report generation with hidden audit columns."""
+"""生成包含隐藏审计列的三日 ROI Excel 报表。"""
 
 from __future__ import annotations
 
@@ -671,7 +671,7 @@ def write_agency_workbooks(
     report_date: str,
     agency_names: set[str] | None = None,
 ) -> list[dict[str, object]]:
-    """Create one miniapp control-advice workbook per agency."""
+    """为每个代理商生成一份小程序调控建议报表。"""
 
     miniapp = rows[
         rows["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])

+ 4 - 4
examples/auto_put_ad_mini/roi_control/repository.py

@@ -1,4 +1,4 @@
-"""MySQL persistence for versioned ROI runs, snapshots, and action audit."""
+"""版本化 ROI 运行、快照和动作审计的 MySQL 持久化。"""
 
 from __future__ import annotations
 
@@ -49,7 +49,7 @@ def publish_fission_parameter_release(
     *,
     published_by: str,
 ) -> bool:
-    """Publish an immutable parameter version; return False if already present."""
+    """发布不可变参数版本;版本已存在时返回 False。"""
 
     value_rows = list(values)
     connection = connect()
@@ -203,7 +203,7 @@ def load_run(run_id: str) -> dict[str, Any] | None:
 
 
 def load_formal_run_for_end_date(end_date: date) -> dict[str, Any] | None:
-    """Return an existing formal run for a data date, excluding internal tests."""
+    """返回指定数据日期已有的正式运行记录,不包含内部测试。"""
 
     connection = connect()
     try:
@@ -384,7 +384,7 @@ def mark_failed(run_id: str, error_message: str) -> None:
 
 
 def upsert_agency_delivery(record: dict[str, Any]) -> dict[str, Any]:
-    """Create or refresh one idempotent agency delivery record."""
+    """创建或刷新一条幂等的代理商发送记录。"""
 
     connection = connect()
     try:

+ 4 - 6
examples/auto_put_ad_mini/roi_control/rules.py

@@ -1,4 +1,4 @@
-"""Versioned three-day policy with a latest-day creative stop supplement."""
+"""版本化三日策略,并补充最新日创意关停规则。"""
 
 from __future__ import annotations
 
@@ -82,7 +82,7 @@ def threshold_eligibility_mask(
     config: RuleConfig,
     expected_dates: list[str],
 ) -> pd.Series:
-    """Return formal creative-level samples for channel-independent lines."""
+    """返回渠道独立阈值使用的正式创意级样本。"""
 
     return entity_eligibility_mask(
         summary, ENTITY_SELF, config, expected_dates
@@ -379,10 +379,8 @@ def _allocate_self_stop_budget(
                 selected_cost = proposed
             break
 
-    # The normal selection stays within the 5.5% soft upper bound. If discrete
-    # creative costs still leave the result below the 4.5% soft lower bound,
-    # allow the next uninterrupted ROI prefix to approach 5%, but never exceed
-    # the 6% absolute cap.
+    # 常规选择保持在 5.5% 软上限内。若离散创意消耗使结果仍低于 4.5% 软下限,
+    # 允许下一个连续 ROI 前缀接近 5%,但绝不能超过 6% 绝对上限。
     if selected_cost < soft_lower_cost:
         for rule in (STOP_RULE_ONE_DAY, STOP_RULE_THREE_DAY):
             for index in ordered[rule]:

+ 3 - 3
examples/auto_put_ad_mini/roi_control/service.py

@@ -1,4 +1,4 @@
-"""Orchestrate one idempotent daily ROI metric and approval batch."""
+"""编排一次幂等的日级 ROI 指标计算与审批批次。"""
 
 from __future__ import annotations
 
@@ -110,7 +110,7 @@ def _annotate_current_creative_status(
     rows,
     tencent: TencentClient | None = None,
 ):
-    """Read current Tencent status only for creative-level stop decisions."""
+    """仅为创意级关停建议读取腾讯当前状态。"""
 
     result = rows.copy()
     result["当前创意状态"] = ""
@@ -224,7 +224,7 @@ def run_daily_roi(
     source_revision: str | None = None,
     internal_test: bool = False,
 ) -> dict[str, Any]:
-    """Compute, snapshot, report, and optionally publish one ROI batch."""
+    """计算、保存快照、生成报表,并按配置发布一个 ROI 批次。"""
 
     config = RoiConfig.from_env()
     agency_webhook_config = AgencyWebhookConfig.from_env()

+ 1 - 1
examples/auto_put_ad_mini/roi_control/sheet_approval.py

@@ -1,4 +1,4 @@
-"""Poll editable ROI sheets and execute approved database action items."""
+"""轮询可编辑 ROI 表格,并执行数据库中已审批的动作项。"""
 
 from __future__ import annotations
 

+ 4 - 4
examples/auto_put_ad_mini/run_daily_roi.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Run one versioned daily ROI computation and optional Feishu approval batch."""
+"""执行一次版本化日级 ROI 计算,并按配置生成飞书审批批次。"""
 
 from __future__ import annotations
 
@@ -29,7 +29,7 @@ from roi_control.service import run_daily_roi  # noqa: E402
 
 def parse_args() -> argparse.Namespace:
     parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument("--end-date", help="ODPS end partition in YYYYMMDD")
+    parser.add_argument("--end-date", help="ODPS 截止分区,格式 YYYYMMDD")
     parser.add_argument(
         "--output-dir",
         type=Path,
@@ -37,13 +37,13 @@ def parse_args() -> argparse.Namespace:
     )
     parser.add_argument(
         "--source-revision",
-        help="Stable revision label for corrected upstream data",
+        help="上游数据修订后的稳定版本标识",
     )
     parser.add_argument("--send-feishu", action="store_true")
     parser.add_argument(
         "--internal-test",
         action="store_true",
-        help="Run an isolated full calculation and notify only the internal webhook",
+        help="执行隔离的完整计算,并且只通知内部 webhook",
     )
     return parser.parse_args()
 

+ 94 - 23
examples/auto_put_ad_mini/run_daily_service.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Schedule daily creation and two-hour creative review scans."""
+"""广告创建、创意审核、未起量清理和日级 ROI 的生产调度服务。"""
 
 from __future__ import annotations
 
@@ -7,6 +7,7 @@ import argparse
 import json
 import logging
 import os
+import secrets
 import subprocess
 import sys
 import time
@@ -14,6 +15,11 @@ from datetime import datetime, timedelta
 from pathlib import Path
 
 from apscheduler.schedulers.blocking import BlockingScheduler
+from apscheduler.events import (
+    EVENT_JOB_ERROR,
+    EVENT_JOB_MAX_INSTANCES,
+    EVENT_JOB_MISSED,
+)
 from apscheduler.triggers.cron import CronTrigger
 from apscheduler.triggers.interval import IntervalTrigger
 from dotenv import load_dotenv
@@ -49,6 +55,7 @@ from roi_control.feishu import RoiFeishuPublisher  # noqa: E402
 from roi_control.odps_client import ODPSClient  # noqa: E402
 from roi_control.repository import load_formal_run_for_end_date  # noqa: E402
 from tools.delivery_config import validate_region_dictionary  # noqa: E402
+from logging_setup import get_trace_id  # noqa: E402
 
 
 logger = logging.getLogger("auto_put_ad_mini.daily_service")
@@ -61,6 +68,16 @@ def _env_flag(name: str, default: bool) -> bool:
     return raw.strip().lower() in {"1", "true", "yes", "on"}
 
 
+def _new_child_trace_id(script_name: str) -> str:
+    """为子任务生成可关联父进程、脚本名和启动时刻的 trace_id。"""
+    parent_trace_id = get_trace_id() or "parent-unknown"
+    task_name = Path(script_name).stem.replace("-", "_")
+    return (
+        f"{parent_trace_id}.{task_name}."
+        f"{time.time_ns()}.{secrets.token_hex(4)}"
+    )
+
+
 def _notify_roi_failure(
     *,
     extra_args: list[str] | None,
@@ -83,8 +100,10 @@ def _notify_roi_failure(
                 "本次已停止,不会生成或发送不完整报表。"
             ),
         )
-    except Exception as exc:
-        logger.error("Failed to send ROI failure alert: %s", exc)
+    except Exception:
+        logger.exception(
+            "event=task_failure_alert task=daily_roi result=failed"
+        )
     finally:
         if publisher is not None:
             publisher.close()
@@ -95,15 +114,17 @@ def _notify_creative_cleanup_failure(error: str) -> None:
     try:
         publisher = RoiFeishuPublisher(require_chat_ids=False)
         publisher.send_service_alert(
-            title="拒审创意自动删除任务失败",
+            title="创意审核异常及未起量清理任务失败",
             content=(
                 f"错误:`{error[:500]}`\n"
-                "请检查审核扫描、腾讯回读、数据库审计和代理通知日志。"
+                "请检查审核/未起量扫描、腾讯回读、数据库审计和内部通知日志。"
             ),
             chat_id=os.getenv("CREATIVE_CLEANUP_FAILURE_FEISHU_CHAT_ID", ""),
         )
-    except Exception as exc:
-        logger.error("Failed to send creative cleanup failure alert: %s", exc)
+    except Exception:
+        logger.exception(
+            "event=task_failure_alert task=creative_cleanup result=failed"
+        )
     finally:
         if publisher is not None:
             publisher.close()
@@ -127,15 +148,17 @@ def _notify_roi_source_cutoff(
                 "当天已停止自动轮询,不会使用旧分区,也不会生成或补发ROI报表。"
             ),
         )
-    except Exception as exc:
-        logger.error("Failed to send ROI source cutoff alert: %s", exc)
+    except Exception:
+        logger.exception(
+            "event=task_failure_alert task=daily_roi_source_cutoff result=failed"
+        )
     finally:
         if publisher is not None:
             publisher.close()
 
 
 def sync_enabled_delivery_templates() -> int:
-    """Keep enabled DB templates aligned with the code's global delivery window."""
+    """将数据库中启用的投放模板同步为代码定义的全局投放时段。"""
     serialized = json.dumps(TIME_SERIES_DEFAULT)
     connection = get_connection()
     try:
@@ -159,6 +182,7 @@ def _run_script(
     lock_name: str,
     extra_args: list[str] | None = None,
 ) -> None:
+    """在数据库互斥锁内运行子脚本,并保留完整的父子任务追踪信息。"""
     task_args = extra_args or []
     started = time.monotonic()
     logger.info(
@@ -173,21 +197,30 @@ def _run_script(
                 script_name, lock_name,
             )
             return
+        child_trace_id = _new_child_trace_id(script_name)
         logger.info(
-            "event=scheduled_task task=%s stage=subprocess result=started lock=%s",
-            script_name, lock_name,
+            "event=scheduled_task task=%s stage=subprocess result=started "
+            "lock=%s child_trace_id=%s",
+            script_name,
+            lock_name,
+            child_trace_id,
         )
+        child_env = os.environ.copy()
+        child_env["AUTO_PUT_TRACE_ID"] = child_trace_id
         try:
             completed = subprocess.run(
                 [sys.executable, str(HERE / script_name), *task_args],
                 cwd=HERE,
+                env=child_env,
                 check=False,
             )
         except Exception as exc:
             logger.exception(
                 "event=scheduled_task task=%s stage=subprocess result=failed "
-                "duration_ms=%d",
-                script_name, int((time.monotonic() - started) * 1000),
+                "duration_ms=%d child_trace_id=%s",
+                script_name,
+                int((time.monotonic() - started) * 1000),
+                child_trace_id,
             )
             if script_name == "run_daily_roi.py":
                 _notify_roi_failure(extra_args=extra_args, error=str(exc))
@@ -197,9 +230,11 @@ def _run_script(
         if completed.returncode:
             logger.error(
                 "event=scheduled_task task=%s stage=subprocess result=failed "
-                "returncode=%d duration_ms=%d",
-                script_name, completed.returncode,
+                "returncode=%d duration_ms=%d child_trace_id=%s",
+                script_name,
+                completed.returncode,
                 int((time.monotonic() - started) * 1000),
+                child_trace_id,
             )
             if script_name == "run_daily_roi.py":
                 _notify_roi_failure(
@@ -211,12 +246,15 @@ def _run_script(
                     f"process exited with code {completed.returncode}"
                 )
             raise RuntimeError(
-                f"{script_name} exited with code {completed.returncode}"
+                f"{script_name} exited with code {completed.returncode} "
+                f"(child_trace_id={child_trace_id})"
             )
         logger.info(
             "event=scheduled_task task=%s stage=subprocess result=succeeded "
-            "returncode=0 duration_ms=%d",
-            script_name, int((time.monotonic() - started) * 1000),
+            "returncode=0 duration_ms=%d child_trace_id=%s",
+            script_name,
+            int((time.monotonic() - started) * 1000),
+            child_trace_id,
         )
 
 
@@ -232,7 +270,7 @@ def run_creation_once(
     account_ids: list[int],
     config_date: str | None = None,
 ) -> None:
-    """Run one locked creation pass for explicitly selected eligible accounts."""
+    """仅对显式指定且符合条件的账户执行一次带锁创建流程。"""
     extra_args: list[str] = []
     if config_date:
         extra_args.extend(["--config-date", config_date])
@@ -253,7 +291,7 @@ 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",
@@ -384,6 +422,35 @@ def run_internal_roi_test() -> None:
     )
 
 
+def _log_scheduler_failure(event: object) -> None:
+    """统一记录调度失败、错过执行和并发上限事件,便于按 job_id 追溯。"""
+
+    job_id = getattr(event, "job_id", "unknown")
+    scheduled_run_time = getattr(event, "scheduled_run_time", None)
+    if getattr(event, "code", None) == EVENT_JOB_ERROR:
+        logger.error(
+            "event=scheduler_job task=%s stage=execute result=failed "
+            "scheduled_run_time=%s exception=%r traceback=%s",
+            job_id,
+            scheduled_run_time,
+            getattr(event, "exception", None),
+            getattr(event, "traceback", None) or "",
+        )
+        return
+    reason = (
+        "max_instances"
+        if getattr(event, "code", None) == EVENT_JOB_MAX_INSTANCES
+        else "misfire"
+    )
+    logger.error(
+        "event=scheduler_job task=%s stage=dispatch result=skipped "
+        "reason=%s scheduled_run_time=%s",
+        job_id,
+        reason,
+        scheduled_run_time,
+    )
+
+
 def _positive_account_id(raw: str) -> int:
     try:
         account_id = int(raw)
@@ -440,6 +507,10 @@ def main(argv: list[str] | None = None) -> None:
         logger.info("Synchronized %d enabled delivery template(s)", changed)
 
     scheduler = BlockingScheduler(timezone="Asia/Shanghai")
+    scheduler.add_listener(
+        _log_scheduler_failure,
+        EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_JOB_MAX_INSTANCES,
+    )
     if _env_flag("DAILY_CREATION_ENABLED", False):
         creation_hour = int(os.getenv("DAILY_CREATION_HOUR", "10"))
         creation_minute = int(os.getenv("DAILY_CREATION_MINUTE", "30"))
@@ -528,7 +599,7 @@ def main(argv: list[str] | None = None) -> None:
                 timezone="Asia/Shanghai",
             ),
             id="rejected_creative_cleanup",
-            name="拒审创意自动删除与代理通知",
+            name="创意审核异常与长期未起量清理",
             max_instances=1,
             coalesce=True,
             misfire_grace_time=int(
@@ -570,7 +641,7 @@ def main(argv: list[str] | None = None) -> None:
         scheduler.add_job(
             run_creative_rejection_cleanup,
             id="rejected_creative_cleanup_startup",
-            name="启动时拒审创意清理",
+            name="启动时创意异常与未起量清理",
         )
     if roi_config.daily_enabled and _env_flag("DAILY_ROI_RUN_ON_STARTUP", False):
         scheduler.add_job(

+ 3 - 3
examples/auto_put_ad_mini/scan_creative_reviews.py

@@ -1,7 +1,7 @@
-"""Scan Tencent official dynamic creative review results.
+"""扫描腾讯动态创意的官方审核结果。
 
-Run every 2 hours in production. This scanner only reads submitted creatives
-from DB and writes official review results/rejection facts back to DB.
+生产环境每 2 小时运行一次;只读取数据库中已提交的创意,并将官方审核结果
+及拒审事实回写数据库,不执行创意或广告删除。
 """
 
 import json

+ 1 - 1
examples/auto_put_ad_mini/seed_material_creative_patterns.py

@@ -1,4 +1,4 @@
-"""Seed initial DRAFT creative patterns for AI material planning."""
+"""为 AI 素材规划写入初始 DRAFT 创意模式。"""
 
 from __future__ import annotations
 

+ 2151 - 0
examples/auto_put_ad_mini/test_creative_performance_cleanup.py

@@ -0,0 +1,2151 @@
+import json
+import unittest
+import os
+from contextlib import contextmanager
+from datetime import date, datetime
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import Mock, patch
+from zoneinfo import ZoneInfo
+
+
+class CreativePerformanceRuleTests(unittest.TestCase):
+    def setUp(self):
+        from tools.creative_rejection_cleanup import performance_cleanup_config
+
+        self.config = {
+            "new_min_age_days": 5,
+            "new_max_age_days": 7,
+            "new_impressions_threshold": 100,
+            "old_daily_impressions_threshold": 100.0,
+            "window_days": 7,
+        }
+        # Keep an import-time reference so failures in the public config helper
+        # are caught by this focused test module too.
+        self.assertTrue(callable(performance_cleanup_config))
+
+    @staticmethod
+    def _creative(**overrides):
+        creative = {
+            "configured_status": "AD_STATUS_NORMAL",
+            "creative_set_approval_status": (
+                "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
+            ),
+        }
+        creative.update(overrides)
+        return creative
+
+    @staticmethod
+    def _ad(**overrides):
+        ad = {"configured_status": "AD_STATUS_NORMAL"}
+        ad.update(overrides)
+        return ad
+
+    def _action(self, *, age_days, impressions, cost_fen=0, **overrides):
+        from tools.creative_rejection_cleanup import (
+            determine_performance_cleanup_action,
+        )
+
+        as_of = date(2026, 8, 19)
+        created = datetime(
+            2026,
+            8,
+            19 - age_days,
+            10,
+            tzinfo=ZoneInfo("Asia/Shanghai"),
+        )
+        return determine_performance_cleanup_action(
+            overrides.pop("creative", self._creative()),
+            overrides.pop("ad", self._ad()),
+            creative_created_at=created,
+            as_of_date=as_of,
+            impressions=impressions,
+            cost_fen=cost_fen,
+            metric_start_date=date(2026, 8, 12),
+            metric_end_date=date(2026, 8, 18),
+            config=self.config,
+            **overrides,
+        )
+
+    def test_new_creative_requires_strict_age_and_impression_boundaries(self):
+        from tools.creative_rejection_cleanup import PERFORMANCE_NEW_RULE
+
+        self.assertIsNone(self._action(age_days=5, impressions=0))
+        self.assertEqual(
+            self._action(age_days=6, impressions=99)["cleanup_rule_type"],
+            PERFORMANCE_NEW_RULE,
+        )
+        self.assertEqual(
+            self._action(age_days=7, impressions=0)["cleanup_rule_type"],
+            PERFORMANCE_NEW_RULE,
+        )
+        self.assertIsNone(self._action(age_days=6, impressions=100))
+        self.assertIsNone(self._action(age_days=6, impressions=0, cost_fen=1))
+        self.assertIsNone(
+            self._action(
+                age_days=6,
+                impressions=0,
+                current_day_cost_fen=1,
+            )
+        )
+
+    def test_old_creative_matches_both_requested_strict_exposure_branches(self):
+        from tools.creative_rejection_cleanup import (
+            PERFORMANCE_OLD_HIGH_RULE,
+            PERFORMANCE_OLD_LOW_RULE,
+        )
+
+        self.assertEqual(
+            self._action(age_days=8, impressions=699)["cleanup_rule_type"],
+            PERFORMANCE_OLD_LOW_RULE,
+        )
+        self.assertEqual(
+            self._action(age_days=8, impressions=701)["cleanup_rule_type"],
+            PERFORMANCE_OLD_HIGH_RULE,
+        )
+        self.assertIsNone(self._action(age_days=8, impressions=700))
+        self.assertIsNone(self._action(age_days=8, impressions=701, cost_fen=1))
+        self.assertIsNone(
+            self._action(
+                age_days=8,
+                impressions=701,
+                current_day_cost_fen=1,
+            )
+        )
+
+    def test_stopped_or_unapproved_assets_are_never_performance_deleted(self):
+        self.assertIsNone(
+            self._action(
+                age_days=8,
+                impressions=0,
+                creative=self._creative(configured_status="AD_STATUS_SUSPEND"),
+            )
+        )
+        self.assertIsNone(
+            self._action(
+                age_days=8,
+                impressions=0,
+                ad=self._ad(configured_status="AD_STATUS_SUSPEND"),
+            )
+        )
+        self.assertIsNone(
+            self._action(
+                age_days=8,
+                impressions=0,
+                ad=self._ad(begin_date="2026-08-20"),
+            )
+        )
+        self.assertIsNone(
+            self._action(
+                age_days=8,
+                impressions=0,
+                creative=self._creative(
+                    creative_set_approval_status=(
+                        "CREATIVE_SET_APPROVAL_STATUS_PENDING"
+                    )
+                ),
+            )
+        )
+
+
+class CreativePerformanceMetricApiTests(unittest.TestCase):
+    def test_tencent_metric_query_sums_impressions_and_cost(self):
+        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,
+                        "view_count": 40,
+                        "cost": 0,
+                    },
+                    {
+                        "dynamic_creative_id": 3,
+                        "view_count": 59,
+                        "cost": 0,
+                    },
+                ],
+                "page_info": {"total_page": 1},
+            },
+        }
+        client = TencentClient()
+        client.session.get = Mock(return_value=response)
+        client._common_params = Mock(return_value={"access_token": "token"})
+
+        metrics = client.get_dynamic_creative_metrics(
+            1,
+            [3, 4],
+            date(2026, 8, 12),
+            date(2026, 8, 18),
+        )
+
+        self.assertEqual(
+            metrics,
+            {
+                3: {"impressions": 99, "cost_fen": 0},
+                4: {"impressions": 0, "cost_fen": 0},
+            },
+        )
+
+    def test_tencent_ad_metric_query_sums_three_days_and_keeps_zero_rows(self):
+        from tencent_client import TencentClient
+
+        response = Mock()
+        response.raise_for_status.return_value = None
+        response.json.return_value = {
+            "code": 0,
+            "data": {
+                "list": [
+                    {"adgroup_id": 2, "cost": 0, "view_count": 10},
+                    {"adgroup_id": 2, "cost": 0, "view_count": 20},
+                ],
+                "page_info": {"total_page": 1},
+            },
+        }
+        client = TencentClient()
+        client.session.get = Mock(return_value=response)
+        client._common_params = Mock(return_value={"access_token": "token"})
+
+        metrics = client.get_ad_metrics(
+            1,
+            [2, 4],
+            date(2026, 8, 16),
+            date(2026, 8, 18),
+        )
+
+        self.assertEqual(metrics[2]["cost_fen"], 0)
+        self.assertEqual(metrics[2]["impressions"], 30)
+        self.assertEqual(metrics[4]["cost_fen"], 0)
+
+
+class CreativePerformanceScopeTests(unittest.TestCase):
+    def test_account_metadata_uses_latest_active_account_row(self):
+        import pandas as pd
+
+        from tools.creative_rejection_cleanup import (
+            fetch_tencent_account_metadata,
+        )
+
+        client = Mock()
+        client.execute_sql.return_value = pd.DataFrame([
+            {
+                "account_id": "1",
+                "account_name": " 账户一 ",
+                "agent_name": " 代理商甲 ",
+            },
+            {
+                "account_id": "2",
+                "account_name": float("nan"),
+                "agent_name": float("nan"),
+            },
+        ])
+
+        metadata = fetch_tencent_account_metadata(client)
+
+        sql = " ".join(client.execute_sql.call_args.args[0].split())
+        self.assertIn("FROM loghubods.ad_put_tencent_account", sql)
+        self.assertIn("PARTITION BY account_id", sql)
+        self.assertLess(sql.index("ROW_NUMBER() OVER"), sql.index("WHERE rn=1"))
+        self.assertIn("WHERE rn=1 AND is_delete=0", sql)
+        self.assertEqual(
+            metadata[1],
+            {"account_name": "账户一", "agent_name": "代理商甲"},
+        )
+        self.assertEqual(
+            metadata[2],
+            {"account_name": "", "agent_name": ""},
+        )
+
+    def test_inventory_filters_is_delete_after_latest_row_selection(self):
+        import pandas as pd
+
+        from tools.creative_rejection_cleanup import (
+            fetch_active_creative_inventory,
+        )
+
+        client = Mock()
+        client.execute_sql.return_value = pd.DataFrame([{
+            "account_id": 1,
+            "ad_id": 2,
+            "creative_id": 3,
+            "creative_name": "创意三",
+            "creative_status": "NORMAL",
+            "create_time": "2026-08-13 10:00:00",
+            "update_time": "2026-08-18 10:00:00",
+        }])
+
+        inventory = fetch_active_creative_inventory(client)
+
+        sql = " ".join(client.execute_sql.call_args.args[0].split())
+        self.assertEqual(sql.count("is_delete=0"), 1)
+        self.assertIn(
+            "ROW_NUMBER() OVER ( PARTITION BY account_id, creative_id",
+            sql,
+        )
+        self.assertLess(sql.index("ROW_NUMBER() OVER"), sql.index("WHERE rn=1"))
+        self.assertEqual(inventory[0]["account_id"], 1)
+        self.assertEqual(inventory[0]["adgroup_id"], 2)
+        self.assertEqual(inventory[0]["creative_id"], 3)
+        self.assertEqual(inventory[0]["creative_status"], "NORMAL")
+        self.assertIsNotNone(inventory[0]["create_time"])
+
+    def test_scan_uses_only_odps_creatives_and_queries_today_separately(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [
+            {
+                "dynamic_creative_id": 3,
+                "adgroup_id": 2,
+                "configured_status": "AD_STATUS_NORMAL",
+                "creative_set_approval_status": (
+                    cleanup.CREATIVE_NORMAL_STATUS
+                ),
+                "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+            },
+            {
+                "dynamic_creative_id": 9,
+                "adgroup_id": 9,
+                "configured_status": "AD_STATUS_NORMAL",
+                "creative_set_approval_status": (
+                    cleanup.CREATIVE_NORMAL_STATUS
+                ),
+                "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+            },
+        ]
+        tencent.get_ads.return_value = [
+            {
+                "adgroup_id": 2,
+                "configured_status": "AD_STATUS_NORMAL",
+                "created_time": "2026-08-10 00:00:00",
+            },
+            {
+                "adgroup_id": 9,
+                "configured_status": "AD_STATUS_NORMAL",
+                "created_time": "2026-08-10 00:00:00",
+            },
+        ]
+        tencent.get_dynamic_creative_metrics.side_effect = [
+            {3: {"impressions": 99, "cost_fen": 0}},
+            {3: {"impressions": 1, "cost_fen": 5}},
+        ]
+        tencent.get_ad_metrics.side_effect = [
+            {2: {"impressions": 0, "cost_fen": 0}},
+            {2: {"impressions": 1, "cost_fen": 7}},
+        ]
+        sources = {
+            3: {
+                "account_id": 1,
+                "adgroup_id": 2,
+                "creative_id": 3,
+                "create_time": "2026-08-13 10:00:00",
+            },
+            4: {
+                "account_id": 1,
+                "adgroup_id": 4,
+                "creative_id": 4,
+                "create_time": "2026-08-13 10:00:00",
+            },
+        }
+
+        result = cleanup._scan_one_account(
+            {"account_id": 1},
+            tencent=tencent,
+            review_fetcher=Mock(),
+            spend_start_date=date(2026, 8, 16),
+            spend_end_date=date(2026, 8, 18),
+            review_enabled=False,
+            performance_enabled=True,
+            performance_source_creatives=sources,
+            performance_start_date=date(2026, 8, 12),
+            performance_end_date=date(2026, 8, 18),
+            current_day_date=date(2026, 8, 19),
+            ad_cleanup_enabled=True,
+            ad_metric_start_date=date(2026, 8, 16),
+            ad_metric_end_date=date(2026, 8, 18),
+        )
+
+        self.assertEqual(result[7][3]["current_day_cost_fen"], 5)
+        self.assertEqual(result[7][3]["impressions"], 100)
+        self.assertEqual(result[9][2]["current_day_cost_fen"], 7)
+        self.assertEqual(result[12]["missing_tencent_creatives"], 1)
+        creative_calls = tencent.get_dynamic_creative_metrics.call_args_list
+        self.assertEqual(creative_calls[0].args[1], [3])
+        self.assertEqual(
+            creative_calls[0].args[2:],
+            (date(2026, 8, 13), date(2026, 8, 18)),
+        )
+        self.assertEqual(
+            creative_calls[1].args[2:],
+            (date(2026, 8, 19), date(2026, 8, 19)),
+        )
+        ad_calls = tencent.get_ad_metrics.call_args_list
+        self.assertEqual(ad_calls[0].args[1], [2])
+        self.assertEqual(
+            ad_calls[1].args[2:],
+            (date(2026, 8, 19), date(2026, 8, 19)),
+        )
+
+    def test_review_failure_does_not_block_independent_ad_metric_scan(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [{
+            "dynamic_creative_id": 3,
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+            "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
+            "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+        }]
+        tencent.get_ads.return_value = [{
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }]
+        tencent.get_ad_metrics.return_value = {
+            2: {"impressions": 0, "cost_fen": 0}
+        }
+
+        result = cleanup._scan_one_account(
+            {"account_id": 1},
+            tencent=tencent,
+            review_fetcher=Mock(side_effect=RuntimeError("review unavailable")),
+            spend_start_date=date(2026, 8, 16),
+            spend_end_date=date(2026, 8, 18),
+            review_enabled=True,
+            performance_enabled=True,
+            performance_source_creatives={
+                3: {
+                    "account_id": 1,
+                    "adgroup_id": 2,
+                    "creative_id": 3,
+                    "create_time": "2026-08-13 10:00:00",
+                }
+            },
+            performance_start_date=date(2026, 8, 12),
+            performance_end_date=date(2026, 8, 18),
+            performance_as_of=datetime(
+                2026,
+                8,
+                19,
+                11,
+                tzinfo=ZoneInfo("Asia/Shanghai"),
+            ),
+            current_day_date=date(2026, 8, 19),
+            ad_cleanup_enabled=True,
+            ad_metric_start_date=date(2026, 8, 16),
+            ad_metric_end_date=date(2026, 8, 18),
+        )
+
+        self.assertEqual(result[11], "review unavailable")
+        self.assertIsNone(result[10])
+        self.assertIn(2, result[9])
+        tencent.get_dynamic_creative_metrics.assert_not_called()
+
+    def test_retry_item_that_left_active_inventory_is_skipped_not_deferred(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        creative_item = {
+            "account_id": 1,
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "cleanup_action": cleanup.DELETE_CREATIVE,
+            "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+        }
+        ad_item = {
+            "account_id": 1,
+            "adgroup_id": 2,
+            "dynamic_creative_id": -2,
+            "cleanup_action": cleanup.DELETE_AD,
+            "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
+        }
+
+        creative_result = cleanup.cleanup_precondition_failure(
+            creative_item,
+            set(),
+            {},
+            performance_scanned_accounts={1},
+            performance_scope_loaded=True,
+            performance_source_keys=set(),
+        )
+        ad_result = cleanup.cleanup_precondition_failure(
+            ad_item,
+            set(),
+            {},
+            ad_scanned_accounts={1},
+            performance_scope_loaded=True,
+            performance_source_ad_keys=set(),
+        )
+        scope_error_result = cleanup.cleanup_precondition_failure(
+            creative_item,
+            set(),
+            {},
+            performance_scope_loaded=False,
+        )
+
+        self.assertEqual(
+            creative_result[0], "SKIPPED_REVIEW_NOT_RECONFIRMED"
+        )
+        self.assertEqual(ad_result[0], "SKIPPED_REVIEW_NOT_RECONFIRMED")
+        self.assertEqual(scope_error_result[0], "DEFERRED")
+
+    def test_ad_uses_odps_ad_id_even_when_tencent_creative_mapping_differs(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [{
+            "dynamic_creative_id": 3,
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+            "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
+            "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+        }]
+        tencent.get_ads.return_value = [{
+            "adgroup_id": 5,
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }]
+        tencent.get_ad_metrics.return_value = {
+            5: {"impressions": 0, "cost_fen": 0}
+        }
+
+        result = cleanup._scan_one_account(
+            {"account_id": 1},
+            tencent=tencent,
+            review_fetcher=Mock(),
+            spend_start_date=date(2026, 8, 16),
+            spend_end_date=date(2026, 8, 18),
+            review_enabled=False,
+            performance_source_creatives={
+                3: {
+                    "account_id": 1,
+                    "adgroup_id": 5,
+                    "creative_id": 3,
+                    "create_time": "2026-08-13 10:00:00",
+                }
+            },
+            current_day_date=date(2026, 8, 19),
+            ad_cleanup_enabled=True,
+            ad_metric_start_date=date(2026, 8, 16),
+            ad_metric_end_date=date(2026, 8, 18),
+        )
+
+        self.assertIn(5, result[9])
+        self.assertEqual(result[12]["ad_mismatches"], 1)
+        self.assertEqual(tencent.get_ad_metrics.call_count, 2)
+        self.assertEqual(tencent.get_ad_metrics.call_args_list[0].args[1], [5])
+
+    def test_creative_list_failure_does_not_block_ad_scan(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        tencent = Mock()
+        tencent.get_dynamic_creatives.side_effect = RuntimeError(
+            "creative endpoint unavailable"
+        )
+        tencent.get_ads.return_value = [{
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }]
+        tencent.get_ad_metrics.return_value = {
+            2: {"impressions": 0, "cost_fen": 0}
+        }
+
+        result = cleanup._scan_one_account(
+            {"account_id": 1},
+            tencent=tencent,
+            review_fetcher=Mock(),
+            spend_start_date=date(2026, 8, 16),
+            spend_end_date=date(2026, 8, 18),
+            review_enabled=True,
+            performance_enabled=True,
+            performance_source_creatives={
+                3: {
+                    "account_id": 1,
+                    "adgroup_id": 2,
+                    "creative_id": 3,
+                    "create_time": "2026-08-13 10:00:00",
+                }
+            },
+            performance_start_date=date(2026, 8, 12),
+            performance_end_date=date(2026, 8, 18),
+            current_day_date=date(2026, 8, 19),
+            ad_cleanup_enabled=True,
+            ad_metric_start_date=date(2026, 8, 16),
+            ad_metric_end_date=date(2026, 8, 18),
+        )
+
+        self.assertIn("creative endpoint unavailable", result[11])
+        self.assertIn("creative endpoint unavailable", result[8])
+        self.assertIsNone(result[10])
+        self.assertIn(2, result[9])
+        tencent.get_ads.assert_called_once_with(1)
+
+
+class AdPerformanceRuleTests(unittest.TestCase):
+    def test_active_ad_with_zero_three_day_spend_is_deleted(self):
+        from tools.creative_rejection_cleanup import (
+            DELETE_AD,
+            PERFORMANCE_AD_ZERO_SPEND_RULE,
+            determine_ad_performance_cleanup_action,
+        )
+
+        action = determine_ad_performance_cleanup_action(
+            {
+                "configured_status": "AD_STATUS_NORMAL",
+                "begin_date": "2026-08-01",
+                "end_date": "2026-08-31",
+                "created_time": "2026-08-10 00:00:00",
+            },
+            as_of_date=date(2026, 8, 19),
+            cost_fen=0,
+            metric_start_date=date(2026, 8, 16),
+            metric_end_date=date(2026, 8, 18),
+            window_days=3,
+        )
+
+        self.assertEqual(action["cleanup_action"], DELETE_AD)
+        self.assertEqual(
+            action["cleanup_rule_type"],
+            PERFORMANCE_AD_ZERO_SPEND_RULE,
+        )
+
+    def test_ad_with_any_spend_or_inactive_status_is_not_deleted(self):
+        from tools.creative_rejection_cleanup import (
+            determine_ad_performance_cleanup_action,
+        )
+
+        common = {
+            "as_of_date": date(2026, 8, 19),
+            "metric_start_date": date(2026, 8, 16),
+            "metric_end_date": date(2026, 8, 18),
+            "window_days": 3,
+        }
+        self.assertIsNone(
+            determine_ad_performance_cleanup_action(
+                {
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "created_time": "2026-08-10 00:00:00",
+                },
+                cost_fen=1,
+                **common,
+            )
+        )
+        self.assertIsNone(
+            determine_ad_performance_cleanup_action(
+                {
+                    "configured_status": "AD_STATUS_SUSPEND",
+                    "created_time": "2026-08-10 00:00:00",
+                },
+                cost_fen=0,
+                **common,
+            )
+        )
+        self.assertIsNone(
+            determine_ad_performance_cleanup_action(
+                {
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "created_time": "2026-08-10 00:00:00",
+                },
+                cost_fen=0,
+                current_day_cost_fen=1,
+                **common,
+            )
+        )
+
+    def test_ad_requires_more_than_five_complete_days_and_created_time(self):
+        from tools.creative_rejection_cleanup import (
+            determine_ad_performance_cleanup_action,
+        )
+
+        as_of = datetime(
+            2026,
+            8,
+            19,
+            11,
+            tzinfo=ZoneInfo("Asia/Shanghai"),
+        )
+        common = {
+            "configured_status": "AD_STATUS_NORMAL",
+            "begin_date": "2026-08-01",
+            "end_date": "2026-08-31",
+        }
+        kwargs = {
+            "as_of_date": as_of,
+            "cost_fen": 0,
+            "metric_start_date": date(2026, 8, 16),
+            "metric_end_date": date(2026, 8, 18),
+            "window_days": 3,
+            "min_age_days": 5,
+        }
+
+        self.assertIsNone(
+            determine_ad_performance_cleanup_action(
+                {**common, "created_time": "2026-08-14 11:00:00"},
+                **kwargs,
+            )
+        )
+        self.assertIsNone(
+            determine_ad_performance_cleanup_action(
+                {**common, "created_time": "2026-08-13 11:01:00"},
+                **kwargs,
+            )
+        )
+        self.assertIsNotNone(
+            determine_ad_performance_cleanup_action(
+                {**common, "created_time": "2026-08-13 11:00:00"},
+                **kwargs,
+            )
+        )
+        self.assertIsNone(
+            determine_ad_performance_cleanup_action(common, **kwargs)
+        )
+
+    def test_tencent_created_time_epoch_seconds_are_parsed_in_shanghai(self):
+        from tools.creative_rejection_cleanup import (
+            _as_shanghai_datetime,
+            determine_ad_performance_cleanup_action,
+        )
+
+        created = datetime(
+            2026,
+            8,
+            10,
+            9,
+            30,
+            tzinfo=ZoneInfo("Asia/Shanghai"),
+        )
+        epoch_seconds = int(created.timestamp())
+        self.assertEqual(_as_shanghai_datetime(epoch_seconds), created)
+        self.assertEqual(_as_shanghai_datetime(epoch_seconds * 1000), created)
+        self.assertEqual(
+            _as_shanghai_datetime(20260810093000),
+            created,
+        )
+        self.assertIsNone(_as_shanghai_datetime(2))
+
+        action = determine_ad_performance_cleanup_action(
+            {
+                "configured_status": "AD_STATUS_NORMAL",
+                "created_time": epoch_seconds,
+            },
+            as_of_date=datetime(
+                2026,
+                8,
+                19,
+                11,
+                tzinfo=ZoneInfo("Asia/Shanghai"),
+            ),
+            cost_fen=0,
+            metric_start_date=date(2026, 8, 16),
+            metric_end_date=date(2026, 8, 18),
+        )
+        self.assertEqual(action["creative_created_at"], created)
+        self.assertNotEqual(action["creative_created_at"].year, 1970)
+
+    def test_tencent_ad_fields_include_creation_time_and_deleted_flag(self):
+        from tencent_client import AD_FIELDS
+
+        self.assertIn("created_time", AD_FIELDS)
+        self.assertIn("is_deleted", AD_FIELDS)
+
+    def test_explicit_deleted_ad_states_are_not_cleanup_candidates(self):
+        from tools.creative_rejection_cleanup import (
+            determine_ad_performance_cleanup_action,
+        )
+
+        common = {
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }
+        kwargs = {
+            "as_of_date": date(2026, 8, 19),
+            "cost_fen": 0,
+            "metric_start_date": date(2026, 8, 16),
+            "metric_end_date": date(2026, 8, 18),
+            "window_days": 3,
+        }
+        for deleted_state in (
+            {"is_deleted": True},
+            {"is_delete": 1},
+            {"system_status": "ADGROUP_STATUS_DELETED"},
+            {"system_status": "SMART_ADGROUP_STATUS_DELETED"},
+        ):
+            with self.subTest(deleted_state=deleted_state):
+                self.assertIsNone(
+                    determine_ad_performance_cleanup_action(
+                        {**common, **deleted_state},
+                        **kwargs,
+                    )
+                )
+
+    def test_get_ad_retries_exact_id_in_deleted_scope(self):
+        from tencent_client import TencentClient
+
+        active_response = Mock()
+        active_response.raise_for_status.return_value = None
+        active_response.json.return_value = {
+            "code": 0,
+            "data": {"list": []},
+        }
+        deleted_response = Mock()
+        deleted_response.raise_for_status.return_value = None
+        deleted_response.json.return_value = {
+            "code": 0,
+            "data": {
+                "list": [{
+                    "adgroup_id": 2,
+                    "is_deleted": True,
+                    "system_status": "ADGROUP_STATUS_DELETED",
+                }]
+            },
+        }
+        client = TencentClient()
+        client.session.get = Mock(
+            side_effect=[active_response, deleted_response]
+        )
+        client._common_params = Mock(return_value={"access_token": "token"})
+
+        result = client.get_ad(1, 2)
+
+        self.assertTrue(result["is_deleted"])
+        self.assertEqual(client.session.get.call_count, 2)
+        first_params = client.session.get.call_args_list[0].kwargs["params"]
+        second_params = client.session.get.call_args_list[1].kwargs["params"]
+        self.assertEqual(
+            json.loads(first_params["filtering"])[0]["values"],
+            ["2"],
+        )
+        self.assertNotIn("is_deleted", first_params)
+        self.assertIs(second_params["is_deleted"], True)
+
+    def test_cleanup_day_guard_rejects_a_batch_that_crossed_midnight(self):
+        from tools.creative_rejection_cleanup import _is_current_cleanup_day
+
+        self.assertTrue(
+            _is_current_cleanup_day(
+                date(2026, 8, 19),
+                datetime(
+                    2026,
+                    8,
+                    19,
+                    23,
+                    59,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+            )
+        )
+        self.assertFalse(
+            _is_current_cleanup_day(
+                date(2026, 8, 19),
+                datetime(
+                    2026,
+                    8,
+                    20,
+                    0,
+                    1,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+            )
+        )
+
+    def test_tencent_ad_delete_uses_delete_endpoint_and_readback(self):
+        from tencent_client import TencentClient
+
+        response = Mock(status_code=200, text="ok", headers={})
+        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={"access_token": "token"})
+        client._user_token = Mock(return_value="user-token")
+        client.get_ad = Mock(
+            return_value={
+                "configured_status": "AD_STATUS_NORMAL",
+                "system_status": "ADGROUP_STATUS_DELETED",
+                "is_deleted": False,
+            }
+        )
+
+        result = client.delete_ad(1, 2)
+
+        self.assertEqual(result["system_status"], "ADGROUP_STATUS_DELETED")
+        request = client.session.post.call_args
+        self.assertTrue(request.args[0].endswith("/adgroups/delete"))
+        self.assertEqual(request.kwargs["json"]["adgroup_id"], 2)
+
+    def test_tencent_ad_delete_accepts_is_deleted_flag(self):
+        from tencent_client import TencentClient
+
+        response = Mock(status_code=200, text="ok", headers={})
+        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={"access_token": "token"})
+        client._user_token = Mock(return_value="user-token")
+        client.get_ad = Mock(
+            return_value={
+                "configured_status": "AD_STATUS_NORMAL",
+                "system_status": "ADGROUP_STATUS_ACTIVE",
+                "is_deleted": True,
+            }
+        )
+
+        result = client.delete_ad(1, 2)
+
+        self.assertTrue(result["is_deleted"])
+
+    def test_tencent_ad_delete_does_not_infer_deletion_from_missing_row(self):
+        from tencent_client import PostWriteVerificationError, TencentClient
+
+        response = Mock(status_code=200, text="ok", headers={})
+        response.raise_for_status.return_value = None
+        response.json.return_value = {"code": 0, "data": {}}
+        client = TencentClient()
+        client.verify_attempts = 1
+        client.session.post = Mock(return_value=response)
+        client._common_params = Mock(return_value={"access_token": "token"})
+        client._user_token = Mock(return_value="user-token")
+        client.get_ad = Mock(
+            side_effect=RuntimeError(
+                "Ad not found after update: account=1 adgroup=2"
+            )
+        )
+
+        with self.assertRaises(PostWriteVerificationError):
+            client.delete_ad(1, 2)
+
+
+class CreativePerformanceNotificationTests(unittest.TestCase):
+    def test_preview_notification_excludes_candidates_not_confirmed_this_run(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        current_row = {
+            "id": 1,
+            "check_date": date(2026, 8, 20),
+            "account_id": 1,
+            "dynamic_creative_id": 3,
+            "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+            "operator_notified_at": datetime(2026, 8, 20, 11),
+        }
+        current_ad_row = {
+            "id": 2,
+            "check_date": date(2026, 8, 20),
+            "account_id": 1,
+            "dynamic_creative_id": -2,
+            "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
+            "creative_created_at": datetime(2026, 8, 10, 10),
+            "operator_notified_at": None,
+        }
+        stale_bad_timestamp_row = {
+            **current_ad_row,
+            "id": 3,
+            "dynamic_creative_id": -4,
+            "creative_created_at": datetime(1970, 1, 1, 0, 0, 2),
+        }
+
+        selected = cleanup.filter_preview_notification_rows(
+            [current_row, current_ad_row, stale_bad_timestamp_row],
+            check_date=date(2026, 8, 20),
+            confirmed_actions={
+                (1, 3): {
+                    "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+                },
+                (1, -2): {
+                    "cleanup_rule_type": (
+                        cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE
+                    ),
+                }
+            },
+            force_notification=True,
+        )
+
+        self.assertEqual([row["id"] for row in selected], [1, 2])
+        self.assertIsNone(selected[0]["operator_notified_at"])
+
+    def test_performance_rows_only_enter_the_separate_internal_delivery(self):
+        from tools.creative_rejection_cleanup import (
+            PERFORMANCE_AD_ZERO_SPEND_RULE,
+            PERFORMANCE_NEW_RULE,
+            REVIEW_DENIED_RULE,
+            split_notification_rows,
+        )
+
+        review_row = {
+            "id": 1,
+            "agency_name": "代理一",
+            "cleanup_rule_type": REVIEW_DENIED_RULE,
+            "agency_notified_at": None,
+            "operator_notified_at": None,
+        }
+        performance_row = {
+            "id": 2,
+            "agency_name": "",
+            "cleanup_rule_type": PERFORMANCE_NEW_RULE,
+            "agency_notified_at": datetime(2026, 8, 19, 10),
+            "operator_notified_at": None,
+        }
+        ad_row = {
+            "id": 3,
+            "agency_name": "",
+            "cleanup_rule_type": PERFORMANCE_AD_ZERO_SPEND_RULE,
+            "agency_notified_at": datetime(2026, 8, 19, 10),
+            "operator_notified_at": None,
+        }
+
+        agency, review_internal, performance_internal = split_notification_rows(
+            [review_row, performance_row, ad_row]
+        )
+
+        self.assertEqual(agency, [review_row])
+        self.assertEqual(review_internal, [review_row])
+        self.assertEqual(performance_internal, [performance_row, ad_row])
+        legacy_agency_rows = [
+            row
+            for row in [review_row, performance_row, ad_row]
+            if row.get("agency_notified_at") is None
+            and str(row.get("agency_name") or "").strip()
+        ]
+        self.assertEqual(legacy_agency_rows, [review_row])
+
+    def test_performance_upsert_atomically_suppresses_agency_delivery(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):
+            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": date(2026, 8, 19),
+                "cleanup_action": cleanup.DELETE_CREATIVE,
+                "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+                "component_ids": [],
+                "element_ids": [],
+                "recent_cost_fen": 0,
+                "cost_start_date": date(2026, 8, 12),
+                "cost_end_date": date(2026, 8, 18),
+                "action_reason": "未起量",
+                "reject_reason": "",
+                "review_result": {},
+                "pre_state": {},
+            })
+
+        insert_sql, insert_params = cursor.calls[0]
+        self.assertEqual(insert_params[3], "")
+        self.assertEqual(insert_params[10], cleanup.PERFORMANCE_NEW_RULE)
+        self.assertIsInstance(insert_params[-1], datetime)
+        self.assertIn("agency_notified_at", insert_sql)
+        self.assertIn("VALUES(cleanup_rule_type)", insert_sql)
+        normalized_insert_sql = " ".join(insert_sql.split())
+        terminal_guard = (
+            "WHEN cleanup_status IN ( "
+            "'DELETING','CREATIVE_DELETED','AD_DELETED' )"
+        )
+        self.assertEqual(normalized_insert_sql.count(terminal_guard), 2)
+        self.assertIn(f"{terminal_guard} THEN agency_name", normalized_insert_sql)
+        self.assertIn(
+            f"{terminal_guard} THEN agency_notified_at",
+            normalized_insert_sql,
+        )
+        self.assertIn("performance_suppression_at", cursor.calls[1][0])
+        self.assertIn("performance_suppression_at", cursor.calls[2][0])
+        extended_sql = cursor.calls[2][0]
+        self.assertIn(
+            "item.creative_created_at <=> incoming.creative_created_at",
+            extended_sql,
+        )
+        self.assertIn(
+            "item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name)",
+            extended_sql,
+        )
+
+    def test_schema_backfill_suppresses_existing_performance_rows(self):
+        from storage import _suppress_performance_agency_delivery
+
+        cursor = Mock()
+        _suppress_performance_agency_delivery(cursor)
+
+        sql = " ".join(cursor.execute.call_args.args[0].split())
+        self.assertIn("SET agency_name=''", sql)
+        self.assertIn(
+            "agency_notified_at=COALESCE(agency_notified_at, NOW())",
+            sql,
+        )
+        self.assertIn(
+            "WHERE LEFT(cleanup_rule_type, 12)='PERFORMANCE_'",
+            sql,
+        )
+
+    def test_schema_backfill_preserves_legacy_partial_review_rule(self):
+        from storage import _backfill_legacy_review_rule_types
+
+        cursor = Mock()
+        _backfill_legacy_review_rule_types(cursor)
+
+        sql = " ".join(cursor.execute.call_args.args[0].split())
+        self.assertIn("SET cleanup_rule_type='REVIEW_PARTIAL'", sql)
+        self.assertIn("cleanup_action='ALERT_ONLY'", sql)
+        self.assertIn(
+            "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL",
+            sql,
+        )
+
+    def test_performance_report_has_its_own_internal_title_and_sheet(self):
+        from openpyxl import load_workbook
+
+        from tools.creative_rejection_cleanup import (
+            DELETE_AD,
+            PERFORMANCE_AD_ZERO_SPEND_RULE,
+            PERFORMANCE_NEW_RULE,
+            write_performance_operator_summary,
+        )
+
+        row = {
+            "id": 2,
+            "agency_name": "代理一",
+            "account_id": 1,
+            "account_name": "账户一",
+            "agent_name": "代理商甲",
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "cleanup_action": "DELETE_CREATIVE",
+            "cleanup_rule_type": PERFORMANCE_NEW_RULE,
+            "cleanup_status": "DISCOVERED",
+            "reject_reason": "",
+            "review_result_json": "{}",
+            "pre_state_json": "{}",
+            "creative_age_days": 6,
+            "metric_impressions": 99,
+            "metric_daily_avg_impressions": 99 / 7,
+            "recent_cost_fen": 0,
+            "cost_start_date": "2026-08-12",
+            "cost_end_date": "2026-08-18",
+        }
+        ad_row = {
+            "id": 3,
+            "agency_name": "",
+            "account_id": 1,
+            "account_name": "账户一",
+            "agent_name": "代理商甲",
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "dynamic_creative_id": -2,
+            "dynamic_creative_name": "",
+            "cleanup_action": DELETE_AD,
+            "cleanup_rule_type": PERFORMANCE_AD_ZERO_SPEND_RULE,
+            "cleanup_status": "DISCOVERED",
+            "reject_reason": "",
+            "review_result_json": "{}",
+            "pre_state_json": '{"configured_status":"AD_STATUS_NORMAL"}',
+            "recent_cost_fen": 0,
+            "metric_window_days": 3,
+            "cost_start_date": "2026-08-16",
+            "cost_end_date": "2026-08-18",
+        }
+        with TemporaryDirectory() as directory:
+            report = write_performance_operator_summary(
+                [row, ad_row],
+                Path(directory),
+                "20260819",
+            )
+            workbook = load_workbook(report["report"])
+
+        self.assertEqual(
+            report["title"],
+            "长期未起量创意及广告清理汇总通知",
+        )
+        self.assertEqual(report["notification_kind"], "performance_internal")
+        self.assertLessEqual(len(str(report["report_version"])), 64)
+        self.assertEqual(report["creative_rows"], 1)
+        self.assertEqual(report["ad_rows"], 1)
+        self.assertIn("长期未起量清理", workbook.sheetnames)
+        sheet = workbook["长期未起量清理"]
+        self.assertEqual(sheet["A1"].value, "清理对象")
+        self.assertEqual(sheet["D1"].value, "代理商昵称")
+        self.assertEqual([sheet["D2"].value, sheet["D3"].value], ["代理商甲"] * 2)
+        self.assertEqual([sheet["A2"].value, sheet["A3"].value], ["创意", "广告"])
+
+
+class CreativePerformanceIntegrationTests(unittest.TestCase):
+    def setUp(self):
+        flags = patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
+            },
+            clear=False,
+        )
+        flags.start()
+        self.addCleanup(flags.stop)
+        account_metadata = patch(
+            "tools.creative_rejection_cleanup.fetch_tencent_account_metadata",
+            return_value={},
+        )
+        account_metadata.start()
+        self.addCleanup(account_metadata.stop)
+
+    def test_ad_candidate_is_created_after_creative_judgment(self):
+        import pandas as pd
+
+        from tools import creative_rejection_cleanup as cleanup
+
+        current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [
+            {
+                "dynamic_creative_id": 3,
+                "dynamic_creative_name": "创意三",
+                "adgroup_id": 2,
+                "configured_status": "AD_STATUS_NORMAL",
+                "creative_set_approval_status": (
+                    "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
+                ),
+                "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+            },
+            {
+                "dynamic_creative_id": 4,
+                "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",
+            },
+            {
+                "dynamic_creative_id": 5,
+                "dynamic_creative_name": "已删除创意五",
+                "adgroup_id": 2,
+                "configured_status": "AD_STATUS_DELETED",
+                "creative_set_approval_status": (
+                    "CREATIVE_SET_APPROVAL_STATUS_DENIED"
+                ),
+                "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
+            },
+        ]
+        tencent.get_ads.return_value = [{
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }]
+        tencent.get_dynamic_creative_metrics.side_effect = [
+            {3: {"impressions": 99, "cost_fen": 0}},
+            {3: {"impressions": 0, "cost_fen": 0}},
+        ]
+        tencent.get_ad_metrics.return_value = {
+            2: {"impressions": 0, "cost_fen": 0}
+        }
+        captured = []
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260818"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup,
+            "fetch_recent_spend_accounts",
+            return_value=[{"account_id": 1, "account_name": "账户一"}],
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[{
+                "account_id": 1,
+                "adgroup_id": 2,
+                "creative_id": 3,
+                "create_time": datetime(
+                    2026,
+                    8,
+                    13,
+                    10,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+            }],
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
+        ), patch.object(
+            cleanup,
+            "upsert_cleanup_candidate",
+            side_effect=lambda record: captured.append(record) or {"id": 1},
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[]
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=current,
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=Mock(return_value=[]),
+            )
+
+        self.assertEqual(
+            [record["cleanup_rule_type"] for record in captured],
+            [
+                cleanup.REVIEW_DENIED_RULE,
+                cleanup.PERFORMANCE_NEW_RULE,
+                cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
+            ],
+        )
+        self.assertEqual(captured[2]["dynamic_creative_id"], -2)
+        self.assertNotIn(5, [record["dynamic_creative_id"] for record in captured])
+        self.assertEqual(summary["rejected_discovered"], 1)
+        self.assertEqual(summary["ad_discovered"], 1)
+
+    def test_combined_task_discovers_performance_candidate_without_applying(self):
+        import pandas as pd
+
+        from tools import creative_rejection_cleanup as cleanup
+
+        current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [
+            {
+                "dynamic_creative_id": 3,
+                "dynamic_creative_name": "创意三",
+                "adgroup_id": 2,
+                "configured_status": "AD_STATUS_NORMAL",
+                "creative_set_approval_status": (
+                    "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
+                ),
+                "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+            }
+        ]
+        tencent.get_ads.return_value = [
+            {
+                "adgroup_id": 2,
+                "adgroup_name": "广告二",
+                "configured_status": "AD_STATUS_NORMAL",
+            }
+        ]
+        tencent.get_dynamic_creative_metrics.side_effect = [
+            {3: {"impressions": 99, "cost_fen": 0}},
+            {3: {"impressions": 0, "cost_fen": 0}},
+        ]
+        review_fetcher = Mock(return_value=[])
+        captured = []
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260818"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup,
+            "fetch_recent_spend_accounts",
+            return_value=[],
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[{
+                "account_id": 1,
+                "adgroup_id": 2,
+                "creative_id": 3,
+                "create_time": datetime(
+                    2026,
+                    8,
+                    13,
+                    10,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+            }],
+        ), patch.object(
+            cleanup,
+            "fetch_tencent_account_metadata",
+            return_value={
+                1: {
+                    "account_name": "大数据账户一",
+                    "agent_name": "代理商甲",
+                }
+            },
+        ), patch.object(
+            cleanup,
+            "prefetch_account_access_tokens",
+            return_value={1: "access-token"},
+        ), patch.object(
+            cleanup,
+            "upsert_cleanup_candidate",
+            side_effect=lambda record: captured.append(record) or {"id": 1},
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[]
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=current,
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=review_fetcher,
+            )
+
+        self.assertEqual(len(captured), 1)
+        self.assertEqual(
+            captured[0]["cleanup_rule_type"],
+            cleanup.PERFORMANCE_NEW_RULE,
+        )
+        self.assertEqual(captured[0]["account_name"], "大数据账户一")
+        self.assertEqual(captured[0]["agent_name"], "代理商甲")
+        self.assertEqual(summary["performance_discovered"], 1)
+        self.assertEqual(summary["rejected_discovered"], 0)
+        self.assertEqual(summary["review_account_ids"], [])
+        self.assertEqual(summary["performance_account_ids"], [1])
+        review_fetcher.assert_not_called()
+        tencent.delete_dynamic_creative.assert_not_called()
+
+    def test_current_day_spend_blocks_creative_and_ad_candidates(self):
+        import pandas as pd
+
+        from tools import creative_rejection_cleanup as cleanup
+
+        current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [{
+            "dynamic_creative_id": 3,
+            "dynamic_creative_name": "创意三",
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+            "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
+            "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+        }]
+        tencent.get_ads.return_value = [{
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }]
+        tencent.get_dynamic_creative_metrics.side_effect = [
+            {3: {"impressions": 99, "cost_fen": 0}},
+            {3: {"impressions": 1, "cost_fen": 1}},
+        ]
+        tencent.get_ad_metrics.side_effect = [
+            {2: {"impressions": 0, "cost_fen": 0}},
+            {2: {"impressions": 1, "cost_fen": 1}},
+        ]
+        captured = []
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260818"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[{
+                "account_id": 1,
+                "adgroup_id": 2,
+                "creative_id": 3,
+                "create_time": datetime(
+                    2026,
+                    8,
+                    13,
+                    10,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+            }],
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
+        ), patch.object(
+            cleanup,
+            "upsert_cleanup_candidate",
+            side_effect=lambda record: captured.append(record) or {"id": 1},
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[]
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=current,
+                tencent=tencent,
+                odps=Mock(),
+                review_fetcher=Mock(return_value=[]),
+            )
+
+        self.assertEqual(captured, [])
+        self.assertEqual(summary["performance_discovered"], 0)
+        self.assertEqual(summary["ad_discovered"], 0)
+        self.assertEqual(summary["current_day_metric_date"], date(2026, 8, 19))
+
+    def test_review_sources_and_creative_endpoint_can_fail_while_ad_continues(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
+        tencent = Mock()
+        tencent.get_dynamic_creatives.side_effect = RuntimeError(
+            "creative endpoint unavailable"
+        )
+        tencent.get_ads.return_value = [{
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }]
+        tencent.get_ad_metrics.return_value = {
+            2: {"impressions": 0, "cost_fen": 0}
+        }
+        captured = []
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup,
+            "resolve_end_date",
+            side_effect=RuntimeError("review source unavailable"),
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[{
+                "account_id": 1,
+                "adgroup_id": 2,
+                "creative_id": 3,
+                "create_time": datetime(
+                    2026,
+                    8,
+                    13,
+                    10,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+            }],
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
+        ), patch.object(
+            cleanup,
+            "upsert_cleanup_candidate",
+            side_effect=lambda record: captured.append(record) or {"id": 1},
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[]
+        ), patch.object(
+            cleanup, "load_unnotified_deleted_items", return_value=[]
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=current,
+                tencent=tencent,
+                odps=Mock(),
+            )
+
+        self.assertEqual(
+            [row["cleanup_rule_type"] for row in captured],
+            [cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE],
+        )
+        self.assertEqual(summary["ad_discovered"], 1)
+        self.assertEqual(summary["performance_discovered"], 1)
+        self.assertIn("review source unavailable", summary["review_scope_error"])
+        self.assertTrue(summary["scan_errors"])
+        tencent.get_dynamic_creative_metrics.assert_not_called()
+
+    def test_performance_apply_does_not_require_agency_route(self):
+        import pandas as pd
+
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
+        created_at = datetime(
+            2026,
+            8,
+            13,
+            10,
+            tzinfo=ZoneInfo("Asia/Shanghai"),
+        )
+        creative = {
+            "dynamic_creative_id": 3,
+            "dynamic_creative_name": "创意三",
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+            "creative_set_approval_status": (
+                "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
+            ),
+            "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+        }
+        ad = {
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "configured_status": "AD_STATUS_NORMAL",
+        }
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [creative]
+        tencent.get_ads.return_value = [ad]
+        tencent.get_dynamic_creative.return_value = creative
+        tencent.get_ad.return_value = ad
+        tencent.get_dynamic_creative_metrics.side_effect = [
+            {3: {"impressions": 99, "cost_fen": 0}},
+            {3: {"impressions": 0, "cost_fen": 0}},
+            {3: {"impressions": 99, "cost_fen": 0}},
+            {3: {"impressions": 0, "cost_fen": 0}},
+        ]
+        tencent.delete_dynamic_creative.return_value = {
+            "dynamic_creative_id": 3,
+            "configured_status": "AD_STATUS_DELETED",
+        }
+        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": cleanup.DELETE_CREATIVE,
+            "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+            "cleanup_status": "DISCOVERED",
+            "creative_created_at": created_at,
+        }
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260818"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[{
+                "account_id": 1,
+                "adgroup_id": 2,
+                "creative_id": 3,
+                "create_time": created_at,
+            }],
+        ), patch.object(
+            cleanup,
+            "prefetch_account_access_tokens",
+            return_value={1: "access-token"},
+        ), patch.object(
+            cleanup, "upsert_cleanup_candidate"
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup, "claim_cleanup_item", return_value=True
+        ), patch.object(
+            cleanup, "update_cleanup_item", return_value=True
+        ), 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=Mock(return_value=[]),
+            )
+
+        tencent.delete_dynamic_creative.assert_called_once_with(1, 3)
+        self.assertEqual(summary["deleted"], 1)
+        self.assertEqual(summary["deferred"], 0)
+
+    def test_zero_spend_ad_apply_runs_after_reconfirmation(self):
+        import pandas as pd
+
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
+        ad = {
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }
+        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_PENDING"
+            ),
+            "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+        }]
+        tencent.get_ads.return_value = [ad]
+        tencent.get_ad_metrics.return_value = {
+            2: {"impressions": 0, "cost_fen": 0}
+        }
+        tencent.get_ad.return_value = ad
+        tencent.delete_ad.return_value = {
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_DELETED",
+        }
+        retry_item = {
+            "id": 9,
+            "account_id": 1,
+            "account_name": "账户一",
+            "agency_name": "",
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "dynamic_creative_id": -2,
+            "dynamic_creative_name": "",
+            "cleanup_action": cleanup.DELETE_AD,
+            "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
+            "cleanup_status": "DISCOVERED",
+        }
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260818"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[{
+                "account_id": 1,
+                "adgroup_id": 2,
+                "creative_id": 3,
+                "create_time": datetime(
+                    2026,
+                    8,
+                    10,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+            }],
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
+        ), patch.object(
+            cleanup, "upsert_cleanup_candidate"
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
+        ), patch.object(
+            cleanup, "claim_cleanup_item", return_value=True
+        ), patch.object(
+            cleanup, "update_cleanup_item", return_value=True
+        ), 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=Mock(return_value=[]),
+            )
+
+        tencent.delete_ad.assert_called_once_with(1, 2)
+        self.assertEqual(summary["deleted"], 1)
+        self.assertEqual(summary["ad_discovered"], 1)
+
+    def test_write_lock_recheck_blocks_deletes_when_today_spend_appears(self):
+        import pandas as pd
+
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
+        created_at = datetime(
+            2026,
+            8,
+            13,
+            10,
+            tzinfo=ZoneInfo("Asia/Shanghai"),
+        )
+        creative = {
+            "dynamic_creative_id": 3,
+            "dynamic_creative_name": "创意三",
+            "adgroup_id": 2,
+            "configured_status": "AD_STATUS_NORMAL",
+            "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
+            "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
+        }
+        ad = {
+            "adgroup_id": 2,
+            "adgroup_name": "广告二",
+            "configured_status": "AD_STATUS_NORMAL",
+            "created_time": "2026-08-10 00:00:00",
+        }
+        tencent = Mock()
+        tencent.get_dynamic_creatives.return_value = [creative]
+        tencent.get_ads.return_value = [ad]
+        tencent.get_dynamic_creative.return_value = creative
+        tencent.get_ad.return_value = ad
+        tencent.get_dynamic_creative_metrics.side_effect = [
+            {3: {"impressions": 99, "cost_fen": 0}},
+            {3: {"impressions": 0, "cost_fen": 0}},
+            {3: {"impressions": 99, "cost_fen": 0}},
+            {3: {"impressions": 1, "cost_fen": 0}},
+        ]
+        tencent.get_ad_metrics.side_effect = [
+            {2: {"impressions": 0, "cost_fen": 0}},
+            {2: {"impressions": 0, "cost_fen": 0}},
+            {2: {"impressions": 0, "cost_fen": 0}},
+            {2: {"impressions": 0, "cost_fen": 1}},
+        ]
+        retry_items = [
+            {
+                "id": 7,
+                "account_id": 1,
+                "agency_name": "",
+                "adgroup_id": 2,
+                "dynamic_creative_id": 3,
+                "cleanup_action": cleanup.DELETE_CREATIVE,
+                "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+                "cleanup_status": "DISCOVERED",
+                "creative_created_at": created_at,
+            },
+            {
+                "id": 8,
+                "account_id": 1,
+                "agency_name": "",
+                "adgroup_id": 2,
+                "dynamic_creative_id": -2,
+                "cleanup_action": cleanup.DELETE_AD,
+                "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
+                "cleanup_status": "DISCOVERED",
+            },
+        ]
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "1",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260818"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[]
+        ), patch.object(
+            cleanup, "fetch_account_agency_fallbacks", return_value={}
+        ), patch.object(
+            cleanup,
+            "fetch_active_creative_inventory",
+            return_value=[{
+                "account_id": 1,
+                "adgroup_id": 2,
+                "creative_id": 3,
+                "create_time": created_at,
+            }],
+        ), patch.object(
+            cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
+        ), patch.object(
+            cleanup, "upsert_cleanup_candidate"
+        ), patch.object(
+            cleanup, "load_retryable_cleanup_items", return_value=retry_items
+        ), patch.object(
+            cleanup, "claim_cleanup_item", return_value=True
+        ), patch.object(
+            cleanup, "update_cleanup_item", return_value=True
+        ), 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=Mock(return_value=[]),
+            )
+
+        tencent.delete_dynamic_creative.assert_not_called()
+        tencent.delete_ad.assert_not_called()
+        self.assertEqual(summary["deleted"], 0)
+        self.assertEqual(tencent.get_dynamic_creative_metrics.call_count, 4)
+        self.assertEqual(tencent.get_ad_metrics.call_count, 4)
+
+    def test_performance_notification_sends_internal_message_without_webhook(self):
+        import pandas as pd
+
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        pending = {
+            "id": 7,
+            "check_date": date(2026, 8, 19),
+            "account_id": 1,
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "agency_name": "代理一",
+            "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+            "cleanup_status": "DISCOVERED",
+            "operator_notified_at": None,
+            "agency_notified_at": None,
+        }
+        performance_report = {
+            "run_id": "performance_20260819_test",
+            "report": "unused.xlsx",
+            "title": "长期未起量创意清理汇总通知",
+            "notification_kind": "performance_internal",
+            "creative_rows": 1,
+        }
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
+                "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
+                "ROI_AGENCY_WEBHOOK_ENABLED": "0",
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date", return_value="20260818"
+        ), patch.object(
+            cleanup, "fetch_daily_data", return_value=pd.DataFrame()
+        ), patch.object(
+            cleanup, "fetch_recent_spend_accounts", return_value=[]
+        ), patch.object(
+            cleanup, "fetch_active_creative_inventory", 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",
+            side_effect=[[pending], [pending]],
+        ), patch.object(
+            cleanup, "advisory_lock", side_effect=acquired_lock
+        ), patch.object(
+            cleanup,
+            "write_performance_operator_summary",
+            return_value=performance_report,
+        ), patch.object(
+            cleanup,
+            "publish_cleanup_operator_summary",
+            return_value={"route": "长期未起量清理汇总", "status": "SENT"},
+        ) as publish_internal, patch.object(
+            cleanup, "publish_agency_reports"
+        ) as publish_agency, patch.object(
+            cleanup, "mark_cleanup_items_operator_notified"
+        ) as mark_internal:
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=datetime(
+                    2026,
+                    8,
+                    19,
+                    11,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+                tencent=Mock(),
+                odps=Mock(),
+                publisher=Mock(),
+            )
+
+        publish_agency.assert_not_called()
+        publish_internal.assert_called_once()
+        self.assertEqual(
+            publish_internal.call_args.kwargs["chat_id"],
+            "chat-internal",
+        )
+        mark_internal.assert_called_once_with(
+            [7],
+            datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai")),
+        )
+
+    def test_force_preview_resends_already_notified_performance_rows(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        @contextmanager
+        def acquired_lock(_name):
+            yield True
+
+        current = datetime(
+            2026, 8, 20, 16, 30, tzinfo=ZoneInfo("Asia/Shanghai")
+        )
+        pending = {
+            "id": 9,
+            "check_date": current.date(),
+            "account_id": 1,
+            "adgroup_id": 2,
+            "dynamic_creative_id": 3,
+            "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+            "cleanup_status": "DISCOVERED",
+            "operator_notified_at": datetime(2026, 8, 20, 11),
+            "agency_notified_at": datetime(2026, 8, 20, 11),
+        }
+        report = {
+            "run_id": "performance_2026-08-20_digest",
+            "report": "unused.xlsx",
+            "title": "长期未起量创意及广告清理汇总通知",
+            "notification_kind": "performance_internal",
+            "creative_rows": 1,
+            "ad_rows": 0,
+        }
+        with TemporaryDirectory() as directory, patch.dict(
+            os.environ,
+            {"FEISHU_AD_PROJECT_CHAT_ID": "chat-internal"},
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "fetch_active_creative_inventory", return_value=[]
+        ), patch.object(
+            cleanup, "fetch_tencent_account_metadata", 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",
+            side_effect=[[pending], [pending]],
+        ) as load_notifications, patch.object(
+            cleanup,
+            "filter_preview_notification_rows",
+            side_effect=lambda rows, **_kwargs: [
+                {**row, "operator_notified_at": None} for row in rows
+            ],
+        ), patch.object(
+            cleanup, "advisory_lock", side_effect=acquired_lock
+        ), patch.object(
+            cleanup,
+            "write_performance_operator_summary",
+            return_value=report,
+        ), patch.object(
+            cleanup,
+            "publish_cleanup_operator_summary",
+            return_value={"route": "长期未起量清理汇总", "status": "SENT"},
+        ) as publish, patch.object(
+            cleanup, "mark_cleanup_items_operator_notified"
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path(directory),
+                now=current,
+                tencent=Mock(),
+                odps=Mock(),
+                publisher=Mock(),
+                underperformance_preview_only=True,
+                force_notification=True,
+            )
+
+        self.assertTrue(summary["force_notification"])
+        self.assertTrue(
+            publish.call_args.kwargs["run_id"].startswith(
+                "performance_2026-08-20_digest_force_"
+            )
+        )
+        self.assertIn("手动重发", publish.call_args.kwargs["report"]["title"])
+        for call in load_notifications.call_args_list:
+            self.assertTrue(call.kwargs["include_notified"])
+        self.assertEqual(summary["notification_errors"], [])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 358 - 8
examples/auto_put_ad_mini/test_creative_review_scan.py

@@ -561,22 +561,138 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
 
         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.assertEqual(values_sql.count("%s"), 22)
+        self.assertEqual(len(insert_params), 22)
+        self.assertEqual(insert_params[-2], "ALERT_PENDING")
+        self.assertIsNone(insert_params[-1])
         update_sql, update_params = cursor.calls[1]
         self.assertIn(
             "NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)",
             update_sql,
         )
+        normalized_update_sql = " ".join(update_sql.split())
         self.assertIn(
-            "item.cleanup_status NOT IN ('DELETING','CREATIVE_DELETED')",
-            update_sql,
+            "'DELETING','CREATIVE_DELETED','AD_DELETED'",
+            normalized_update_sql,
         )
         self.assertEqual(update_params, insert_params)
         self.assertEqual(result, {"id": 7})
         connection.close.assert_called_once()
 
+    def test_cleanup_candidates_reuse_one_transaction_connection_per_batch(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        connections = []
+
+        def connection_factory():
+            cursor = Mock()
+            cursor.__enter__ = Mock(return_value=cursor)
+            cursor.__exit__ = Mock(return_value=None)
+            connection = Mock()
+            connection.cursor.return_value = cursor
+            connections.append((connection, cursor))
+            return connection
+
+        records = [
+            {
+                "account_id": 1,
+                "account_name": "账户一",
+                "agency_name": "",
+                "adgroup_id": 2,
+                "adgroup_name": "广告二",
+                "dynamic_creative_id": creative_id,
+                "dynamic_creative_name": f"创意{creative_id}",
+                "check_date": date(2026, 8, 20),
+                "cleanup_action": cleanup.DELETE_CREATIVE,
+                "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+                "reject_reason": "",
+                "review_result": {},
+                "pre_state": {},
+            }
+            for creative_id in range(1, 5)
+        ]
+        with patch.dict(
+            os.environ,
+            {
+                "DAILY_CLEANUP_CANDIDATE_BATCH_SIZE": "2",
+                "DAILY_CLEANUP_CANDIDATE_STORE_WORKERS": "2",
+            },
+            clear=False,
+        ), patch.object(
+            cleanup,
+            "get_connection",
+            side_effect=connection_factory,
+        ):
+            stored, errors = cleanup.upsert_cleanup_candidates(records)
+
+        self.assertEqual(len(stored), 4)
+        self.assertEqual(errors, [])
+        self.assertEqual(len(connections), 2)
+        self.assertEqual(
+            sum(cursor.execute.call_count for _, cursor in connections),
+            12,
+        )
+        for connection, _cursor in connections:
+            connection.begin.assert_called_once_with()
+            connection.commit.assert_called_once_with()
+            connection.close.assert_called_once_with()
+
+    def test_cleanup_candidate_batch_failure_falls_back_to_individual_rows(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        connections = []
+
+        def connection_factory():
+            cursor = Mock()
+            cursor.__enter__ = Mock(return_value=cursor)
+            cursor.__exit__ = Mock(return_value=None)
+            cursor.fetchone.return_value = {"id": 7}
+            connection = Mock()
+            connection.cursor.return_value = cursor
+            if not connections:
+                cursor.execute.side_effect = RuntimeError("batch write failed")
+            connections.append((connection, cursor))
+            return connection
+
+        records = [
+            {
+                "account_id": 1,
+                "account_name": "账户一",
+                "agency_name": "",
+                "adgroup_id": 2,
+                "adgroup_name": "广告二",
+                "dynamic_creative_id": creative_id,
+                "dynamic_creative_name": f"创意{creative_id}",
+                "check_date": date(2026, 8, 20),
+                "cleanup_action": cleanup.DELETE_CREATIVE,
+                "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
+                "reject_reason": "",
+                "review_result": {},
+                "pre_state": {},
+            }
+            for creative_id in (1, 2)
+        ]
+        with patch.dict(
+            os.environ,
+            {
+                "DAILY_CLEANUP_CANDIDATE_BATCH_SIZE": "10",
+                "DAILY_CLEANUP_CANDIDATE_STORE_WORKERS": "1",
+            },
+            clear=False,
+        ), patch.object(
+            cleanup,
+            "get_connection",
+            side_effect=connection_factory,
+        ):
+            stored, errors = cleanup.upsert_cleanup_candidates(records)
+
+        self.assertEqual(len(stored), 2)
+        self.assertEqual(errors, [])
+        self.assertEqual(len(connections), 3)
+        connections[0][0].rollback.assert_called_once_with()
+        for connection, _cursor in connections:
+            connection.close.assert_called_once_with()
+
     def test_retry_query_only_loads_whole_creative_deletes(self):
         from tools import creative_rejection_cleanup as cleanup
 
@@ -591,7 +707,10 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
             self.assertEqual(cleanup.load_retryable_cleanup_items(), [])
 
         sql = cursor.execute.call_args.args[0]
-        self.assertIn("cleanup_action='DELETE_CREATIVE'", sql)
+        self.assertIn(
+            "cleanup_action IN ('DELETE_CREATIVE','DELETE_AD')",
+            sql,
+        )
         self.assertIn("item.cleanup_status='DELETING'", sql)
         self.assertIn("DATE_SUB(NOW(), INTERVAL %s MINUTE)", sql)
         self.assertNotIn("COMPONENTS_PARTIAL", sql)
@@ -611,7 +730,10 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
 
         sql, params = cursor.execute.call_args.args
         self.assertIn("SET cleanup_status='DELETING'", sql)
-        self.assertIn("cleanup_action='DELETE_CREATIVE'", sql)
+        self.assertIn(
+            "cleanup_action IN ('DELETE_CREATIVE','DELETE_AD')",
+            sql,
+        )
         self.assertIn("cleanup_status IN", sql)
         self.assertIn("cleanup_status='DELETING'", sql)
         self.assertEqual(params, (7, cleanup.DEFAULT_DELETE_CLAIM_STALE_MINUTES))
@@ -655,6 +777,53 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
         sql = cursor.execute.call_args.args[0]
         self.assertIn("'DISCOVERED'", sql)
 
+    def test_preview_notification_query_filters_current_performance_day_in_sql(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
+        target_date = date(2026, 8, 20)
+
+        with patch.object(cleanup, "get_connection", return_value=connection):
+            cleanup.load_pending_notification_items(
+                include_discovered=True,
+                check_date=target_date,
+                performance_only=True,
+            )
+
+        sql, params = cursor.execute.call_args.args
+        self.assertIn("AND check_date=%s", sql)
+        self.assertIn(
+            "LEFT(cleanup_rule_type, 12)='PERFORMANCE_'",
+            sql,
+        )
+        self.assertEqual(params, [target_date])
+
+    def test_force_preview_notification_query_includes_notified_rows(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):
+            cleanup.load_pending_notification_items(
+                include_discovered=True,
+                check_date=date(2026, 8, 20),
+                performance_only=True,
+                include_notified=True,
+            )
+
+        sql = cursor.execute.call_args.args[0]
+        self.assertNotIn("operator_notified_at IS NULL", sql)
+
     def test_dry_run_reports_mark_delete_as_not_executed(self):
         from tools.creative_rejection_cleanup import (
             write_cleanup_operator_summary,
@@ -798,6 +967,7 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
         )
 
         self.assertEqual(result[5], 0)
+        self.assertEqual(result[0], [])
         review_fetcher.assert_not_called()
         tencent.get_dynamic_creative_costs.assert_not_called()
 
@@ -2664,15 +2834,195 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
         from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
 
         summary = {
+            "deleted": 3,
+            "deferred": 1,
             "scan_errors": [],
             "delete_errors": [],
             "notification_errors": ["operator failed"],
         }
         with patch("logging_setup.setup_logging"), patch.object(
             entry, "run_rejected_creative_cleanup", return_value=summary
-        ):
+        ), patch.object(entry.logger, "error") as error_log:
             self.assertEqual(entry.main(), 1)
 
+        error_log.assert_called_once()
+        message, *values = error_log.call_args.args
+        self.assertIn("result=partial_failure", message)
+        self.assertEqual(values[:5], [3, 1, 0, 0, 1])
+        self.assertIn("operator failed", values[5])
+
+    def test_cleanup_entry_exposes_underperformance_preview_only_mode(self):
+        from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
+
+        summary = {
+            "scan_errors": [],
+            "delete_errors": [],
+            "notification_errors": [],
+        }
+        output_dir = Path("/tmp/underperformance-preview-test")
+        with patch("logging_setup.setup_logging"), patch.object(
+            entry, "run_rejected_creative_cleanup", return_value=summary
+        ) as run:
+            self.assertEqual(
+                entry.main([
+                    "--underperformance-preview-only",
+                    "--output-dir",
+                    str(output_dir),
+                ]),
+                0,
+            )
+
+        run.assert_called_once_with(
+            output_dir=output_dir,
+            underperformance_preview_only=True,
+            force_notification=False,
+        )
+
+    def test_force_notification_requires_underperformance_preview(self):
+        from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
+
+        with self.assertRaises(SystemExit):
+            entry.parse_args(["--force-notification"])
+
+    def test_cleanup_entry_exposes_force_notification_mode(self):
+        from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
+
+        summary = {
+            "scan_errors": [],
+            "delete_errors": [],
+            "notification_errors": [],
+        }
+        with patch("logging_setup.setup_logging"), patch.object(
+            entry, "run_rejected_creative_cleanup", return_value=summary
+        ) as run:
+            self.assertEqual(
+                entry.main([
+                    "--underperformance-preview-only",
+                    "--force-notification",
+                ]),
+                0,
+            )
+
+        run.assert_called_once_with(
+            output_dir=entry.HERE / "outputs" / "creative_rejection_cleanup",
+            underperformance_preview_only=True,
+            force_notification=True,
+        )
+
+    def test_underperformance_preview_forces_no_delete_and_skips_review(self):
+        from tools import creative_rejection_cleanup as cleanup
+
+        review_pending_row = {
+            "id": 99,
+            "check_date": date(2026, 8, 20),
+            "cleanup_rule_type": cleanup.REVIEW_DENIED_RULE,
+        }
+        tencent = Mock()
+        with patch.dict(
+            os.environ,
+            {
+                "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
+                "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "1",
+                "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "1",
+            },
+            clear=False,
+        ), patch.object(cleanup, "initialize_schema"), patch.object(
+            cleanup, "resolve_end_date"
+        ) as resolve_review_date, patch.object(
+            cleanup, "fetch_daily_data"
+        ) as fetch_review_context, patch.object(
+            cleanup, "fetch_recent_spend_accounts"
+        ) as fetch_review_accounts, patch.object(
+            cleanup, "fetch_account_agency_fallbacks"
+        ) as fetch_agency_fallbacks, patch.object(
+            cleanup, "fetch_active_creative_inventory", 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=[review_pending_row],
+        ):
+            summary = cleanup.run_rejected_creative_cleanup(
+                output_dir=Path("/tmp/underperformance-preview-test"),
+                now=datetime(
+                    2026,
+                    8,
+                    20,
+                    11,
+                    tzinfo=ZoneInfo("Asia/Shanghai"),
+                ),
+                tencent=tencent,
+                odps=Mock(),
+                publisher=Mock(),
+                underperformance_preview_only=True,
+            )
+
+        self.assertTrue(summary["underperformance_preview_only"])
+        self.assertTrue(summary["performance_cleanup_enabled"])
+        self.assertTrue(summary["ad_cleanup_enabled"])
+        self.assertFalse(summary["apply_enabled"])
+        self.assertFalse(summary["performance_apply_enabled"])
+        self.assertFalse(summary["ad_apply_enabled"])
+        resolve_review_date.assert_not_called()
+        fetch_review_context.assert_not_called()
+        fetch_review_accounts.assert_not_called()
+        fetch_agency_fallbacks.assert_not_called()
+        tencent.delete_dynamic_creative.assert_not_called()
+        tencent.delete_ad.assert_not_called()
+
+    def test_logging_accepts_only_safe_inherited_child_trace_id(self):
+        from examples.auto_put_ad_mini import logging_setup
+
+        with patch.dict(
+            os.environ,
+            {"AUTO_PUT_TRACE_ID": "parent.cleanup.1234.abcd1234"},
+            clear=False,
+        ):
+            self.assertEqual(
+                logging_setup._inherited_trace_id(),
+                "parent.cleanup.1234.abcd1234",
+            )
+        with patch.dict(
+            os.environ,
+            {"AUTO_PUT_TRACE_ID": "unsafe\nforged-log"},
+            clear=False,
+        ):
+            self.assertIsNone(logging_setup._inherited_trace_id())
+
+    def test_scheduled_subprocess_receives_searchable_child_trace_id(self):
+        import run_daily_service as daily_service
+
+        @contextmanager
+        def acquired_lock():
+            yield True
+
+        with patch.object(
+            daily_service, "advisory_lock", return_value=acquired_lock()
+        ), patch.object(
+            daily_service, "get_trace_id", return_value="parent-trace"
+        ), patch.object(
+            daily_service.time, "time_ns", return_value=123456789
+        ), patch.object(
+            daily_service.secrets, "token_hex", return_value="abcd1234"
+        ), patch.object(
+            daily_service.subprocess,
+            "run",
+            return_value=Mock(returncode=7),
+        ) as run:
+            with self.assertRaises(RuntimeError) as raised:
+                daily_service._run_script("failing_task.py", "test-lock")
+
+        expected_trace = "parent-trace.failing_task.123456789.abcd1234"
+        self.assertIn(expected_trace, str(raised.exception))
+        self.assertEqual(
+            run.call_args.kwargs["env"]["AUTO_PUT_TRACE_ID"],
+            expected_trace,
+        )
+
 
 if __name__ == "__main__":
     unittest.main()

+ 25 - 0
examples/auto_put_ad_mini/test_daily_roi_polling.py

@@ -3,6 +3,7 @@ import sys
 import unittest
 from datetime import date, datetime
 from pathlib import Path
+from types import SimpleNamespace
 from unittest.mock import Mock, patch
 
 
@@ -241,6 +242,30 @@ class DailyRoiPollingTest(unittest.TestCase):
             jobs["daily_roi_cutoff"].args[0],
             run_daily_service.finalize_daily_roi_at_cutoff,
         )
+        scheduler.add_listener.assert_called_once_with(
+            run_daily_service._log_scheduler_failure,
+            run_daily_service.EVENT_JOB_ERROR
+            | run_daily_service.EVENT_JOB_MISSED
+            | run_daily_service.EVENT_JOB_MAX_INSTANCES,
+        )
+
+    def test_scheduler_error_log_contains_job_and_traceback(self):
+        event = SimpleNamespace(
+            code=run_daily_service.EVENT_JOB_ERROR,
+            job_id="rejected_creative_cleanup",
+            scheduled_run_time="2026-08-20T11:00:00+08:00",
+            exception=RuntimeError("child failed"),
+            traceback="traceback detail",
+        )
+
+        with patch.object(run_daily_service.logger, "error") as error:
+            run_daily_service._log_scheduler_failure(event)
+
+        message, *values = error.call_args.args
+        self.assertIn("result=failed", message)
+        self.assertEqual(values[0], "rejected_creative_cleanup")
+        self.assertIn("child failed", repr(values[2]))
+        self.assertEqual(values[3], "traceback detail")
 
 
 class FormalRunLookupTest(unittest.TestCase):

+ 4 - 5
examples/auto_put_ad_mini/tools/account_material_strategy.py

@@ -1,7 +1,6 @@
-"""Account-level creative material source strategy.
+"""账户级创意素材来源策略。
 
-The default remains historical recall. AI generation and external recall are
-enabled only for accounts explicitly configured for those module B branches.
+默认仍使用历史召回;只有显式配置模块 B 对应分支的账户才启用 AI 生成或外部召回。
 """
 
 from __future__ import annotations
@@ -73,7 +72,7 @@ def parse_bool_flag(raw: object) -> bool:
 
 
 def ensure_account_material_strategy_columns() -> None:
-    """Add strategy columns when deploying to an older DB schema."""
+    """部署到旧数据库结构时补充素材策略字段。"""
     from db.connection import get_connection
 
     conn = get_connection()
@@ -113,7 +112,7 @@ def ensure_account_material_strategy_columns() -> None:
 
 
 def load_account_material_strategy(account_id: int) -> AccountMaterialStrategy:
-    """Load configured material strategy. Missing config defaults to history."""
+    """读取已配置的素材策略;缺少配置时默认使用历史素材。"""
     from db.connection import get_connection
 
     try:

+ 3 - 3
examples/auto_put_ad_mini/tools/ad_api.py

@@ -10,9 +10,9 @@
 - GET 请求:所有参数(含公共参数)在 URL query,复杂对象需 JSON 序列化后 URL 编码
 
 环境变量:
-  TENCENT_AD_ACCESS_TOKEN   OAuth2 access token
+  TENCENT_AD_ACCESS_TOKEN   OAuth2 访问令牌
   TENCENT_AD_ACCOUNT_ID     默认广告账户 ID(可被参数覆盖)
-  TENCENT_AD_BASE_URL       API base,默认 https://api.e.qq.com/v3.0
+  TENCENT_AD_BASE_URL       API 基础地址,默认 https://api.e.qq.com/v3.0
 """
 
 import hashlib
@@ -100,7 +100,7 @@ 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."""
+    """并发读取去重后的账户 access token,并预热缓存。"""
 
     account_set = set()
     for raw_value in account_ids:

+ 1 - 1
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -1362,7 +1362,7 @@ async def apply_decisions(
         if ad_status_path.exists():
             try:
                 df_status = pd.read_csv(ad_status_path)
-                # 1) SUSPEND
+                # 1)暂停(SUSPEND)
                 suspended_mask = df_status["ad_status"] == "AD_STATUS_SUSPEND"
                 # 2) 腾讯侧已删除(sync_ad_status.py 每日同步回写,向后兼容:列缺失即视为全 False)
                 if "is_deleted" in df_status.columns:

+ 14 - 16
examples/auto_put_ad_mini/tools/ai_generated_material.py

@@ -1,8 +1,7 @@
-"""AI generated creative image assets for module B.
+"""模块 B 的 AI 生成创意图片资产。
 
-This module is intentionally isolated from the historical material recall path.
-External side effects happen only when an account explicitly uses
-material_source=ai_generated.
+本模块与历史素材召回链路保持隔离;只有账户明确配置
+material_source=ai_generated 时才产生外部副作用。
 """
 
 from __future__ import annotations
@@ -343,7 +342,7 @@ def _extract_json_object(text: str) -> dict[str, Any]:
 
 
 def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
-    """Rewrite ODPS topic into an ad-theme seed for image generation."""
+    """将 ODPS 主题改写为图片生成使用的广告主题种子。"""
     raw = (raw_description or "").strip()
     if not raw:
         raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
@@ -434,10 +433,10 @@ def _normalize_highlight_terms(value: Any, *, title: str) -> list[str]:
 
 
 def generate_cover_copy(video_description: str, selection, model: str = OPENROUTER_TEXT_MODEL) -> CoverCopy:
-    """Generate the cover title before image generation.
+    """在生成图片前先生成封面标题。
 
-    The image model is weak at deciding ad copy and then rendering it. This node
-    makes the copy explicit while keeping final compliance to later review.
+    图片模型不擅长同时决定广告文案并准确渲染,因此此节点先明确文案,最终合规
+    仍交由后续审核判断。
     """
     description = (video_description or "").strip()
     if not description:
@@ -528,7 +527,7 @@ def build_generation_prompts(
     features: Iterable[VideoElementFeature],
     sanitized_description: str = "",
 ) -> list[GenerationPrompt]:
-    """Build one generation prompt from the video's ODPS topic."""
+    """根据视频的 ODPS 主题构建一个生成提示词。"""
     feature_list = list(features or [])
     topic = _top_feature(feature_list, dimension="解构选题")
     topic_text = (topic or {}).get("standard_element")
@@ -559,7 +558,7 @@ def build_pattern_generation_prompts(
     top_k: int = AI_IMAGE_PATTERN_TOP_K,
     text_model: str = OPENROUTER_TEXT_MODEL,
 ) -> list[GenerationPrompt]:
-    """Build generation prompts using model-selected creative patterns."""
+    """使用模型选出的创意模式构建生成提示词。"""
     from tools.material_strategy_learning import select_creative_patterns
 
     feature_list = list(features or [])
@@ -657,7 +656,7 @@ def _openrouter_api_key() -> str:
 
 
 def _to_jpeg_bytes(image_bytes: bytes) -> bytes:
-    """Normalize model output to RGB JPEG for Tencent material upload."""
+    """将模型输出标准化为 RGB JPEG,供腾讯素材上传。"""
     with Image.open(BytesIO(image_bytes)) as img:
         if img.mode not in ("RGB", "L"):
             background = Image.new("RGB", img.size, (255, 255, 255))
@@ -727,11 +726,10 @@ def generate_image_bytes(
     *,
     input_image_url: str = "",
 ) -> tuple[bytes, str, dict]:
-    """Generate one image through OpenRouter Images API.
+    """通过 OpenRouter Images API 生成一张图片。
 
-    The Images API lets us pass aspect_ratio directly. We still normalize the
-    returned raster to JPEG in code because not every model supports
-    output_format.
+    Images API 支持直接传 aspect_ratio;但并非所有模型都支持 output_format,
+    因此代码仍会把返回的位图标准化为 JPEG。
     """
     body = {
         "model": model,
@@ -928,7 +926,7 @@ def insert_and_review_generated_material(
     oss_url: str,
     raw_response: dict,
 ) -> tuple[GeneratedMaterialAsset, Any]:
-    """Persist one generated asset and run the same AI review used by production."""
+    """持久化一个生成资产,并执行与生产一致的 AI 审核。"""
     asset = _insert_generated_material(
         account_id=account_id,
         adgroup_id=adgroup_id,

+ 3 - 4
examples/auto_put_ad_mini/tools/ai_material_review.py

@@ -1,7 +1,6 @@
-"""AI review for generated image materials.
+"""生成图片素材的 AI 审核。
 
-This module is intentionally separate from image generation. It can be used
-both inline after generation and later for rescanning historical assets.
+本模块刻意与图片生成解耦,既可在生成后立即调用,也可用于后续重扫历史资产。
 """
 
 from __future__ import annotations
@@ -96,7 +95,7 @@ def review_generated_material(
     feature_hits: list[dict],
     model: str = AI_MATERIAL_REVIEW_MODEL,
 ) -> MaterialReviewResult:
-    """Review one generated material image with multimodal model."""
+    """使用多模态模型审核一张生成素材图片。"""
     payload = {
         "task": "审核一张AI生成的腾讯广告信息流中文封面图是否可进入人工投放审批候选",
         "review_goals": [

+ 11 - 11
examples/auto_put_ad_mini/tools/creative_material_usage.py

@@ -1,7 +1,7 @@
-"""Creative material usage reservation for duplicate control.
+"""用于排重的创意素材使用预留记录。
 
-Usage is written when a pending creative is prepared, before Tencent POST.
-Review/task tables remain responsible for submitted Tencent creatives.
+待提交创意准备完成、调用腾讯 POST 前即写入使用记录;已提交腾讯的创意仍由
+审核表和任务表负责跟踪。
 """
 
 from __future__ import annotations
@@ -72,7 +72,7 @@ def load_recent_used_material_ids(
     crowd_package: str,
     lookback_days: int = CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS,
 ) -> set[str]:
-    """Load recent material reservations for the same crowd package."""
+    """读取同一人群包近期的素材预留记录。"""
     if not crowd_package:
         return set()
     ensure_usage_table()
@@ -116,7 +116,7 @@ def load_recent_used_landing_video_ids(
     crowd_package: str,
     lookback_days: int = CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS,
 ) -> set[int]:
-    """Load recent landing-video reservations for the same crowd package."""
+    """读取同一人群包近期的落地页视频预留记录。"""
     if not crowd_package:
         return set()
     ensure_usage_table()
@@ -186,10 +186,10 @@ def load_recent_landing_usage_counts(
     crowd_package: str,
     lookback_days: int = CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS,
 ) -> dict[str, dict[int, int]]:
-    """Load recent landing-video usage counts split by material source.
+    """按素材来源读取近期落地页视频使用次数。
 
-    The `source` column in creative_material_usage means primary/hot video source,
-    so material source must be derived from material_id/raw_record.
+    creative_material_usage 的 ``source`` 列表示 primary/hot 视频来源,因此
+    素材来源必须从 material_id/raw_record 推导。
     """
     counts: dict[str, dict[int, int]] = {
         "history": {},
@@ -261,7 +261,7 @@ def load_recoverable_prepared_records(
     limit: int,
     lookback_hours: int = CREATION_RECOVERY_LOOKBACK_HOURS,
 ) -> list[dict]:
-    """Load unsubmitted prepared records after an interrupted Phase 1 run."""
+    """读取第一阶段中断后仍未提交的已准备记录。"""
     if limit <= 0 or not crowd_package:
         return []
     ensure_usage_table()
@@ -319,7 +319,7 @@ def load_recoverable_prepared_records(
 
 
 def record_prepared_material_usage(record: dict, status: str = "prepared") -> None:
-    """Reserve a material once Phase 1 has produced a pending creative."""
+    """第一阶段生成待提交创意后立即预留对应素材。"""
     material_id = str(record.get("_material_id") or "").strip()
     crowd_package = str(record.get("audience_tier") or "").strip()
     if not material_id or not crowd_package:
@@ -363,7 +363,7 @@ def update_material_usage_status(
     dynamic_creative_id: int | str | None = None,
     error: str = "",
 ) -> None:
-    """Update the latest usage reservation for this crowd_package + material_id."""
+    """更新当前人群包与素材 ID 组合的最新使用预留记录。"""
     material_id = str(record.get("_material_id") or "").strip()
     crowd_package = str(record.get("audience_tier") or "").strip()
     if not material_id or not crowd_package:

Datei-Diff unterdrückt, da er zu groß ist
+ 867 - 34
examples/auto_put_ad_mini/tools/creative_rejection_cleanup.py


+ 10 - 12
examples/auto_put_ad_mini/tools/creative_review.py

@@ -1,7 +1,6 @@
-"""Tencent dynamic creative review result scanner.
+"""腾讯动态创意审核结果扫描器。
 
-This module tracks creatives submitted by the creation pipeline and polls
-Tencent's official review-result API. It does not perform prereview.
+本模块跟踪创建链路已提交的创意,并轮询腾讯官方审核结果 API,不执行预审。
 """
 
 from __future__ import annotations
@@ -83,8 +82,7 @@ CREATE TABLE IF NOT EXISTS creative_rejection_fact (
 FINAL_REVIEW_STATUSES = {"approved", "rejected"}
 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.
+# 面向业务报表的腾讯 API 枚举说明;未知值保持原样,便于识别和诊断新增枚举。
 STATUS_DESCRIPTIONS = {
     "AD_STATUS_NORMAL": "投放中",
     "AD_STATUS_SUSPEND": "暂停",
@@ -160,7 +158,7 @@ def _is_normal_review_status(value: object) -> bool:
 
 
 def status_desc(value: object) -> str:
-    """Return the Chinese API description, preserving unknown enum values."""
+    """返回 API 的中文说明,未知枚举值保持原样。"""
 
     raw = str(value or "").strip()
     return STATUS_DESCRIPTIONS.get(raw, raw)
@@ -182,7 +180,7 @@ def _labeled_values(
 
 
 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]] = []
@@ -387,7 +385,7 @@ def _collect_compose_facts(result: dict) -> tuple[list[str], list[RejectionFact]
 
 
 def parse_review_result(result: dict) -> ParsedReviewResult:
-    """Parse Tencent dynamic creative review result into stable internal status."""
+    """将腾讯动态创意审核结果解析为稳定的内部状态。"""
     reject_messages: list[str] = []
     delay_messages = [str(v) for v in (result.get("delay_message_list") or []) if v]
     facts: list[RejectionFact] = []
@@ -444,7 +442,7 @@ def group_review_tasks(
     tasks: Iterable[dict],
     batch_size: int = 100,
 ) -> Iterator[tuple[int, list[int]]]:
-    """Yield (account_id, dynamic_creative_ids) batches for Tencent API."""
+    """按腾讯 API 限制分批生成账户 ID 与动态创意 ID 列表。"""
     by_account: dict[int, list[int]] = {}
     for task in tasks:
         try:
@@ -474,7 +472,7 @@ def ensure_review_tables() -> None:
 
 
 def record_creation_submission(record: dict, dynamic_creative_id: int) -> None:
-    """Persist a successfully submitted creative as pending review scan."""
+    """将成功提交的创意保存为待审核扫描状态。"""
     ensure_review_tables()
     from db.connection import get_connection
 
@@ -517,7 +515,7 @@ ON DUPLICATE KEY UPDATE
 
 
 def mark_creation_submit_failed(record: dict, error: str) -> None:
-    """Persist submit failure for observability."""
+    """持久化提交失败信息,供任务追踪与诊断。"""
     ensure_review_tables()
     from db.connection import get_connection
 
@@ -687,7 +685,7 @@ def scan_pending_reviews(
     limit: int = 1000,
     batch_size: int = 100,
 ) -> dict:
-    """Scan pending submitted creatives and persist official review results."""
+    """扫描待审核的已提交创意,并保存官方审核结果。"""
     tasks = load_pending_review_tasks(lookback_hours=lookback_hours, limit=limit)
     from tools.ad_api import prefetch_access_tokens
 

+ 1 - 1
examples/auto_put_ad_mini/tools/creative_roi_calculator.py

@@ -53,7 +53,7 @@ def _aggregate_creative_to_creative_day(df: pd.DataFrame) -> pd.DataFrame:
 
     df = df.copy()
 
-    # bizdate → date
+    # 将 bizdate 转换为 date
     if "bizdate" in df.columns:
         df["date"] = df["bizdate"].astype(str)
     elif "date" not in df.columns:

+ 2 - 2
examples/auto_put_ad_mini/tools/delivery_config.py

@@ -1,4 +1,4 @@
-"""Account-level bidding and placement config parsed from Feishu/DB."""
+"""从飞书和数据库解析的账户级出价与版位配置。"""
 
 from __future__ import annotations
 
@@ -65,7 +65,7 @@ _TEMPLATE_KEYS = {"排地域", "默认地域"}                  # 使用投放
 
 
 def validate_region_dictionary() -> Path:
-    """Fail fast when the versioned Tencent region asset is missing or malformed."""
+    """版本化腾讯地域资产缺失或格式异常时立即失败。"""
     path = REGION_DICTIONARY_PATH
     if not path.is_file():
         raise RuntimeError(f"腾讯地域字典缺失: {path}")

+ 4 - 5
examples/auto_put_ad_mini/tools/external_recalled_material.py

@@ -1,8 +1,7 @@
-"""External recalled material processing for module B.
+"""模块 B 的外部召回素材处理。
 
-This path is enabled only for accounts configured with
-``material_source=external_recall``. It keeps historical recall and the
-existing text-to-image flow unchanged.
+仅对配置 ``material_source=external_recall`` 的账户启用此链路;历史召回与
+既有文生图流程保持不变。
 """
 
 from __future__ import annotations
@@ -220,7 +219,7 @@ def rank_external_materials_by_visit_uv(
     materials: list[Material],
     min_uv: int | None = None,
 ) -> list[Material]:
-    """Attach configured-window visit UV, filter by min_uv, sort by UV then similarity."""
+    """补充配置窗口访问 UV,按 min_uv 过滤,再按 UV 和相似度排序。"""
     if not materials:
         return []
     totals, start_dt, end_dt = _query_visit_uv_30d(

+ 1 - 1
examples/auto_put_ad_mini/tools/im_approval_ad_creation.py

@@ -120,7 +120,7 @@ def generate_ad_approval_xlsx(records: list[dict], output_path: Path) -> Path:
     ws = wb.active
     ws.title = "广告新建审批"
 
-    # header
+    # 表头
     for col_idx, name in enumerate(HEADERS, start=1):
         cell = ws.cell(row=1, column=col_idx, value=name)
         cell.font = Font(bold=True, size=11)

+ 2 - 2
examples/auto_put_ad_mini/tools/im_approval_creation.py

@@ -189,13 +189,13 @@ def _prepare_thumbnail(src_path: Path, dst_path: Path) -> bool:
 
 
 def _embed_material_preview_images(ws, records: list[dict]) -> None:
-    """Embed thumbnails into the material preview column, keeping hyperlinks as fallback."""
+    """在素材预览列嵌入缩略图,并保留超链接作为兜底。"""
     if not EMBED_MATERIAL_PREVIEW_IMAGES:
         return
 
     tmp_dir = tempfile.TemporaryDirectory(prefix="creative_preview_")
     tmp_root = Path(tmp_dir.name)
-    # Keep the tempdir alive until workbook.save() finishes.
+    # 临时目录必须保留到 workbook.save() 完成后再释放。
     ws._creative_preview_tmp_dir = tmp_dir  # type: ignore[attr-defined]
     embedded = 0
     for row_idx, rec in enumerate(records, start=2):

+ 4 - 5
examples/auto_put_ad_mini/tools/material_recall.py

@@ -149,7 +149,7 @@ EXCLUDED_COVER_URL_PATTERNS = (
 
 @dataclass
 class RecallQuery:
-    """One ODPS-derived material recall query."""
+    """一条由 ODPS 特征生成的素材召回查询。"""
 
     query_text: str
     config_code: str
@@ -509,11 +509,10 @@ def _build_recall_queries_from_features(
     element_features: Iterable,
     query_limit: int,
 ) -> List[RecallQuery]:
-    """Build recall queries from ODPS features.
+    """根据 ODPS 特征构建召回查询。
 
-    Supported dimensions:
-    - 解构选题 -> VIDEO_TOPIC
-    - 实质 + 灵感点/关键点/目的点 -> *_SUBSTANCE
+    支持的维度:解构选题映射到 VIDEO_TOPIC;实质加灵感点、关键点或目的点映射
+    到对应的 *_SUBSTANCE。
     """
     queries: List[RecallQuery] = []
     seen = set()

+ 17 - 24
examples/auto_put_ad_mini/tools/material_strategy_learning.py

@@ -1,8 +1,7 @@
-"""Material strategy learning persistence.
+"""素材策略学习数据持久化。
 
-This module stores high-consumption material snapshots and visual annotations.
-It is intentionally separate from the ad/creative creation pipeline: importing
-learning data must not create or modify Tencent ads.
+本模块保存高消耗素材快照与视觉标注,并刻意与广告/创意创建链路解耦;导入
+学习数据绝不能创建或修改腾讯广告。
 """
 
 from __future__ import annotations
@@ -345,7 +344,7 @@ def _load_seed_patterns(path: Path = DEFAULT_PATTERN_SEED_PATH) -> list[Creative
 
 
 def seed_default_draft_patterns(seed_path: Path = DEFAULT_PATTERN_SEED_PATH) -> int:
-    """Upsert initial DRAFT creative patterns learned from Top100 analysis."""
+    """更新或插入从 Top100 分析得到的初始 DRAFT 创意模式。"""
     ensure_strategy_learning_tables()
     from db.connection import get_connection
 
@@ -419,7 +418,7 @@ def seed_default_draft_patterns(seed_path: Path = DEFAULT_PATTERN_SEED_PATH) ->
 
 
 def load_creative_patterns(include_draft: bool = False) -> list[CreativePattern]:
-    """Load selectable patterns. Production should keep include_draft=False."""
+    """读取可选创意模式;生产环境应保持 include_draft=False。"""
     ensure_strategy_learning_tables()
     from db.connection import get_connection
 
@@ -493,11 +492,10 @@ def _fallback_score_pattern(
     feature_texts: Sequence[str],
     placement: str,
 ) -> PatternSelection:
-    """Stable fallback score when the model selector is unavailable.
+    """模型选择器不可用时采用稳定的兜底评分。
 
-    This intentionally does not use keyword relevance matching. Pattern
-    relevance is the model node's responsibility. Fallback only keeps the system
-    available and auditable.
+    这里刻意不使用关键词相关性匹配;模式相关性由模型节点负责,兜底逻辑只保证
+    系统可用且可审计。
     """
     joined_features = " ".join(feature_texts)
     score = 10.0
@@ -509,7 +507,7 @@ def _fallback_score_pattern(
     reasons = ["fallback_candidate", *reasons]
     matched_features: list[str] = list(feature_texts[:3])
 
-    # Weak prior from historical material volume, not semantic relevance.
+    # 仅使用历史素材量作为弱先验,不代表语义相关性。
     if pattern.source_total_cost_fen:
         score += min(15.0, pattern.source_total_cost_fen / 10_000_000)
         reasons.append("historical_cost_prior")
@@ -538,7 +536,7 @@ def _fallback_score_pattern(
 
 
 def _openrouter_api_key() -> str:
-    # Keep the same precedence as tools/ai_generated_material.py.
+    # 保持与 tools/ai_generated_material.py 相同的密钥读取优先级。
     key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
     if not key:
         raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法用模型选择pattern")
@@ -574,10 +572,9 @@ def _model_rerank_pattern_selections(
     top_k: int,
     model: str | None = None,
 ) -> list[PatternSelection]:
-    """Use an LLM node to choose from pre-existing candidate patterns.
+    """使用 LLM 节点从既有候选模式中选择。
 
-    The model can only rerank/select candidates produced from DB. It cannot
-    invent pattern keys or bypass compliance/risk context.
+    模型只能重排或选择数据库产生的候选,不能编造模式键,也不能绕过合规和风险上下文。
     """
     if not candidates:
         return []
@@ -688,15 +685,11 @@ def select_creative_patterns(
     use_model: bool = True,
     model: str | None = None,
 ) -> list[PatternSelection]:
-    """Select creative patterns for one video.
+    """为一个视频选择创意模式。
 
-    Production shape:
-    1. Load patterns from DB.
-    2. Send all available candidates to the model for semantic selection.
-    3. If the model fails, use a non-semantic stable fallback.
-
-    crowd_package is intentionally ignored: material style selection is shared
-    across audience packages.
+    生产流程:先从数据库读取模式,再把全部可用候选交给模型做语义选择;模型
+    失败时使用不依赖语义的稳定兜底。此处刻意忽略 crowd_package,因为素材风格
+    选择在不同人群包之间共享。
     """
     patterns = load_creative_patterns(include_draft=include_draft)
     feature_texts = _feature_texts(video_features)
@@ -822,7 +815,7 @@ def import_current_material_analysis(
     annotator: str = "rule_contact_sheet_review",
     report_version: str = "top100_visual_v1",
 ) -> ImportResult:
-    """Import the existing local high-consumption material analysis into DB."""
+    """将已有的本地高消耗素材分析导入数据库。"""
     ensure_strategy_learning_tables()
 
     performance_rows = _read_csv(performance_csv)

+ 2 - 3
examples/auto_put_ad_mini/tools/report_generator.py

@@ -248,12 +248,12 @@ async def generate_report(
             if "_has_spend" in df_out.columns:
                 df_out.drop(columns=["_has_spend"], inplace=True)
 
-        # CSV
+        # 生成 CSV
         csv_path = _REPORTS_DIR / f"decision_{end_date}.csv"
         df_out.to_csv(csv_path, index=False, encoding="utf-8-sig")
         logger.info("CSV 已生成: %s", csv_path)
 
-        # XLSX
+        # 生成 XLSX
         xlsx_path = _REPORTS_DIR / f"decision_{end_date}.xlsx"
         _write_xlsx_with_format(df_out, xlsx_path)
 
@@ -283,4 +283,3 @@ async def generate_report(
             str(e)[:200], exc_info=True,
         )
 
-

+ 1 - 1
examples/auto_put_ad_mini/tools/roi_calculator.py

@@ -456,7 +456,7 @@ async def calculate_roi_metrics(
                 f"(近 7 天累计消耗=0,视为已关闭),creative_df: {_before} → {len(creative_df)} 行"
             )
         # ============================================================
-        # TEMP END
+        # 临时逻辑结束
         # ============================================================
 
         # Step 1: 聚合到广告级

+ 13 - 14
examples/auto_put_ad_mini/tools/video_feature_query.py

@@ -1,7 +1,7 @@
-"""ODPS video feature lookup for landing-video material recall.
+"""落地页视频素材召回使用的 ODPS 视频特征查询。
 
-videoContentList only chooses landing videos. Material recall features are read
-from loghubods.dwd_video_element_contribution_analysis by video id.
+videoContentList 只负责选择落地页视频;素材召回特征按视频 ID 从
+loghubods.dwd_video_element_contribution_analysis 读取。
 """
 
 from __future__ import annotations
@@ -98,11 +98,10 @@ def _latest_partition_dt(client) -> str:
 
 
 def _query_odps_rows(client, sql: str, columns: list[str]) -> list[dict]:
-    """Read ODPS rows without tunnel when possible.
+    """条件允许时不经 tunnel 读取 ODPS 行。
 
-    The configured tunnel endpoint can be unreachable in some runtime
-    environments. Small feature lookups are better served through the instance
-    reader without tunnel.
+    部分运行环境无法访问配置的 tunnel endpoint;小批量特征查询使用实例读取器
+    更可靠。
     """
     odps = getattr(client, "_odps", None)
     if odps is None:
@@ -266,7 +265,7 @@ ON DUPLICATE KEY UPDATE
 def read_cached_video_element_features(
     video_ids: Iterable[int],
 ) -> dict[int, list[VideoElementFeature]]:
-    """Read latest cached feature rows per video id from local DB only."""
+    """仅从本地数据库读取每个视频 ID 的最新缓存特征行。"""
     ids: list[int] = []
     seen: set[int] = set()
     for raw in video_ids:
@@ -328,13 +327,13 @@ def fetch_video_element_features(
     video_ids: Iterable[int],
     chunk_size: int = 100,
 ) -> dict[int, list[VideoElementFeature]]:
-    """Fetch recall feature rows per video id from latest ODPS partition.
+    """从 ODPS 最新分区读取各视频 ID 的召回特征。
 
-    One video can have multiple usable element rows. Keep all rows where:
-    - 元素维度 = 解构选题
-    - 元素维度 = 实质 and 点类型 in 灵感点/关键点/目的点
-    - 贡献分 >= 0.8
-    - 标准化元素 is non-empty
+    一个视频可以有多条可用元素,保留符合以下条件的全部记录:
+    - 元素维度为“解构选题”;
+    - 元素维度为“实质”,且点类型属于灵感点、关键点或目的点;
+    - 贡献分不低于 0.8;
+    - 标准化元素非空。
     """
     ids: list[int] = []
     seen: set[int] = set()

+ 2 - 3
examples/auto_put_ad_mini/tools/video_recall.py

@@ -219,7 +219,7 @@ def _normalize_category(raw) -> str:
 
 
 def _enrich_hot_video_elements(videos: List["LandingVideo"]) -> None:
-    """Fill pointType / standardElement for hot videos from ODPS latest partition."""
+    """根据 ODPS 最新分区为 hot 视频补充 pointType 和 standardElement。"""
     if not videos:
         return
     try:
@@ -245,8 +245,7 @@ def _enrich_hot_video_elements(videos: List["LandingVideo"]) -> None:
             for f in features
         ]
         v.raw["element_features"] = feature_payload
-        # Keep the top contribution element on first-class fields so existing
-        # candidate checks continue to work.
+        # 将贡献度最高的元素保留在一级字段,确保既有候选校验继续生效。
         v.point_type = v.point_type or features[0].point_type
         v.standard_element = v.standard_element or features[0].standard_element
         enriched += 1

+ 2 - 2
examples/auto_put_ad_mini/utils/log_capture.py

@@ -56,6 +56,6 @@ class LogCapture:
         return self.buffer.getvalue()
 
 # 使用示例
-# with LogCapture() as capture:
+# 使用示例:with LogCapture() as capture:
 #     logger.info("这条日志会被捕获")
-#     logs = capture.get_logs()
+#     logs = capture.get_logs()  # 读取捕获到的日志

+ 1 - 1
examples/auto_put_ad_mini/verify_decision.py

@@ -154,7 +154,7 @@ csv_path = reports_dir / f"metrics_{yesterday}_verify.csv"
 df_out.to_csv(csv_path, index=False, encoding="utf-8-sig")
 print(f"   CSV: {csv_path}")
 
-# XLSX
+# XLSX 校验
 xlsx_path = reports_dir / f"metrics_{yesterday}_verify.xlsx"
 _write_xlsx_with_format(df_out, xlsx_path)
 print(f"   XLSX: {xlsx_path}")

+ 1 - 1
examples/tencent_realtime_control/__init__.py

@@ -1 +1 @@
-"""Tencent advertising real-time control module."""
+"""腾讯广告实时调控模块。"""

+ 1 - 1
examples/tencent_realtime_control/backtest_revenue_speed_forecast.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Strict walk-forward backtest of the weighted-speed forecast."""
+"""对加权速度预测执行严格的前向滚动回测。"""
 
 from __future__ import annotations
 

+ 1 - 1
examples/tencent_realtime_control/build_revenue_speed_parameters.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Build and publish weighted-speed parameters from completed revenue days."""
+"""基于完整收入日构建并发布加权速度参数。"""
 
 from __future__ import annotations
 

+ 1 - 1
examples/tencent_realtime_control/command_intent_parser.py

@@ -1,4 +1,4 @@
-"""Restricted natural-language understanding for Feishu operator commands."""
+"""飞书运营命令使用的受限自然语言理解。"""
 
 from __future__ import annotations
 

+ 2 - 2
examples/tencent_realtime_control/export_historical_hourly_roi.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Export historical hourly spend, revenue, CPM and ROI to a Feishu sheet."""
+"""将历史小时消耗、收入、CPM 和 ROI 导出到飞书表格。"""
 
 from __future__ import annotations
 
@@ -313,7 +313,7 @@ def build_hourly_report(
 ) -> pd.DataFrame:
     if not 0 <= start_hour <= end_hour <= 23:
         raise ValueError("hours must satisfy 0 <= start_hour <= end_hour <= 23")
-    # Build the full day first so cumulative values at 06:00 include 00:00-05:00.
+    # 先构建完整自然日,确保 06:00 的累计值包含 00:00-05:00 数据。
     grid = pd.DataFrame(
         [
             {"data_date": f"{item:%Y%m%d}", "hour_of_day": hour}

+ 1 - 1
examples/tencent_realtime_control/feishu_command_service.py

@@ -1,4 +1,4 @@
-"""Feishu WebSocket entry for safe advertising control commands."""
+"""安全广告控制命令的飞书 WebSocket 入口。"""
 
 from __future__ import annotations
 

+ 2 - 2
examples/tencent_realtime_control/feishu_notifier.py

@@ -1,4 +1,4 @@
-"""Send successful real-time control actions as a Feishu spreadsheet."""
+"""将成功执行的实时调控动作通过飞书表格发送。"""
 
 from __future__ import annotations
 
@@ -495,7 +495,7 @@ class FeishuNotifier:
         return url
 
     def import_spreadsheet(self, path: Path, *, readonly_link: bool = False) -> str:
-        """Upload a local xlsx and return the Feishu online spreadsheet URL."""
+        """上传本地 xlsx,并返回飞书在线表格地址。"""
         self._require_drive_config()
         token = self._tenant_token()
         file_token = self._upload(token, path)

+ 1 - 1
examples/tencent_realtime_control/fetch_daily_hourly_cpm.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Fetch one day's overall hourly CPM for real-time ad control."""
+"""读取单日整体小时 CPM,供广告实时调控使用。"""
 
 from __future__ import annotations
 

+ 1 - 1
examples/tencent_realtime_control/init_db.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Initialize real-time control state tables."""
+"""初始化实时调控状态表。"""
 
 from pathlib import Path
 

+ 1 - 1
examples/tencent_realtime_control/odps_source.py

@@ -1,4 +1,4 @@
-"""ODPS source for the overall hourly CPM signal."""
+"""整体小时 CPM 信号的 ODPS 数据源。"""
 
 from __future__ import annotations
 

+ 3 - 3
examples/tencent_realtime_control/operator_commands.py

@@ -1,4 +1,4 @@
-"""Deterministic parsing for Feishu advertising control commands."""
+"""飞书广告控制命令的确定性解析。"""
 
 from __future__ import annotations
 
@@ -94,7 +94,7 @@ def extract_account_ids(raw: str) -> tuple[int, ...]:
 
 
 def parse_deterministic_intent(raw: str) -> CommandIntent | None:
-    """Parse safe command forms, including incomplete commands for clarification."""
+    """解析安全命令格式,包括需要继续补问的不完整命令。"""
     text = normalize_text(raw)
     if not text:
         return None
@@ -132,7 +132,7 @@ def parse_deterministic_intent(raw: str) -> CommandIntent | None:
 
 
 def parse_scope_reply(raw: str) -> tuple[str, tuple[int, ...]] | None:
-    """Parse a scope-only answer used to complete an active conversation draft."""
+    """解析只包含范围的回答,用于补全当前多轮会话草稿。"""
     text = normalize_text(raw)
     if not text:
         return None

+ 1 - 1
examples/tencent_realtime_control/operator_control.py

@@ -1,4 +1,4 @@
-"""Preview and execute confirmed Feishu operator commands."""
+"""预览并执行已确认的飞书运营控制命令。"""
 
 from __future__ import annotations
 

+ 1 - 1
examples/tencent_realtime_control/realtime_config.py

@@ -1,4 +1,4 @@
-"""Runtime configuration for Tencent real-time control."""
+"""腾讯广告实时调控运行配置。"""
 
 from __future__ import annotations
 

+ 1 - 1
examples/tencent_realtime_control/revenue_forecast.py

@@ -1,4 +1,4 @@
-"""Revenue forecast domain values."""
+"""收入预测领域对象。"""
 
 from __future__ import annotations
 

+ 1 - 1
examples/tencent_realtime_control/revenue_forecast_config.py

@@ -1,4 +1,4 @@
-"""Runtime configuration for real-time revenue forecasting."""
+"""实时收入预测的运行配置。"""
 
 from __future__ import annotations
 

+ 1 - 1
examples/tencent_realtime_control/revenue_forecast_job.py

@@ -1,4 +1,4 @@
-"""One revenue forecast collection and calculation cycle."""
+"""单轮收入预测采集与计算任务。"""
 
 from __future__ import annotations
 

+ 1 - 1
examples/tencent_realtime_control/revenue_forecast_repository.py

@@ -1,4 +1,4 @@
-"""MySQL persistence for revenue observations and forecasts."""
+"""收入观测与预测结果的 MySQL 持久化。"""
 
 from __future__ import annotations
 

+ 26 - 4
examples/tencent_realtime_control/revenue_forecast_service.py

@@ -1,10 +1,11 @@
-"""Background scheduler for revenue forecast collection."""
+"""收入预测采集使用的后台调度服务。"""
 
 from __future__ import annotations
 
 import logging
 import threading
 import time
+import uuid
 from datetime import datetime, timedelta
 from zoneinfo import ZoneInfo
 
@@ -17,6 +18,8 @@ logger = logging.getLogger("tencent_realtime_control.revenue_forecast")
 
 
 def _next_wake(now: datetime, config: RevenueForecastConfig) -> datetime:
+    """计算收入预测线程的下一次执行时间。"""
+
     local_time = now.timetz().replace(tzinfo=None)
     if local_time > config.stop_time:
         return datetime.combine(
@@ -34,19 +37,31 @@ def _next_wake(now: datetime, config: RevenueForecastConfig) -> datetime:
 
 
 def run_revenue_forecast_forever(config: RevenueForecastConfig) -> None:
+    """持续执行收入预测,并为每一轮记录独立追踪信息。"""
+
     logger.info(
-        "Revenue forecast started hours=%s-%s interval=%ss version=%s",
+        "event=revenue_forecast_service stage=startup result=succeeded "
+        "hours=%s-%s interval_seconds=%s version=%s",
         config.start_time.strftime("%H:%M"),
         config.stop_time.strftime("%H:%M"),
         config.poll_seconds,
         config.forecast_version,
     )
     while True:
+        cycle_id = str(uuid.uuid4())
+        started = time.monotonic()
         try:
+            logger.info(
+                "event=revenue_forecast stage=run result=started cycle_id=%s",
+                cycle_id,
+            )
             payload = run_revenue_forecast_cycle(config=config)
             logger.info(
-                "Revenue forecast cycle status=%s report_time=%s revenue=%s "
+                "event=revenue_forecast stage=run result=succeeded cycle_id=%s "
+                "duration_ms=%d status=%s report_time=%s revenue=%s "
                 "target_cost=%s miniapp_target_cost=%s",
+                cycle_id,
+                int((time.monotonic() - started) * 1000),
                 payload.get("status"),
                 payload.get("report_time"),
                 payload.get("forecast_revenue"),
@@ -54,7 +69,12 @@ def run_revenue_forecast_forever(config: RevenueForecastConfig) -> None:
                 payload.get("miniapp_target_daily_cost"),
             )
         except Exception:
-            logger.exception("Revenue forecast cycle failed")
+            logger.exception(
+                "event=revenue_forecast stage=run result=failed cycle_id=%s "
+                "duration_ms=%d",
+                cycle_id,
+                int((time.monotonic() - started) * 1000),
+            )
 
         wake_at = _next_wake(datetime.now(SHANGHAI), config)
         while True:
@@ -67,6 +87,8 @@ def run_revenue_forecast_forever(config: RevenueForecastConfig) -> None:
 def start_revenue_forecast_service(
     config: RevenueForecastConfig,
 ) -> threading.Thread:
+    """启动守护线程并返回线程对象,便于服务启动测试和健康检查。"""
+
     thread = threading.Thread(
         target=run_revenue_forecast_forever,
         args=(config,),

+ 2 - 2
examples/tencent_realtime_control/revenue_forecast_source.py

@@ -1,4 +1,4 @@
-"""Read same-source 15-minute business revenue series from ODPS."""
+"""从 ODPS 读取同口径的 15 分钟业务收入序列。"""
 
 from __future__ import annotations
 
@@ -142,7 +142,7 @@ def fetch_interval_revenue_series(
     client: ODPS,
     data_dates: tuple[date, ...],
 ) -> dict[date, list[tuple[datetime, Decimal]]]:
-    """Return each date's deduplicated 15-minute `today` interval series."""
+    """返回各日期去重后的 15 分钟 ``today`` 区间序列。"""
     if not data_dates:
         return {}
     return _fetch_interval_today_series(client, data_dates)

+ 2 - 2
examples/tencent_realtime_control/revenue_speed_forecast.py

@@ -1,4 +1,4 @@
-"""Weighted revenue-speed forecast and versioned parameter calculation."""
+"""加权收入速度预测及版本化参数计算。"""
 
 from __future__ import annotations
 
@@ -43,7 +43,7 @@ class RevenueSpeedParameter:
 
 
 class SpeedForecastUnavailable(RuntimeError):
-    """The current snapshot cannot produce a weighted-speed forecast."""
+    """当前快照无法生成加权速度预测。"""
 
 
 def decimal_quantile(values: list[Decimal], percentile: Decimal) -> Decimal:

+ 31 - 12
examples/tencent_realtime_control/run_control_service.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Run Feishu controls and the ten-minute real-time control loop."""
+"""启动飞书控制入口、审批服务和实时调控循环。"""
 
 from __future__ import annotations
 
@@ -34,19 +34,28 @@ def _env_flag(name: str, default: bool = False) -> bool:
 
 
 def parse_args() -> argparse.Namespace:
+    """解析服务启动参数。"""
+
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument(
         "--apply",
         action="store_true",
-        help="Enable confirmed Feishu commands and real Tencent updates.",
+        help="允许已确认的飞书命令和实时调控执行腾讯写操作",
     )
     return parser.parse_args()
 
 
 def main() -> None:
     load_environment()
+    from logging_setup import setup_logging
+
+    setup_logging(level=os.getenv("LOG_LEVEL", "INFO"))
     args = parse_args()
     apply = args.apply or _env_flag("RTC_APPLY_ENABLED")
+    logger.info(
+        "event=control_service stage=startup result=started apply=%s",
+        apply,
+    )
     roi_config = RoiConfig.from_env()
     revenue_config = RevenueForecastConfig.from_env()
     initialize_schema()
@@ -58,28 +67,38 @@ def main() -> None:
         from feishu_command_service import FeishuCommandService
 
         FeishuCommandService(apply=apply).start()
-        logger.info("Feishu command WebSocket started")
+        logger.info(
+            "event=control_service component=feishu_command result=started"
+        )
     else:
-        logger.warning("Feishu command WebSocket is disabled")
+        logger.warning(
+            "event=control_service component=feishu_command result=disabled"
+        )
     if roi_config.sheet_approval_enabled:
         from roi_control.sheet_approval import RoiSheetApprovalService
 
         RoiSheetApprovalService().start()
-        logger.info("ROI sheet approval polling started")
+        logger.info(
+            "event=control_service component=roi_sheet_approval result=started"
+        )
     else:
-        logger.warning("ROI sheet approval polling is disabled")
+        logger.warning(
+            "event=control_service component=roi_sheet_approval result=disabled"
+        )
     if revenue_config.enabled:
         from revenue_forecast_service import start_revenue_forecast_service
 
         start_revenue_forecast_service(revenue_config)
     else:
-        logger.info("Revenue forecast is disabled")
+        logger.info(
+            "event=control_service component=revenue_forecast result=disabled"
+        )
     run_forever(apply=apply)
 
 
 if __name__ == "__main__":
-    logging.basicConfig(
-        level=os.getenv("LOG_LEVEL", "INFO"),
-        format="%(asctime)s %(levelname)s %(name)s %(message)s",
-    )
-    main()
+    try:
+        main()
+    except Exception:
+        logger.exception("event=control_service stage=run result=failed")
+        raise SystemExit(1)

+ 29 - 10
examples/tencent_realtime_control/run_once.py

@@ -1,11 +1,12 @@
 #!/usr/bin/env python
-"""Run one idempotent real-time CPM control cycle."""
+"""执行一次幂等的实时 CPM 调控循环。"""
 
 from __future__ import annotations
 
 import argparse
 import json
 import logging
+import os
 import time
 import uuid
 from datetime import datetime, timedelta
@@ -33,6 +34,8 @@ logger = logging.getLogger("tencent_realtime_control")
 
 
 def load_environment() -> None:
+    """按生产优先级加载自动投放与当前工作目录的环境变量。"""
+
     load_dotenv(ROOT.parent / "auto_put_ad_mini" / ".env", override=False)
     load_dotenv(Path.cwd() / ".env", override=False)
 
@@ -162,6 +165,8 @@ def target_for_ad(
 
 
 def write_run_report(payload: dict[str, Any]) -> Path:
+    """将本轮完整决策和执行结果写入本地 JSON 审计文件。"""
+
     output_dir = ROOT / "outputs"
     output_dir.mkdir(parents=True, exist_ok=True)
     timestamp = datetime.now(SHANGHAI).strftime("%Y%m%d_%H%M%S")
@@ -289,9 +294,8 @@ def execute_inventory_action(
                         action="OPERATOR_DAY_PAUSE_EXPIRED",
                         action_at=now,
                     )
-                # Legacy DAY_PAUSE rows used SUSPEND. Keep the old one-time
-                # recovery plan only for those rows; new rows stay NORMAL and
-                # resume automatically through begin_date/time_series.
+                # 旧 DAY_PAUSE 记录使用 SUSPEND,仅这些记录保留一次性恢复逻辑;
+                # 新记录维持 NORMAL,并通过 begin_date/time_series 自动恢复。
                 operator_pause_expired = current_status != ACTIVE_STATUS
 
             if operator_pause_active:
@@ -577,6 +581,8 @@ def run_cycle(
     force_refresh: bool = False,
     test_notification: bool = False,
 ) -> dict[str, Any]:
+    """在全局互斥锁内读取实时信号并执行一轮确定性调控。"""
+
     from odps_source import HourlyCpm, build_odps_client, fetch_latest_cpm
     from realtime_config import RealtimeControlConfig
     from storage import (
@@ -752,7 +758,15 @@ def parse_args() -> argparse.Namespace:
 
 def main() -> int:
     load_environment()
+    from logging_setup import setup_logging
+
+    setup_logging(level=os.getenv("LOG_LEVEL", "INFO"))
     args = parse_args()
+    started = time.monotonic()
+    logger.info(
+        "event=realtime_control stage=run result=started apply=%s",
+        args.apply,
+    )
     if args.apply and (args.at or args.cpm_override is not None):
         raise ValueError("--at and --cpm-override are forbidden with --apply")
     if args.apply and args.test_notification:
@@ -783,16 +797,21 @@ def main() -> int:
         "report": str(report),
     }
     print(json.dumps(compact, ensure_ascii=False))
+    logger.info(
+        "event=realtime_control stage=run result=%s duration_ms=%d "
+        "decision=%s failures=%s report=%s",
+        "partial_failure" if compact["failures"] else "succeeded",
+        int((time.monotonic() - started) * 1000),
+        compact["decision"],
+        compact["failures"],
+        compact["report"],
+    )
     return 1 if compact["failures"] else 0
 
 
 if __name__ == "__main__":
-    logging.basicConfig(
-        level=logging.INFO,
-        format="%(asctime)s %(levelname)s %(name)s %(message)s",
-    )
     try:
         raise SystemExit(main())
-    except Exception as exc:
-        logger.exception("Real-time control failed: %s", exc)
+    except Exception:
+        logger.exception("event=realtime_control stage=run result=failed")
         raise SystemExit(1)

+ 29 - 8
examples/tencent_realtime_control/run_revenue_forecast.py

@@ -1,11 +1,13 @@
 #!/usr/bin/env python
-"""Collect and calculate one real-time revenue forecast."""
+"""采集并计算一次实时收入预测。"""
 
 from __future__ import annotations
 
 import argparse
 import json
 import logging
+import os
+import time
 
 from revenue_forecast_job import run_revenue_forecast_cycle
 from run_once import load_environment
@@ -24,18 +26,37 @@ def parse_args() -> argparse.Namespace:
 
 def main() -> int:
     load_environment()
+    from logging_setup import setup_logging
+
+    setup_logging(level=os.getenv("LOG_LEVEL", "INFO"))
     args = parse_args()
-    initialize_schema()
-    payload = run_revenue_forecast_cycle(
-        ignore_runtime_window=args.ignore_runtime_window
+    logger = logging.getLogger("tencent_realtime_control.run_revenue_forecast")
+    started = time.monotonic()
+    logger.info(
+        "event=revenue_forecast_manual stage=run result=started "
+        "ignore_runtime_window=%s",
+        args.ignore_runtime_window,
     )
+    try:
+        initialize_schema()
+        payload = run_revenue_forecast_cycle(
+            ignore_runtime_window=args.ignore_runtime_window
+        )
+    except Exception:
+        logger.exception(
+            "event=revenue_forecast_manual stage=run result=failed duration_ms=%d",
+            int((time.monotonic() - started) * 1000),
+        )
+        raise
     print(json.dumps(payload, ensure_ascii=False))
+    logger.info(
+        "event=revenue_forecast_manual stage=run result=succeeded "
+        "duration_ms=%d status=%s",
+        int((time.monotonic() - started) * 1000),
+        payload.get("status"),
+    )
     return 0
 
 
 if __name__ == "__main__":
-    logging.basicConfig(
-        level=logging.INFO,
-        format="%(asctime)s %(levelname)s %(name)s %(message)s",
-    )
     raise SystemExit(main())

+ 32 - 6
examples/tencent_realtime_control/run_scheduler.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-"""Run real-time control every ten minutes during delivery hours."""
+"""在投放时段内按配置间隔循环执行实时调控。"""
 
 from __future__ import annotations
 
@@ -7,6 +7,7 @@ import argparse
 import logging
 import signal
 import time
+import uuid
 from datetime import datetime, timedelta
 from zoneinfo import ZoneInfo
 
@@ -20,12 +21,16 @@ stopping = False
 
 
 def request_stop(signum: int, _frame: object) -> None:
+    """收到退出信号后设置停止标记,让当前循环安全结束。"""
+
     global stopping
-    logger.info("Received signal %s; scheduler will stop.", signum)
+    logger.info("event=realtime_scheduler stage=signal result=stopping signal=%s", signum)
     stopping = True
 
 
 def next_wake(now: datetime, config: RealtimeControlConfig) -> datetime:
+    """计算下一次执行时间;投放时段外跳到下一有效起点。"""
+
     if now.hour >= config.stop_hour:
         tomorrow = now.date() + timedelta(days=1)
         return datetime.combine(
@@ -57,38 +62,59 @@ def parse_args() -> argparse.Namespace:
 
 
 def run_forever(*, apply: bool, install_signal_handlers: bool = True) -> None:
+    """持续执行实时调控,并确保单轮失败不会终止整个服务。"""
+
     config = RealtimeControlConfig.from_env()
     if install_signal_handlers:
         signal.signal(signal.SIGINT, request_stop)
         signal.signal(signal.SIGTERM, request_stop)
 
     logger.info(
-        "Scheduler started apply=%s hours=%02d:00-%02d:00 interval=%ss",
+        "event=realtime_scheduler stage=startup result=succeeded "
+        "apply=%s hours=%02d:00-%02d:00 interval_seconds=%s",
         apply,
         config.start_hour,
         config.stop_hour,
         config.poll_seconds,
     )
     while not stopping:
+        cycle_id = str(uuid.uuid4())
+        started = time.monotonic()
         try:
+            logger.info(
+                "event=realtime_cycle stage=run result=started cycle_id=%s apply=%s",
+                cycle_id,
+                apply,
+            )
             payload = run_cycle(
                 now=datetime.now(SHANGHAI),
                 apply=apply,
             )
             report = write_run_report(payload)
+            failures = (payload.get("summary") or {}).get("failures", 0)
             logger.info(
-                "Cycle decision=%s cpm=%s updates=%s failures=%s notification=%s report=%s",
+                "event=realtime_cycle stage=run result=%s cycle_id=%s "
+                "duration_ms=%d decision=%s cpm=%s updates=%s failures=%s "
+                "notification=%s report=%s",
+                "partial_failure" if failures else "succeeded",
+                cycle_id,
+                int((time.monotonic() - started) * 1000),
                 payload.get("decision"),
                 payload.get("observed_cpm"),
                 (payload.get("summary") or {}).get("api_updates", 0),
-                (payload.get("summary") or {}).get("failures", 0),
+                failures,
                 ((payload.get("summary") or {}).get("notification") or {}).get(
                     "status"
                 ),
                 report,
             )
         except Exception:
-            logger.exception("Real-time control cycle failed")
+            logger.exception(
+                "event=realtime_cycle stage=run result=failed cycle_id=%s "
+                "duration_ms=%d",
+                cycle_id,
+                int((time.monotonic() - started) * 1000),
+            )
 
         wake_at = next_wake(datetime.now(SHANGHAI), config)
         while not stopping:

+ 9 - 2
examples/tencent_realtime_control/schema.sql

@@ -229,6 +229,7 @@ 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,
+    agent_name VARCHAR(255) DEFAULT NULL,
     agency_name VARCHAR(255) DEFAULT NULL,
     adgroup_id BIGINT NOT NULL,
     adgroup_name VARCHAR(255) DEFAULT NULL,
@@ -236,6 +237,7 @@ CREATE TABLE IF NOT EXISTS creative_rejection_cleanup_item (
     dynamic_creative_name VARCHAR(255) DEFAULT NULL,
     check_date DATE NOT NULL,
     cleanup_action VARCHAR(32) DEFAULT NULL,
+    cleanup_rule_type VARCHAR(64) NOT NULL DEFAULT 'REVIEW_DENIED',
     target_component_ids_json LONGTEXT DEFAULT NULL,
     target_element_ids_json LONGTEXT DEFAULT NULL,
     recent_cost_fen BIGINT DEFAULT NULL,
@@ -243,6 +245,11 @@ CREATE TABLE IF NOT EXISTS creative_rejection_cleanup_item (
     cost_end_date DATE DEFAULT NULL,
     action_reason TEXT DEFAULT NULL,
     reject_reason TEXT NOT NULL,
+    creative_created_at DATETIME DEFAULT NULL,
+    creative_age_days INT DEFAULT NULL,
+    metric_impressions BIGINT DEFAULT NULL,
+    metric_daily_avg_impressions DECIMAL(20, 4) DEFAULT NULL,
+    metric_window_days INT DEFAULT NULL,
     review_result_json LONGTEXT DEFAULT NULL,
     pre_state_json LONGTEXT DEFAULT NULL,
     readback_json LONGTEXT DEFAULT NULL,
@@ -258,7 +265,7 @@ CREATE TABLE IF NOT EXISTS creative_rejection_cleanup_item (
         (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='拒审创意清理及部分投放创意报警审计';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意审核异常及长期未起量清理审计';
 
 CREATE TABLE IF NOT EXISTS creative_rejection_delivery (
     id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
@@ -282,7 +289,7 @@ CREATE TABLE IF NOT EXISTS creative_rejection_delivery (
     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='审核异常创意清理及人工报警通知审计';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意审核异常及长期未起量通知审计';
 
 CREATE TABLE IF NOT EXISTS realtime_control_action_log (
     id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,

+ 56 - 8
examples/tencent_realtime_control/storage.py

@@ -1,4 +1,4 @@
-"""MySQL state and audit storage for real-time control."""
+"""实时调控使用的 MySQL 状态与审计存储。"""
 
 from __future__ import annotations
 
@@ -16,6 +16,41 @@ import pymysql.cursors
 ROOT = Path(__file__).resolve().parent
 
 
+def _suppress_performance_agency_delivery(cursor) -> None:
+    """确保 PERFORMANCE 记录对当前及回滚版本的代理商读取链路均不可见。"""
+
+    cursor.execute(
+        """
+        UPDATE creative_rejection_cleanup_item
+        SET agency_name='',
+            agency_notified_at=COALESCE(agency_notified_at, NOW())
+        WHERE LEFT(cleanup_rule_type, 12)='PERFORMANCE_'
+          AND (
+              COALESCE(agency_name, '')<>''
+              OR agency_notified_at IS NULL
+          )
+        """
+    )
+
+
+def _backfill_legacy_review_rule_types(cursor) -> None:
+    """新增 cleanup_rule_type 后回填旧数据中的部分审核语义。"""
+
+    cursor.execute(
+        """
+        UPDATE creative_rejection_cleanup_item
+        SET cleanup_rule_type='REVIEW_PARTIAL'
+        WHERE cleanup_rule_type='REVIEW_DENIED'
+          AND (
+              cleanup_action='ALERT_ONLY'
+              OR pre_state_json LIKE
+                 '%CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL%'
+              OR action_reason LIKE '部分投放中%'
+          )
+        """
+    )
+
+
 def connect() -> pymysql.Connection:
     required = ["DB_HOST", "DB_USER", "DB_NAME"]
     missing = [key for key in required if not os.getenv(key)]
@@ -176,13 +211,24 @@ def initialize_schema() -> None:
                 },
                 "creative_rejection_cleanup_item": {
                     "check_date": "DATE DEFAULT NULL",
+                    "agent_name": "VARCHAR(255) DEFAULT NULL",
                     "cleanup_action": "VARCHAR(32) DEFAULT NULL",
+                    "cleanup_rule_type": (
+                        "VARCHAR(64) NOT NULL DEFAULT 'REVIEW_DENIED'"
+                    ),
                     "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",
+                    "creative_created_at": "DATETIME DEFAULT NULL",
+                    "creative_age_days": "INT DEFAULT NULL",
+                    "metric_impressions": "BIGINT DEFAULT NULL",
+                    "metric_daily_avg_impressions": (
+                        "DECIMAL(20, 4) DEFAULT NULL"
+                    ),
+                    "metric_window_days": "INT DEFAULT NULL",
                     "agency_notified_at": "DATETIME DEFAULT NULL",
                     "operator_notified_at": "DATETIME DEFAULT NULL",
                 },
@@ -236,6 +282,8 @@ def initialize_schema() -> None:
                             f"ALTER TABLE {table_name} "
                             f"ADD COLUMN {column} {definition}"
                         )
+            _backfill_legacy_review_rule_types(cursor)
+            _suppress_performance_agency_delivery(cursor)
             cursor.execute(
                 """
                 UPDATE creative_rejection_cleanup_item
@@ -460,7 +508,7 @@ def load_enabled_accounts() -> list[dict[str, Any]]:
 
 
 def load_automation_spend_accounts() -> list[dict[str, Any]]:
-    """Load historical automation accounts that remain whitelisted."""
+    """读取仍在白名单中的历史自动化账户。"""
     connection = connect()
     try:
         with connection.cursor() as cursor:
@@ -479,7 +527,7 @@ def load_automation_spend_accounts() -> list[dict[str, Any]]:
 
 
 def load_all_spend_accounts() -> list[dict[str, Any]]:
-    """Load every enabled account from the local account whitelist."""
+    """读取本地账户白名单中所有启用账户。"""
     connection = connect()
     try:
         with connection.cursor() as cursor:
@@ -497,7 +545,7 @@ def load_all_spend_accounts() -> list[dict[str, Any]]:
 
 
 def load_realtime_accounts() -> list[dict[str, Any]]:
-    """Load full-control automation accounts plus explicit extra scope."""
+    """读取完整调控的自动化账户与显式额外纳管账户。"""
     connection = connect()
     try:
         with connection.cursor() as cursor:
@@ -571,7 +619,7 @@ def upsert_realtime_account_scope(
 
 
 def load_managed_accounts() -> list[dict[str, Any]]:
-    """Historical automation accounts still allowed by the account whitelist."""
+    """返回账户白名单仍允许纳管的历史自动化账户。"""
     connection = connect()
     try:
         with connection.cursor() as cursor:
@@ -713,7 +761,7 @@ def sync_roi_base_bid(
     boosted_date: date | None,
     action_at: datetime,
 ) -> None:
-    """Persist a permanent ROI base bid without losing intraday boost state."""
+    """持久化永久 ROI 基础出价,同时保留日内扩量状态。"""
 
     connection = connect()
     try:
@@ -767,7 +815,7 @@ def set_bid_experiment_hold(
     action_at: datetime,
     reason: str,
 ) -> None:
-    """Persist an experiment base bid and prevent real-time bid overrides."""
+    """持久化实验基础出价,并阻止实时调控覆盖该出价。"""
 
     connection = connect()
     try:
@@ -923,7 +971,7 @@ def create_operator_command_with_items(
     record: dict[str, Any],
     items: list[dict[str, Any]],
 ) -> dict[str, Any]:
-    """Atomically persist an immutable command preview and its ad snapshots."""
+    """原子保存不可变的命令预览及其广告快照。"""
     connection = connect()
     try:
         connection.begin()

+ 316 - 102
examples/tencent_realtime_control/tencent_client.py

@@ -1,4 +1,4 @@
-"""Minimal Tencent Ads read/write client for real-time control."""
+"""实时调控使用的精简腾讯广告读写客户端。"""
 
 from __future__ import annotations
 
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
 
 
 class _CreativeDeleteRateLimiter:
-    """Process-wide start-rate limiter shared by all TencentClient instances."""
+    """所有 TencentClient 实例共享的进程级删除请求启动限速器。"""
 
     def __init__(self) -> None:
         self._condition = threading.Condition()
@@ -36,13 +36,12 @@ class _CreativeDeleteRateLimiter:
                 if delay <= 0:
                     self._next_allowed_at = now + min_interval_seconds
                     return
-                # Condition.wait releases the lock so a concurrent 429 can
-                # immediately extend the shared cooldown before this request
-                # is allowed to start.
+                # Condition.wait 会释放锁,使并发请求收到 429 后能在本请求启动前
+                # 立即延长全局冷却时间。
                 self._condition.wait(timeout=delay)
 
     def defer(self, delay_seconds: float) -> None:
-        """Apply a shared cooldown after any worker receives a rate limit."""
+        """任一工作线程遇到限流后,设置所有线程共享的冷却时间。"""
 
         if delay_seconds <= 0:
             return
@@ -58,7 +57,7 @@ _CREATIVE_DELETE_RATE_LIMITER = _CreativeDeleteRateLimiter()
 
 
 def _creative_delete_rate_limit_config() -> tuple[float, int, float]:
-    """Return (minimum interval, retries, backoff) for delete POST requests."""
+    """返回删除 POST 请求的最小间隔、重试次数和退避秒数。"""
 
     try:
         min_interval = float(os.getenv("TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS", "0.25"))
@@ -110,9 +109,8 @@ def _is_rate_limited_payload(payload: Any) -> bool:
     ]
     if "429" in codes:
         return True
-    # Text matching is only safe on an explicit error response. A successful
-    # delete may legally include informational text mentioning QPS or quotas;
-    # retrying that POST could repeat a write that already succeeded.
+    # 只有明确错误响应才能安全匹配文本;成功删除响应也可能包含 QPS 或配额说明,
+    # 若因此重试 POST,可能重复执行已经成功的写操作。
     if not codes or all(code == "0" for code in codes):
         return False
     values = [
@@ -146,17 +144,25 @@ ACTIVE_STATUS = "AD_STATUS_NORMAL"
 SUSPEND_STATUS = "AD_STATUS_SUSPEND"
 DELETED_STATUS = "AD_STATUS_DELETED"
 DYNAMIC_CREATIVE_DELETED_STATUS = "DYNAMIC_CREATIVE_STATUS_DELETED"
+ADGROUP_DELETED_SYSTEM_STATUSES = frozenset(
+    {
+        "ADGROUP_STATUS_DELETED",
+        "SMART_ADGROUP_STATUS_DELETED",
+    }
+)
 AD_FIELDS = [
     "adgroup_id",
     "adgroup_name",
     "configured_status",
     "system_status",
+    "is_deleted",
     "bid_amount",
     "custom_cost_cap",
     "smart_bid_type",
     "cost_constraint_scene",
     "begin_date",
     "end_date",
+    "created_time",
     "time_series",
 ]
 DYNAMIC_CREATIVE_FIELDS = [
@@ -170,8 +176,26 @@ DYNAMIC_CREATIVE_FIELDS = [
 COMPONENT_FIELDS = ["component_id", "component_type", "is_deleted"]
 
 
+def _is_true_api_value(value: Any) -> bool:
+    if value is True or value == 1:
+        return True
+    return str(value or "").strip().lower() in {"true", "1"}
+
+
+def is_deleted_ad(ad: Mapping[str, Any]) -> bool:
+    """判断 v3 广告回读结果是否通过显式字段表明广告已删除。"""
+
+    system_status = str(ad.get("system_status") or "").strip().upper()
+    return (
+        _is_true_api_value(ad.get("is_deleted"))
+        # 部分内部适配器使用 ODPS 风格的单数删除字段名。
+        or _is_true_api_value(ad.get("is_delete"))
+        or system_status in ADGROUP_DELETED_SYSTEM_STATUSES
+    )
+
+
 class PostWriteVerificationError(RuntimeError):
-    """Tencent accepted a write, but the read-back did not converge in time."""
+    """腾讯已接受写请求,但回读状态未在限定时间内收敛。"""
 
     def __init__(
         self,
@@ -193,19 +217,19 @@ class PostWriteVerificationError(RuntimeError):
 
 
 class TencentWriteNotSentError(RuntimeError):
-    """The request failed before Tencent received the write request."""
+    """写请求尚未送达腾讯前即失败。"""
 
 
 class TencentWriteRejectedError(RuntimeError):
-    """Tencent explicitly rejected the write request."""
+    """腾讯明确拒绝写请求。"""
 
 
 class TencentWriteRateLimitedError(TencentWriteRejectedError):
-    """Tencent explicitly rejected the write because of rate limiting."""
+    """腾讯因限流明确拒绝写请求。"""
 
 
 class TencentWriteOutcomeUnknownError(RuntimeError):
-    """The request may have reached Tencent, but no definitive result exists."""
+    """请求可能已到达腾讯,但无法确认最终写入结果。"""
 
 
 class TencentClient:
@@ -235,7 +259,7 @@ class TencentClient:
         self.session = requests.Session()
 
     def seed_access_tokens(self, tokens: Mapping[int, str]) -> None:
-        """Warm account access tokens fetched by the shared concurrent loader."""
+        """使用共享并发加载器预热账户 access token 缓存。"""
 
         for raw_account_id, raw_token in tokens.items():
             account_id = int(raw_account_id)
@@ -335,7 +359,7 @@ class TencentClient:
         return ads
 
     def get_ad(self, account_id: int, adgroup_id: int) -> dict[str, Any]:
-        params = {
+        base_params = {
             **self._common_params(account_id),
             "account_id": account_id,
             "fields": json.dumps(AD_FIELDS, ensure_ascii=False),
@@ -351,17 +375,23 @@ class TencentClient:
             "page": 1,
             "page_size": 10,
         }
-        response = self.session.get(
-            f"{self.base_url}/adgroups/get",
-            params=params,
-            timeout=self.timeout,
-        )
-        response.raise_for_status()
-        data = self._check(response.json(), "get_ad")
-        rows = data.get("list") or []
-        for row in rows:
-            if int(row.get("adgroup_id") or 0) == adgroup_id:
-                return row
+        # 账户级默认查询可能排除已删除广告。按 ID 精确查询无结果时,再显式查询
+        # 删除范围,让调用方根据 is_deleted/system_status 判断,而不是从空列表推断。
+        for include_deleted in (False, True):
+            params = dict(base_params)
+            if include_deleted:
+                params["is_deleted"] = True
+            response = self.session.get(
+                f"{self.base_url}/adgroups/get",
+                params=params,
+                timeout=self.timeout,
+            )
+            response.raise_for_status()
+            data = self._check(response.json(), "get_ad")
+            rows = data.get("list") or []
+            for row in rows:
+                if int(row.get("adgroup_id") or 0) == adgroup_id:
+                    return row
         raise RuntimeError(
             f"Ad not found after update: account={account_id} adgroup={adgroup_id}"
         )
@@ -455,6 +485,124 @@ class TencentClient:
             actual=last_actual,
         )
 
+    def delete_ad(self, account_id: int, adgroup_id: int) -> dict[str, Any]:
+        """调用腾讯广告专用删除接口,并通过回读校验结果。"""
+
+        min_interval, rate_limit_retries, rate_limit_backoff = (
+            _creative_delete_rate_limit_config()
+        )
+        try:
+            user_token = self._user_token(account_id)
+        except Exception as exc:
+            raise TencentWriteNotSentError(str(exc)) from exc
+        data: dict[str, Any] | None = None
+        for attempt in range(rate_limit_retries + 1):
+            _CREATIVE_DELETE_RATE_LIMITER.wait(min_interval)
+            try:
+                params = {
+                    **self._common_params(account_id),
+                    "user_token": user_token,
+                }
+            except Exception as exc:
+                raise TencentWriteNotSentError(str(exc)) from exc
+            try:
+                response = self.session.post(
+                    f"{self.base_url}/adgroups/delete",
+                    params=params,
+                    json={"account_id": account_id, "adgroup_id": adgroup_id},
+                    timeout=self.timeout,
+                )
+            except requests.RequestException as exc:
+                raise TencentWriteOutcomeUnknownError(str(exc)) from exc
+            if response.status_code == 429:
+                delay = _rate_limit_retry_after(response)
+                if delay is None:
+                    delay = min(rate_limit_backoff * (2**attempt), 60.0)
+                _CREATIVE_DELETE_RATE_LIMITER.defer(delay)
+                if attempt < rate_limit_retries:
+                    logger.warning(
+                        "Tencent ad delete rate-limited account=%s ad=%s "
+                        "attempt=%d/%d retry_in=%.2fs",
+                        account_id,
+                        adgroup_id,
+                        attempt + 1,
+                        rate_limit_retries + 1,
+                        delay,
+                    )
+                    continue
+                raise TencentWriteRateLimitedError(
+                    f"Tencent HTTP 429: {response.text[:500]}"
+                )
+            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()
+                payload = response.json()
+            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
+            if not isinstance(payload, dict):
+                raise TencentWriteOutcomeUnknownError(
+                    "Tencent returned a non-object JSON response: "
+                    f"{response.text[:500]}"
+                )
+            if _is_rate_limited_payload(payload):
+                delay = _rate_limit_retry_after(response)
+                if delay is None:
+                    delay = min(rate_limit_backoff * (2**attempt), 60.0)
+                _CREATIVE_DELETE_RATE_LIMITER.defer(delay)
+                if attempt < rate_limit_retries:
+                    continue
+                raise TencentWriteRateLimitedError(
+                    f"delete_ad rate limited after retries: {payload}"
+                )
+            try:
+                data = self._check(payload, "delete_ad")
+            except RuntimeError as exc:
+                raise TencentWriteRejectedError(str(exc)) from exc
+            break
+        if data is None:
+            raise TencentWriteRejectedError(
+                "delete_ad did not return a definitive response"
+            )
+
+        last_actual: dict[str, Any] = {}
+        for attempt in range(1, self.verify_attempts + 1):
+            try:
+                ad = self.get_ad(account_id, adgroup_id)
+                last_actual = {
+                    "configured_status": ad.get("configured_status"),
+                    "system_status": ad.get("system_status"),
+                    "is_deleted": ad.get("is_deleted"),
+                    "is_delete": ad.get("is_delete"),
+                    "adgroup_name": ad.get("adgroup_name"),
+                }
+                if is_deleted_ad(ad):
+                    return {
+                        **data,
+                        "adgroup_id": adgroup_id,
+                        **last_actual,
+                        "deleted_from_listing": False,
+                    }
+            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=adgroup_id,
+            expected={
+                "is_deleted": True,
+                "system_status": sorted(ADGROUP_DELETED_SYSTEM_STATUSES),
+            },
+            actual=last_actual,
+        )
+
     def get_dynamic_creative(
         self,
         account_id: int,
@@ -492,7 +640,7 @@ class TencentClient:
         )
 
     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
@@ -590,7 +738,7 @@ class TencentClient:
         account_id: int,
         dynamic_creative_id: int,
     ) -> dict[str, Any]:
-        """Delete a creative through Tencent's dedicated delete endpoint."""
+        """调用腾讯创意专用删除接口删除动态创意。"""
 
         min_interval, rate_limit_retries, rate_limit_backoff = (
             _creative_delete_rate_limit_config()
@@ -608,9 +756,8 @@ class TencentClient:
         for attempt in range(rate_limit_retries + 1):
             _CREATIVE_DELETE_RATE_LIMITER.wait(min_interval)
             try:
-                # Refresh timestamp/nonce on every explicit rate-limit retry;
-                # Tencent may reject a reused signed request even after the
-                # rate-limit window has elapsed.
+                # 每次限流重试都刷新 timestamp/nonce;即使限流窗口已经结束,腾讯
+                # 仍可能拒绝重复使用签名参数的请求。
                 params = {
                     **self._common_params(account_id),
                     "user_token": user_token,
@@ -625,18 +772,16 @@ class TencentClient:
                     timeout=self.timeout,
                 )
             except requests.RequestException as exc:
-                # The request may have reached Tencent.  Retrying a POST here
-                # could issue a duplicate delete, so let the next scheduled run
-                # resolve it by read-back.
+                # 请求可能已经到达腾讯,此处重试 POST 可能造成重复删除,因此交由
+                # 下一次定时任务通过回读状态收敛。
                 raise TencentWriteOutcomeUnknownError(str(exc)) from exc
 
             if response.status_code == 429:
                 delay = _rate_limit_retry_after(response)
                 if delay is None:
                     delay = min(rate_limit_backoff * (2**attempt), 60.0)
-                # Publish every explicit limit, including the final exhausted
-                # attempt, so other delete workers do not immediately continue
-                # hitting the same Tencent quota window.
+                # 包括最后一次重试耗尽在内,每次明确限流都更新共享冷却时间,避免
+                # 其他删除线程立即继续撞上同一个腾讯配额窗口。
                 _CREATIVE_DELETE_RATE_LIMITER.defer(delay)
                 if attempt < rate_limit_retries:
                     logger.warning(
@@ -813,7 +958,7 @@ class TencentClient:
         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)
@@ -970,68 +1115,99 @@ class TencentClient:
         adgroup_ids: list[int],
         data_date: date,
     ) -> dict[int, dict[str, int]]:
+        return self.get_ad_metrics(
+            account_id,
+            adgroup_ids,
+            data_date,
+            data_date,
+        )
+
+    def get_ad_metrics(
+        self,
+        account_id: int,
+        adgroup_ids: list[int],
+        start_date: date,
+        end_date: date,
+    ) -> dict[int, dict[str, int]]:
+        """返回日期范围内的广告级汇总,并为无数据广告显式补零。"""
+
         if not adgroup_ids:
             return {}
 
-        metrics: dict[int, dict[str, int]] = {}
-        page = 1
-        while True:
-            params = {
-                **self._common_params(account_id),
-                "account_id": account_id,
-                "level": "REPORT_LEVEL_ADGROUP",
-                "date_range": json.dumps(
-                    {
-                        "start_date": data_date.isoformat(),
-                        "end_date": data_date.isoformat(),
-                    }
-                ),
-                "group_by": json.dumps(["adgroup_id", "date"]),
-                "fields": json.dumps(
-                    [
-                        "account_id",
-                        "adgroup_id",
-                        "date",
-                        "view_count",
-                        "valid_click_count",
-                        "cost",
-                        "conversions_count",
-                    ]
-                ),
-                "filtering": json.dumps(
-                    [
+        normalized_ids = list(
+            dict.fromkeys(int(value) for value in adgroup_ids if int(value) > 0)
+        )
+        metrics = {
+            adgroup_id: {
+                "cost_fen": 0,
+                "impressions": 0,
+                "clicks": 0,
+                "conversions": 0,
+            }
+            for adgroup_id in normalized_ids
+        }
+        for offset in range(0, len(normalized_ids), 100):
+            batch = normalized_ids[offset : offset + 100]
+            page = 1
+            while batch:
+                params = {
+                    **self._common_params(account_id),
+                    "account_id": account_id,
+                    "level": "REPORT_LEVEL_ADGROUP",
+                    "date_range": json.dumps(
                         {
+                            "start_date": start_date.isoformat(),
+                            "end_date": end_date.isoformat(),
+                        }
+                    ),
+                    "group_by": json.dumps(["adgroup_id", "date"]),
+                    "fields": json.dumps(
+                        [
+                            "account_id",
+                            "adgroup_id",
+                            "date",
+                            "view_count",
+                            "valid_click_count",
+                            "cost",
+                            "conversions_count",
+                        ]
+                    ),
+                    "filtering": json.dumps(
+                        [{
                             "field": "adgroup_id",
                             "operator": "IN",
-                            "values": [str(value) for value in adgroup_ids],
-                        }
-                    ]
-                ),
-                "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_today_ad_metrics")
-            for row in data.get("list") or []:
-                adgroup_id = int(row.get("adgroup_id") or 0)
-                if adgroup_id <= 0:
-                    continue
-                metrics[adgroup_id] = {
-                    "cost_fen": int(row.get("cost") or 0),
-                    "impressions": int(row.get("view_count") or 0),
-                    "clicks": int(row.get("valid_click_count") or 0),
-                    "conversions": int(row.get("conversions_count") or 0),
+                            "values": [str(value) for value in batch],
+                        }]
+                    ),
+                    "page": page,
+                    "page_size": 100,
+                    "time_line": "REQUEST_TIME",
                 }
-            page_info = data.get("page_info") or {}
-            if page >= int(page_info.get("total_page") or 1):
-                break
-            page += 1
+                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_ad_metrics")
+                for row in data.get("list") or []:
+                    adgroup_id = int(row.get("adgroup_id") or 0)
+                    if adgroup_id not in metrics:
+                        continue
+                    metrics[adgroup_id]["cost_fen"] += int(row.get("cost") or 0)
+                    metrics[adgroup_id]["impressions"] += int(
+                        row.get("view_count") or 0
+                    )
+                    metrics[adgroup_id]["clicks"] += int(
+                        row.get("valid_click_count") or 0
+                    )
+                    metrics[adgroup_id]["conversions"] += int(
+                        row.get("conversions_count") or 0
+                    )
+                page_info = data.get("page_info") or {}
+                if page >= int(page_info.get("total_page") or 1):
+                    break
+                page += 1
         return metrics
 
     def get_dynamic_creative_costs(
@@ -1041,14 +1217,41 @@ class TencentClient:
         start_date: date,
         end_date: date,
     ) -> dict[int, int]:
-        """Return Tencent creative-level cost totals in fen for a date range."""
+        """返回日期范围内腾讯创意级消耗汇总,单位为分。"""
+
+        metrics = self.get_dynamic_creative_metrics(
+            account_id,
+            dynamic_creative_ids,
+            start_date,
+            end_date,
+        )
+        return {
+            creative_id: int(values["cost_fen"])
+            for creative_id, values in metrics.items()
+        }
+
+    def get_dynamic_creative_metrics(
+        self,
+        account_id: int,
+        dynamic_creative_ids: list[int],
+        start_date: date,
+        end_date: date,
+    ) -> dict[int, dict[str, int]]:
+        """返回日期范围内的创意级曝光和消耗汇总。
+
+        报表缺失的创意会显式补零;但接口请求失败仍表示数据未知,调用方不能将其
+        当作零投放处理。
+        """
 
         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}
+        metrics = {
+            creative_id: {"impressions": 0, "cost_fen": 0}
+            for creative_id in creative_ids
+        }
         for offset in range(0, len(creative_ids), 100):
             batch = creative_ids[offset : offset + 100]
             page = 1
@@ -1065,7 +1268,13 @@ class TencentClient:
                     ),
                     "group_by": json.dumps(["dynamic_creative_id", "date"]),
                     "fields": json.dumps(
-                        ["account_id", "dynamic_creative_id", "date", "cost"]
+                        [
+                            "account_id",
+                            "dynamic_creative_id",
+                            "date",
+                            "view_count",
+                            "cost",
+                        ]
                     ),
                     "filtering": json.dumps(
                         [
@@ -1088,17 +1297,22 @@ class TencentClient:
                 response.raise_for_status()
                 data = self._check(
                     response.json(),
-                    "get_dynamic_creative_costs",
+                    "get_dynamic_creative_metrics",
                 )
                 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)
+                    if creative_id in metrics:
+                        metrics[creative_id]["impressions"] += int(
+                            row.get("view_count") or 0
+                        )
+                        metrics[creative_id]["cost_fen"] += 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
+        return metrics
 
     def get_today_account_metrics(
         self,

+ 1 - 1
examples/tencent_realtime_control/today_spend_query.py

@@ -1,4 +1,4 @@
-"""Read-only Tencent daily spend summaries for Feishu commands."""
+"""为飞书命令只读查询腾讯当日消耗汇总。"""
 
 from __future__ import annotations
 

+ 23 - 0
runtime.env.example

@@ -122,6 +122,29 @@ CREATIVE_REVIEW_BATCH_SIZE=100
 
 DAILY_REJECTED_CREATIVE_CLEANUP_ENABLED=0
 DAILY_REJECTED_CREATIVE_APPLY_ENABLED=0
+# 同一清理任务内先执行审核异常,再执行长期未起量;已删除或审核已命中的创意不会重复判断。
+# 未起量范围取 ad_put_tencent_creative_day 最新 is_delete=0 创意;新素材累计指标含今日,
+# 老素材另查当日消耗;当日有消耗时不删除。
+# 账户名称及内部 Excel 代理商昵称取 ad_put_tencent_account 最新 is_delete=0 记录。
+# 未起量结果只单独通知 FEISHU_AD_PROJECT_CHAT_ID,绝不发代理群。
+DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=0
+DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED=0
+DAILY_UNDERPERFORMING_CREATIVE_NEW_MIN_AGE_DAYS=5
+DAILY_UNDERPERFORMING_CREATIVE_NEW_MAX_AGE_DAYS=7
+DAILY_UNDERPERFORMING_CREATIVE_NEW_IMPRESSIONS_THRESHOLD=100
+DAILY_UNDERPERFORMING_CREATIVE_OLD_DAILY_IMPRESSIONS_THRESHOLD=100
+DAILY_UNDERPERFORMING_CREATIVE_WINDOW_DAYS=7
+# 全部创意判断结束后,再判断创建>5天、近3个完整日及当日消耗都为0的广告。
+# 广告扫描要求 DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=1。
+DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED=0
+DAILY_UNDERPERFORMING_AD_APPLY_ENABLED=0
+DAILY_UNDERPERFORMING_AD_WINDOW_DAYS=3
+DAILY_UNDERPERFORMING_AD_MIN_AGE_DAYS=5
+# 大批量候选复用事务连接并按批次并发落库;小批量保留原逐条路径。
+DAILY_CLEANUP_CANDIDATE_BATCH_MIN_SIZE=20
+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_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN=100
 DAILY_REJECTED_CREATIVE_CLEANUP_HOUR=11

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.