Jelajahi Sumber

feat(creation): automate account delivery setup

刘立冬 1 Minggu lalu
induk
melakukan
bb0ea0b786

+ 65 - 0
AGENTS.md

@@ -0,0 +1,65 @@
+# Agent 项目级改代码原则
+
+## 适用范围
+
+本仓库包含生产级自动投放系统。凡是修改 `examples/auto_put_ad_mini` 下的代码,都要按生产自动化系统处理,不能当成一次性脚本。
+
+## 生产自动化原则
+
+- 优先使用数据库和飞书配置,避免硬编码账户级参数。
+- 人工配置应尽量只包含账户 ID、人群包名称、出价、预算、是否执行。
+- 每日自动流程应根据腾讯侧当前状态自行判断是否需要创建广告或补创意。
+- 飞书配置行关闭时,只代表不再新建广告或补创意,不能因此暂停、删除或改动既有广告。
+- 尽量保持幂等。重复运行时应跳过已完成的工作,只补缺口。
+
+## 模块 B 创意创建规则
+
+- 目标是单广告最终合格创意数,不是单次生成的 pending 行数。
+- 当前目标:每条广告最终有效创意不少于 4 个。
+- `DENIED` 创意不计入有效创意。
+- 每次创意准备中,每个视频来源最多获取并尝试 100 条视频。
+- 先尝试 primary 视频来源。
+- 如果 primary 视频经过品类过滤、风险审核、素材质量过滤后仍无法产出可用 pending creative,再用同一个 `crowdPackage` 尝试 `source=hot`。
+- `source=hot` 只能改变内容服务的视频来源,不能改变账户配置的人群包。
+- hot 兜底必须走和 primary 完全一致的品类过滤、风险审核、素材召回和审批流程。
+
+## 承接视频安全和质量
+
+- 创建落地计划或腾讯创意前,必须先做视频风险审核。
+- 默认风险阈值是 1。风险等级 2-10 应拦截。
+- 风险接口异常时要保守处理,不能创建落地计划。
+- 使用 `videoContentList.category` 做内容品类过滤。
+- 默认过滤品类是 `早中晚好`、`祝福音乐`。
+- 品类过滤必须发生在风险审核、素材召回和 `xcx/save` 之前。
+- 不要把 `categoryName` 当作内容品类使用;它是召回逻辑使用的语义/视觉特征字段。
+
+## 素材召回规则
+
+- 除非用户明确要求,不要随意降低素材质量门槛。
+- 当前素材门槛:`impressions > 2000`、`ctr >= 0.10`、统计窗口 180 天。
+- 视频可以通过风险审核,但仍然因为没有素材满足质量门槛而不能被选中。
+- 不能为了凑满创意数量而静默降低素材质量阈值。
+
+## 腾讯人群包和资产规则
+
+- 创建广告前,人群包 ID 必须验证为目标广告账户可见。
+- 跨账号使用人群包时,要走腾讯人群授权接口,授权后再次验证目标账户可见。
+- 品牌图 ID 是账户级资产,不能复用其他账户的 `brand_image_id`。
+- 监测链接 `feedback_id` 是账户级资产,应通过配置模板查找或创建。
+
+## 飞书同步规则
+
+- 飞书配置同步是每日主流程的一部分。
+- 飞书同步失败时应停止创建流程。
+- `是否自动化执行` 不等于 `是` 时,只关闭新建/补量,不删除 DB 配置,不修改腾讯既有资产。
+- 不要因为飞书行被关闭就删除或回滚线上状态。
+
+## 代码修改纪律
+
+- 修改范围要收敛在当前生产链路相关模块内。
+- 运营可能需要调整的生产行为,应提供配置项。
+- 属于账户、人群包、预算、出价、品牌、监测链接的值,优先进入飞书或 DB,不要写死在代码里。
+- 端到端执行前,必须意识到可能产生外部副作用:腾讯广告/创意创建、图片上传、DataNexus 监测链接创建、人群包授权、`xcx/save` 落地计划创建。
+- 端到端流程中断时,已经完成的外部副作用不会自动回滚。
+- 行为、环境变量或运营流程变化时,要同步更新面向生产的文档。
+

+ 181 - 0
examples/auto_put_ad_mini/PRODUCTION_AUTOMATION.md

@@ -0,0 +1,181 @@
+# auto_put_ad_mini 生产自动投放系统说明
+
+## 目标
+
+本系统用于服务端 Docker 环境每日自动扫描投放配置,完成腾讯广告自动搭建与创意补量。
+
+人工只维护待投放配置,主流程自动完成:
+
+- 从飞书配置表同步待投放账户
+- 校验账户白名单
+- 校验/授权腾讯人群包
+- 创建缺口广告
+- 给广告补足创意
+- 视频风险审核
+- 内容品类过滤
+- 素材召回与质量过滤
+- 飞书审批
+- 审批通过后创建腾讯动态创意
+
+## 人工配置入口
+
+当前生产配置来自飞书表 `自动化账户`。
+
+每个账户只需要配置:
+
+- 账户 ID
+- 人群包名称
+- 初始出价
+- 单广告预算
+- 是否自动化执行
+
+当 `是否自动化执行=是` 时,每日主流程会扫描并处理该账户。为否时,系统不再为该账户新建广告或补创意,但不会暂停或删除既有广告。
+
+## 生产目标
+
+当前单广告最终有效创意目标:
+
+```text
+TARGET_CREATIVES_PER_AD = 4
+```
+
+含义:
+
+- 每条广告最终有效创意数不少于 4 个
+- 已有有效创意会计入目标
+- `DENIED` 创意不计入有效创意
+- 差几个补几个
+- 补充创意会先进入飞书审批,审批通过后才调用腾讯创建接口
+
+## 承接视频选择
+
+每次为一条广告准备一条创意时,视频最多扫描:
+
+```text
+primary source: 最多 100 条
+hot source: 最多 100 条
+```
+
+执行顺序:
+
+1. 先用账户配置的人群包拉主池视频
+2. 主池 100 条经过过滤、风险审核、素材召回后仍无法产出可用创意时,再用同一个人群包拉 `source=hot`
+3. hot 池同样最多 100 条
+4. hot 池同样走风险审核、品类过滤、素材质量过滤
+
+注意: `source=hot` 只改变内容服务的视频来源,`crowdPackage` 仍然传当前账户配置的人群包,不会固定成其他人群包。
+
+## 视频过滤规则
+
+内容服务 `videoContentList` 返回的 `category` 字段会用于内容品类过滤。
+
+默认过滤:
+
+```text
+LANDING_EXCLUDED_CATEGORIES=早中晚好,祝福音乐
+```
+
+命中后,该视频会在以下步骤前被跳过:
+
+- 视频风险审核
+- 素材召回
+- xcx/save 落地计划创建
+
+可通过 Docker/K8s 环境变量覆盖:
+
+```bash
+LANDING_EXCLUDED_CATEGORIES=早中晚好,祝福音乐
+```
+
+多个品类用英文逗号分隔。
+
+## 视频风险审核
+
+承接视频在创建落地计划前调用风险接口:
+
+```text
+POST https://longvideoapi.piaoquantv.com/longvideoapi/openapi/video/getVideoTagIds
+```
+
+当前阈值:
+
+```text
+VIDEO_RISK_MAX_ALLOWED_LEVEL=1
+```
+
+规则:
+
+- 风险等级 0/1 通过
+- 风险等级 2-10 拦截
+- 风险接口异常时按不通过处理,避免风险未知的视频进入投放
+
+## 素材召回质量门槛
+
+素材召回仍使用现有质量阈值:
+
+```text
+RECALL_MIN_IMPRESSIONS=2000
+RECALL_MIN_CTR=0.10
+RECALL_DAYS=180
+RECALL_DISPLAY_K=30
+```
+
+素材必须同时满足:
+
+- 曝光 `impressions > 2000`
+- CTR `>= 10%`
+- 封面 URL 不命中已知黑名单
+
+## 热门兜底配置
+
+热门兜底由以下环境变量控制:
+
+```bash
+PIAOQUANTV_HOT_FALLBACK_ENABLED=true
+PIAOQUANTV_HOT_FALLBACK_SOURCE=hot
+```
+
+生产默认开启。
+
+主池不足和主池无法产出可用创意是两个不同口径:
+
+- 视频列表不足: 主池返回条数少于请求条数时,视频获取层可补 hot
+- 创意产出不足: 主池 100 条无法产出 pending creative 时,创意准备层继续跑 hot 100 条
+
+生产投放以“最终有效创意数达到目标”为准。
+
+## Docker/K8s 必配环境变量
+
+除数据库、飞书、腾讯、ODPS 等基础密钥外,生产自动投放建议显式配置:
+
+```bash
+PIAOQUANTV_TOKEN=...
+PIAOQUANTV_VIDEO_TYPE=5
+PIAOQUANTV_VIDEO_SOURCE=prior
+PIAOQUANTV_HOT_FALLBACK_ENABLED=true
+PIAOQUANTV_HOT_FALLBACK_SOURCE=hot
+LANDING_EXCLUDED_CATEGORIES=早中晚好,祝福音乐
+VIDEO_RISK_MAX_ALLOWED_LEVEL=1
+TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID=55615440
+TENCENT_AUDIENCE_GRANT_BUSINESS_ID=12312
+```
+
+## 当前已知限制
+
+- `R330` 账户如果源账户无法解析到 ONLINE/SUCCESS 人群包,Phase 0 会跳过该账户。
+- `泛人群` 主池可能返回 0,需要依赖 `source=hot` 兜底。
+- 当前素材质量门槛较高,部分安全视频会因素材曝光/CTR 不达标而无法产出创意。
+- 每次 pending creative 准备会调用 `xcx/save` 生成落地计划;端到端测试中断不会自动清理已生成的落地计划。
+
+## 上线检查
+
+上线前至少确认:
+
+- 飞书配置表可读
+- `是否自动化执行` 只对目标账户开启
+- 目标账户在 `account_whitelist` 启用
+- 人群包名称可在源账户解析并授权到目标广告账户
+- 品牌图和监测链接模板可自动初始化
+- Docker/K8s 环境变量与本文件一致
+- 手动跑一次 `execute_creation_once.py` 能生成飞书审批表
+

+ 423 - 31
examples/auto_put_ad_mini/config.py

@@ -10,6 +10,7 @@
 import os
 import logging
 from pathlib import Path
+from typing import Optional
 from agent.core.runner import RunConfig, KnowledgeConfig
 
 # 初始化 logger(必须在使用前定义)
@@ -276,16 +277,171 @@ if EXPERIMENTAL_SCOPE_ACCOUNTS is not None:
     )
 
 # ═══════════════════════════════════════════
-# 一账一包映射(账户级 audience pack · 2026-06-05 业务确认
+# 待投放账户配置(DB 单一真相源
 # ═══════════════════════════════════════════
-# 用户明确约束:一个账户只用一个人群包,即使有多个广告也不变
-# audience_pack_id 由运营提供;LLM 不参与选择
-# 字段格式:account_id -> (audience_pack_id, audience_tier_label)
-# - audience_pack_id 为 None 表示该账户不使用人群包(仅靠地域/年龄/性别自然定向)
-ACCOUNT_AUDIENCE_PACK_MAPPING = {
-    83846793: (None, "no_audience_pack"),       # 不传 custom_audience,按"泛人群"出价
-    83846804: (44722088, "R330+"),              # Q-R_330+_WX_UNIONID_2026-06-02 (865 万人)
-}
+# 人工只配置:account_id / audience_name / bid_min_fen / bid_max_fen / age_min / age_max / delivery_version。
+# 系统自动解析 audience_pack_id、授权并验证目标账户可见后再创建广告。
+# - audience_name="泛人群" 表示不使用 custom_audience,无需授权。
+# - bid_amount_fen 若为空,按 bid_min_fen/bid_max_fen 和 BID_PICK_STRATEGY 取值。
+def get_account_creation_config(account_id: int) -> dict:
+    """从 DB 读取账户级广告创建配置。
+
+    Returns:
+        {
+          account_id, audience_name, audience_pack_id,
+          audience_tier_label, bid_amount_fen,
+          bid_min_fen, bid_max_fen, age, delivery_version, ...
+        }
+
+    Raises:
+        ValueError: 账户未配置、未启用,或缺少必要人工配置。
+    """
+    try:
+        from db.connection import get_connection
+    except Exception as e:
+        raise ValueError(
+            f"无法读取 account_id {account_id} 的创建配置: DB 模块不可用({e})"
+        ) from e
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT c.account_id, c.enabled, c.delivery_version,
+                       c.audience_name, c.audience_pack_id,
+                       c.audience_tier_label, c.bid_min_fen, c.bid_max_fen,
+                       c.bid_amount_fen, c.age_min, c.age_max,
+                       c.daily_budget_fen AS account_daily_budget_fen,
+                       t.site_set_json, t.location_types_json, t.region_ids_json,
+                       t.daily_budget_fen, t.time_series_json,
+                       w.enabled AS whitelist_enabled
+                FROM ad_creation_account_config c
+                JOIN account_whitelist w ON w.account_id = c.account_id
+                JOIN ad_delivery_template t
+                  ON t.delivery_version = c.delivery_version
+                 AND t.enabled = TRUE
+                WHERE c.account_id=%s
+                """,
+                (account_id,),
+            )
+            row = cur.fetchone()
+    finally:
+        conn.close()
+
+    if not row:
+        raise ValueError(
+            f"account_id {account_id} 未写入 ad_creation_account_config,不能创建广告"
+        )
+    if not row.get("whitelist_enabled"):
+        raise ValueError(
+            f"account_id {account_id} 未在 account_whitelist 启用,不能创建广告"
+        )
+    if not row.get("enabled"):
+        raise ValueError(
+            f"account_id {account_id} 在 ad_creation_account_config 中未启用,不能创建广告"
+        )
+
+    audience_name = (row.get("audience_name") or "").strip()
+    if not audience_name:
+        raise ValueError(
+            f"account_id {account_id} 缺少 audience_name。"
+            "请先人工配置待投放人群包名称;泛人群账户写 audience_name='泛人群'"
+        )
+
+    bid_amount_fen = row.get("bid_amount_fen")
+    bid_min_fen = row.get("bid_min_fen")
+    bid_max_fen = row.get("bid_max_fen")
+    if bid_amount_fen is None:
+        if bid_min_fen is None or bid_max_fen is None:
+            raise ValueError(
+                f"account_id {account_id} 缺少 bid_amount_fen 或 bid_min_fen/bid_max_fen"
+            )
+        if int(bid_min_fen) > int(bid_max_fen):
+            raise ValueError(
+                f"account_id {account_id} 出价范围非法:"
+                f" bid_min_fen={bid_min_fen} > bid_max_fen={bid_max_fen}"
+            )
+        if BID_PICK_STRATEGY == "max":
+            bid_amount_fen = int(bid_max_fen)
+        elif BID_PICK_STRATEGY == "min":
+            bid_amount_fen = int(bid_min_fen)
+        else:
+            bid_amount_fen = int(round((int(bid_min_fen) + int(bid_max_fen)) / 2))
+
+    pack_id = row.get("audience_pack_id")
+    age_min = row.get("age_min")
+    age_max = row.get("age_max")
+    if age_min is None or age_max is None:
+        raise ValueError(
+            f"account_id {account_id} 缺少 age_min/age_max"
+        )
+    if int(age_min) > int(age_max):
+        raise ValueError(
+            f"account_id {account_id} 年龄范围非法: age_min={age_min} > age_max={age_max}"
+        )
+
+    import json as _json
+    site_set = _json.loads(row["site_set_json"])
+    location_types = _json.loads(row["location_types_json"])
+    region_ids = _json.loads(row["region_ids_json"])
+    time_series = _json.loads(row["time_series_json"])
+
+    return {
+        "account_id": int(row["account_id"]),
+        "delivery_version": row["delivery_version"],
+        "audience_name": audience_name,
+        "audience_pack_id": int(pack_id) if pack_id is not None else None,
+        "audience_tier_label": row.get("audience_tier_label") or audience_name,
+        "bid_min_fen": int(bid_min_fen) if bid_min_fen is not None else None,
+        "bid_max_fen": int(bid_max_fen) if bid_max_fen is not None else None,
+        "bid_amount_fen": int(bid_amount_fen),
+        "age": [{"min": int(age_min), "max": int(age_max)}],
+        "site_set": site_set,
+        "location_types": location_types,
+        "region_ids": region_ids,
+        "daily_budget_fen": int(row.get("account_daily_budget_fen") or row["daily_budget_fen"]),
+        "time_series": time_series,
+    }
+
+
+def get_account_audience_pack(account_id: int) -> tuple[Optional[int], str]:
+    """兼容旧调用:返回 (audience_pack_id, audience_tier_label)。"""
+    cfg = get_account_creation_config(account_id)
+    return cfg["audience_pack_id"], cfg["audience_tier_label"]
+
+
+def get_creation_account_ids() -> list[int]:
+    """读取当前启用的待投放账户,作为 Phase 0 创建广告的账户范围。"""
+    try:
+        from db.connection import get_connection
+    except Exception as e:
+        logger.warning("读取待投放账户失败:DB 模块不可用(%s)", e)
+        return []
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT c.account_id
+                FROM ad_creation_account_config c
+                JOIN account_whitelist w ON w.account_id = c.account_id
+                JOIN ad_delivery_template t
+                  ON t.delivery_version = c.delivery_version
+                 AND t.enabled = TRUE
+                WHERE c.enabled = TRUE
+                  AND w.enabled = TRUE
+                ORDER BY c.id ASC
+                """
+            )
+            rows = cur.fetchall()
+            return [int(r["account_id"]) for r in rows]
+    except Exception as e:
+        logger.warning("读取待投放账户失败:%s", e)
+        return []
+    finally:
+        conn.close()
 
 # ═══════════════════════════════════════════════════════════════════
 # [CREATION SOP] 广告搭建 SOP 固定参数(2026-06-05 业务确认)
@@ -330,12 +486,227 @@ ACCOUNT_FEEDBACK_ID_MAPPING = {
 def get_account_feedback_id(account_id: int):
     """获取账户的 feedback_id(监测链接 ID)。
 
-    优先级:DB(将来) > config.py 字典 > None
+    优先级:
+    1. DB account_whitelist.feedback_id
+    2. ad_creation_account_config.feedback_key → feedback_asset_template 自动查找/创建
+    3. config.py 旧字典
+
     返回 None 时调用方应反问或报错,不能猜测。
     """
-    # TODO: 后续接入 DB,从 account_whitelist 表读
+    db_feedback_id = _get_account_feedback_id_from_db(account_id)
+    if db_feedback_id is not None:
+        return db_feedback_id
+
+    try:
+        created_id = _ensure_account_feedback_id(account_id)
+        if created_id is not None:
+            return created_id
+    except Exception as e:
+        logger.warning(
+            "自动获取/创建 account_id %s 的 feedback_id 失败, fallback config 字典:%s",
+            account_id, e,
+        )
     return ACCOUNT_FEEDBACK_ID_MAPPING.get(account_id)
 
+
+def _get_account_feedback_id_from_db(account_id: int) -> Optional[int]:
+    """从 account_whitelist 读取账户级 feedback_id。"""
+    try:
+        from db.connection import get_connection
+        conn = get_connection()
+        try:
+            with conn.cursor() as cur:
+                cur.execute(
+                    "SELECT feedback_id FROM account_whitelist WHERE account_id=%s",
+                    (account_id,),
+                )
+                row = cur.fetchone()
+                if row and row.get("feedback_id") is not None:
+                    return int(row["feedback_id"])
+        finally:
+            conn.close()
+    except Exception as e:
+        logger.warning(
+            "读取 account_id %s 的 DB feedback_id 失败, fallback config 字典:%s",
+            account_id, e,
+        )
+    return None
+
+
+def _ensure_account_feedback_id(account_id: int) -> Optional[int]:
+    """按账户绑定的 feedback_key 查找或新建 DataNexus 监测链接组。
+
+    feedback_url 是全局模板;feedback_id 是账户级结果,拿到后写回
+    account_whitelist.feedback_id。
+    """
+    import json as _json
+    import time as _time
+    import uuid as _uuid
+    from urllib.parse import urlencode as _urlencode
+
+    import httpx as _httpx
+
+    try:
+        from db.connection import get_connection
+    except Exception as e:
+        raise RuntimeError(f"DB 模块不可用:{e}") from e
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT f.feedback_key, f.feedback_name, f.second_category_type,
+                       f.feedback_type, f.feedback_url
+                FROM ad_creation_account_config c
+                JOIN feedback_asset_template f
+                  ON f.feedback_key = c.feedback_key
+                 AND f.enabled = TRUE
+                WHERE c.account_id=%s
+                  AND c.enabled = TRUE
+                """,
+                (account_id,),
+            )
+            tpl = cur.fetchone()
+    finally:
+        conn.close()
+
+    if not tpl:
+        return None
+
+    feedback_name = str(tpl["feedback_name"])
+    second_category_type = str(tpl["second_category_type"])
+    feedback_type = str(tpl["feedback_type"])
+    feedback_url = str(tpl["feedback_url"])
+
+    token = _get_datanexus_access_token(account_id)
+    common = {
+        "access_token": token,
+        "timestamp": str(int(_time.time())),
+        "nonce": _uuid.uuid4().hex,
+    }
+
+    page = 1
+    while True:
+        params = dict(common)
+        params.update({"account_id": account_id, "page": page, "page_size": 100})
+        resp = _httpx.get(
+            f"https://api.e.qq.com/v3.0/feedback_info/get?{_urlencode(params)}",
+            timeout=30,
+        ).json()
+        if resp.get("code") != 0:
+            raise RuntimeError(
+                f"feedback_info/get 失败 code={resp.get('code')} "
+                f"msg={resp.get('message_cn') or resp.get('message')}"
+            )
+        data = resp.get("data") or {}
+        items = data.get("list") or []
+        for item in items:
+            if item.get("second_category_type") != second_category_type:
+                continue
+            url_infos = item.get("url_info_list") or []
+            for info in url_infos:
+                if info.get("feedback_type") == feedback_type and info.get("feedback_url") == feedback_url:
+                    feedback_id = int(item["feedback_id"])
+                    _write_account_feedback_id(account_id, feedback_id)
+                    logger.info(
+                        "[feedback] account=%d 复用已有 feedback_id=%s name=%s",
+                        account_id, feedback_id, item.get("feedback_name"),
+                    )
+                    return feedback_id
+            if item.get("feedback_name") == feedback_name:
+                feedback_id = int(item["feedback_id"])
+                _write_account_feedback_id(account_id, feedback_id)
+                logger.info(
+                    "[feedback] account=%d 按名称复用已有 feedback_id=%s name=%s",
+                    account_id, feedback_id, item.get("feedback_name"),
+                )
+                return feedback_id
+
+        page_info = data.get("page_info") or resp.get("page_info") or {}
+        total_page = int(page_info.get("total_page") or page)
+        if page >= total_page:
+            break
+        page += 1
+
+    add_params = dict(common)
+    add_params["nonce"] = _uuid.uuid4().hex
+    body = {
+        "account_id": account_id,
+        "feedback_name": feedback_name,
+        "second_category_type": second_category_type,
+        "url_info_list": [
+            {
+                "feedback_type": feedback_type,
+                "feedback_url": feedback_url,
+            }
+        ],
+    }
+    add_resp = _httpx.post(
+        f"https://api.e.qq.com/v3.0/feedback_info/add?{_urlencode(add_params)}",
+        json=body,
+        timeout=30,
+    ).json()
+    if add_resp.get("code") != 0:
+        raise RuntimeError(
+            f"feedback_info/add 失败 code={add_resp.get('code')} "
+            f"msg={add_resp.get('message_cn') or add_resp.get('message')} "
+            f"body={_json.dumps(body, ensure_ascii=False)[:300]}"
+        )
+    feedback_id = int(((add_resp.get("data") or {}).get("feedback_info") or {})["feedback_id"])
+    _write_account_feedback_id(account_id, feedback_id)
+    logger.info(
+        "[feedback] account=%d 新建 feedback_id=%s feedback_key=%s",
+        account_id, feedback_id, tpl.get("feedback_key"),
+    )
+    return feedback_id
+
+
+def _get_datanexus_access_token(account_id: int) -> str:
+    """获取 DataNexus API access_token。
+
+    当前内部 token API 实测可用于 /feedback_info/get;如后续拆分 DataNexus
+    专用 token,可通过 DATANEXUS_ACCESS_TOKEN 或 DATANEXUS_TOKEN_API 覆盖。
+    """
+    import httpx as _httpx
+
+    static_token = os.getenv("DATANEXUS_ACCESS_TOKEN", "").strip()
+    if static_token:
+        return static_token
+
+    token_api = os.getenv("DATANEXUS_TOKEN_API") or os.getenv(
+        "TENCENT_AD_TOKEN_API",
+        "https://api.piaoquantv.com/ad/put/tencent/getAccessToken",
+    )
+    resp = _httpx.get(token_api, params={"accountId": account_id}, timeout=10)
+    resp.raise_for_status()
+    token = resp.text.strip()
+    if not token or len(token) <= 10:
+        raise RuntimeError("DataNexus token 返回异常")
+    return token
+
+
+def _write_account_feedback_id(account_id: int, feedback_id: int) -> None:
+    """把账户级 DataNexus feedback_id 写回白名单。"""
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                UPDATE account_whitelist
+                SET feedback_id=%s,
+                    updated_by=%s,
+                    updated_at=CURRENT_TIMESTAMP
+                WHERE account_id=%s
+                """,
+                (feedback_id, "auto-feedback-init", account_id),
+            )
+        conn.commit()
+    finally:
+        conn.close()
+
 OPTIMIZATION_GOAL = "OPTIMIZATIONGOAL_PROMOTION_VIEW_KEY_PAGE"   # 关键页面访问次数(USER_GROWTH 配套)
 # 注:2026-06-05 业务确认 — 两个测试账户均走 USER_GROWTH + PAGE_KEY 路线
 # 即使 83846804 带人群包,仍用此优化目标,不切换到 BRAND_PROMOTION + CLICK
@@ -415,7 +786,7 @@ SITE_SET_COMBINATIONS = [
 SMART_TARGETING_MODE = "SMART_TARGETING_MANUAL"
 
 # --- WECHAT_POSITION 定投场景(2026-06-11 用户确认:生产 5 项小程序版位)---
-# 历史:9 项 preset(来自参考广告 77868332)→ 实际生产删除重建走 3 项 → 本期补 1024797/1024794 共 5 项
+# 历史:9 项 preset(来自参考广告 77868332)→ 实际生产删除重建走 3 项 → 本期补 1024797
 # 中文映射通过 tools.scene_spec.get_wechat_position_tags(account_id) 运行时查询(进程内缓存 1h)
 #
 # 业务生效场景(全部小程序流量位):
@@ -423,17 +794,15 @@ SMART_TARGETING_MODE = "SMART_TARGETING_MANUAL"
 #   1024796 小程序插屏广告
 #   2100748 小程序原生广告
 #   1024797 小程序封面广告(2026-06-11 新增)
-#   1024794 小程序banner广告(2026-06-11 新增 — 见下警告)
 #
-# ⚠️ 1024794 banner 警告(2026-06-11 实测,用户决策保留):
-#    → /adgroups/update 接口已下线,报 code=1800945 "banner 广告选项已下线,请选择其他选项"
-#    → /adgroups/add 接口未实测,scene_spec_tags/get 仍返回此 ID(账户级仍开放)
-#    → 若下次 Phase 0 POST add 也报 1800945,从此列表移除 1024794 即可
+# 1024794 小程序 banner 广告已移除:
+#    → /adgroups/update 接口已下线,报 code=1800945
+#    → 2026-06-30 /adgroups/add 对 84502339 同样报 code=1800945
 #
 # ⚠️ 创建后锁死(2026-06-11 实测复现):wechat_position 一旦创建,update 报 code=36840
 #    → 要新增场景必须删除重建广告,代价是丢失已挂创意 + 学习数据
 WECHAT_POSITION_TARGETED_PRESET = [
-    1024795, 1024796, 2100748, 1024797, 1024794,
+    1024795, 1024796, 2100748, 1024797,
 ]
 
 # 1 账户广告数(2026-06-09 用户确认:2 条,一条有 wechat_position 定投,一条无定投)
@@ -535,17 +904,47 @@ AUDIENCE_TIER_PATTERNS = [
 # 数据流:find_ads_needing_creatives → 关联点过滤 → 召回素材 → POST 创意。
 
 # --- 创意补量目标(单广告期望创意数)---
-# 2026-06-09:用户确认改 3(每广告 3 条创意)
+# 2026-07-01:用户确认最终合格创意不少于 4 个
 # 生产阶段长期应回到 15(腾讯经验下限,MIN_CREATIVES_PER_AD)
 # 这是 find_ads_needing_creatives 阈值 + 补量目标的**同一个语义变量**,不要拆
-TARGET_CREATIVES_PER_AD = 3
+TARGET_CREATIVES_PER_AD = 4
 
 # --- 主循环 try-fallback 限额(防无限召回)---
 # 单广告最多尝试 N 条 landing,超过即放弃此条创意(不影响广告剩余 to_add)
-# 2026-06-10 用户要求:扩到 30(从 10),因为 used_landing_ids 全局共享后池子越来越紧
-MAX_LANDING_ATTEMPTS_PER_AD = 30
+# 2026-07-01 用户确认:视频获取/尝试上限 100 条
+MAX_LANDING_ATTEMPTS_PER_AD = 100
 # 单 landing 最多尝试 N 条素材(召回 top N)
 MAX_MATERIAL_PER_LANDING = 10
+# 内容服务返回的内容品类黑名单。为空则不过滤;多个品类用英文逗号分隔。
+LANDING_EXCLUDED_CATEGORIES = {
+    v.strip()
+    for v in os.getenv("LANDING_EXCLUDED_CATEGORIES", "早中晚好,祝福音乐").split(",")
+    if v.strip()
+}
+
+# --- 承接视频风险审核(2026-06-29 接入)---
+# 在 xcx/save 之前调用 piaoquantv 风险标签接口。高风险视频直接跳过,继续尝试下一条 landing。
+VIDEO_RISK_CHECK_ENABLED = True
+VIDEO_RISK_API_URL = os.getenv(
+    "VIDEO_RISK_API_URL",
+    "https://longvideoapi.piaoquantv.com/longvideoapi/openapi/video/getVideoTagIds",
+)
+# 风险等级映射:tag_id -> level,level 越高风险越大。
+VIDEO_RISK_TAG_LEVELS = {
+    "85856": 1,
+    "85862": 2,
+    "85863": 3,
+    "85864": 4,
+    "85865": 5,
+    "85866": 6,
+    "85867": 7,
+    "85868": 8,
+    "85869": 9,
+    "85870": 10,
+}
+# 默认仅允许 0/1,拦截 2-10。可通过环境变量临时调整。
+VIDEO_RISK_MAX_ALLOWED_LEVEL = int(os.getenv("VIDEO_RISK_MAX_ALLOWED_LEVEL", "1"))
+VIDEO_RISK_API_TIMEOUT_SECONDS = int(os.getenv("VIDEO_RISK_API_TIMEOUT_SECONDS", "10"))
 
 # --- 素材召回质量过滤(2026-06-10 用户确认,batchByText 升级)---
 # 服务端 ranking 参数:simThreshold=0.7(语义相关),alpha=0(不要 sim 加权)
@@ -573,17 +972,11 @@ RECALL_CONFIG_CODES_FULL = [
     "INSPIRATION_FORM", "KEYPOINT_FORM", "PURPOSE_FORM",
 ]
 
-# --- 创意文案池(2026-06-09 用户确认,照搬示例 78420850 反查值)---
-# 示例 78420850 的 13 条创意全部 ACTIVE,description 都用同一组 3 句通用文案
-# 我们旧策略(用素材标题)实测 DENIED + SUSPEND,改为照搬示例文案池
+# --- 创意文案池---
+# 2026-07-01 用户确认:创意文案统一使用"打开看看"。
 CREATIVE_DESCRIPTION_POOL = [
     "打开看看",
-    "究竟怎么回事?打开看看",
-    "竟有这事?",
 ]
-# 每条创意从池中随机选 N 条作为 description
-# 2026-06-10 用户确认:N=1 — 每条创意随机抽 1 条文案,不再 3 条一起
-# (跟示例 78420850 的"3 条一起"策略不同 — 用户明确要求多样性)
 CREATIVE_DESCRIPTION_COUNT_PER_AD = 1
 
 # --- 审批开关 ---
@@ -618,4 +1011,3 @@ SLS_LOG_LEVEL = "INFO"
 # QueuedLogHandler 内部异步队列参数(SDK 默认 + 微调,避免长连接 idle 断)
 SLS_BATCH_SIZE_MAX = 1024      # 单次 PutLogs 最多条数
 SLS_PUT_WAIT_MS    = 2000      # 队列攒到 batch_size 或等 2s flush 一次
-

+ 294 - 0
examples/auto_put_ad_mini/configure_creation_accounts.py

@@ -0,0 +1,294 @@
+"""批量写入待投放账户三元组配置。
+
+用法:
+  python configure_creation_accounts.py --account-id 84545647 --audience-name 'R50*已转化' --bid 0.35
+  python configure_creation_accounts.py --csv inputs/accounts.csv
+
+CSV 字段:
+  account_id,audience_name,bid
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from dataclasses import dataclass
+from decimal import Decimal, ROUND_HALF_UP
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+_HERE = Path(__file__).parent
+load_dotenv(_HERE / ".env")
+sys.path.insert(0, str(_HERE))
+
+
+DEFAULT_DELIVERY_VERSION = "miniapp_user_growth_v1"
+DEFAULT_BRAND_KEY = "default_piaoquan"
+DEFAULT_FEEDBACK_KEY = "miniapp_click_default"
+DEFAULT_AGE_MIN = 45
+DEFAULT_AGE_MAX = 66
+DEFAULT_SOURCE_ACCOUNT_ID = 55615440
+
+
+@dataclass(frozen=True)
+class AccountConfigInput:
+    account_id: int
+    audience_name: str
+    bid_min_fen: int
+    bid_max_fen: int
+    bid_amount_fen: int | None = None
+    daily_budget_fen: int | None = None
+    enabled: bool = True
+
+
+def _bid_to_fen(raw: str) -> int:
+    text = str(raw).strip().replace("元", "")
+    if not text:
+        raise ValueError("bid 为空")
+    value = Decimal(text).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+    return int((value * 100).to_integral_value(rounding=ROUND_HALF_UP))
+
+
+def parse_bid(raw: str) -> tuple[int, int, int | None]:
+    text = str(raw).strip()
+    if "-" in text:
+        left, right = text.split("-", 1)
+        bid_min = _bid_to_fen(left)
+        bid_max = _bid_to_fen(right)
+        if bid_min > bid_max:
+            raise ValueError(f"出价范围非法:{raw}")
+        return bid_min, bid_max, None
+    bid = _bid_to_fen(text)
+    return bid, bid, bid
+
+
+def parse_budget_fen(raw: object) -> int | None:
+    text = str(raw or "").strip()
+    if not text or text in {"不限制", "不限", "-"}:
+        return None
+    # 飞书里预算按元填,腾讯 API 用分。
+    return _bid_to_fen(text)
+
+
+def _read_inputs(args: argparse.Namespace) -> list[AccountConfigInput]:
+    rows: list[AccountConfigInput] = []
+    if args.csv:
+        with open(args.csv, newline="", encoding="utf-8-sig") as f:
+            reader = csv.DictReader(f)
+            for idx, row in enumerate(reader, start=2):
+                try:
+                    rows.append(AccountConfigInput(
+                        account_id=int(str(row.get("account_id") or "").strip()),
+                        audience_name=str(row.get("audience_name") or "").strip(),
+                        bid_min_fen=parse_bid(str(row.get("bid") or ""))[0],
+                        bid_max_fen=parse_bid(str(row.get("bid") or ""))[1],
+                        bid_amount_fen=parse_bid(str(row.get("bid") or ""))[2],
+                    ))
+                except Exception as e:
+                    raise ValueError(f"CSV 第 {idx} 行格式错误:{e}") from e
+    else:
+        if args.account_id is None or not args.audience_name or not args.bid:
+            raise ValueError("单账户模式必须提供 --account-id --audience-name --bid")
+        bid_min, bid_max, bid_amount = parse_bid(args.bid)
+        rows.append(AccountConfigInput(
+            account_id=int(args.account_id),
+            audience_name=args.audience_name.strip(),
+            bid_min_fen=bid_min,
+            bid_max_fen=bid_max,
+            bid_amount_fen=bid_amount,
+        ))
+
+    for row in rows:
+        if not row.audience_name:
+            raise ValueError(f"account_id={row.account_id} audience_name 为空")
+        if row.bid_min_fen <= 0 or row.bid_max_fen <= 0:
+            raise ValueError(f"account_id={row.account_id} bid 必须大于 0")
+    return rows
+
+
+def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> None:
+    from db.connection import get_connection
+
+    source_account_id = None if row.audience_name == "泛人群" else DEFAULT_SOURCE_ACCOUNT_ID
+    grant_status = "not_required_no_audience_pack" if row.audience_name == "泛人群" else "pending_resolve"
+    audience_pack_id = None
+
+    if dry_run:
+        print(
+            f"[dry-run] account={row.account_id} audience={row.audience_name} "
+            f"bid={row.bid_min_fen}-{row.bid_max_fen} source={source_account_id} status={grant_status}"
+        )
+        return
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT user_token
+                FROM account_whitelist
+                WHERE user_token IS NOT NULL
+                ORDER BY updated_at DESC
+                LIMIT 1
+                """
+            )
+            token_row = cur.fetchone() or {}
+            user_token = token_row.get("user_token")
+
+            cur.execute(
+                """
+                INSERT INTO account_whitelist
+                  (account_id, account_name, business_line, enabled, remark,
+                   created_by, updated_by, audience_pack_id, audience_tier_label,
+                   user_token, crowd_package)
+                VALUES
+                  (%s, %s, %s, TRUE, %s,
+                   %s, %s, %s, %s,
+                   %s, %s)
+                ON DUPLICATE KEY UPDATE
+                  enabled=TRUE,
+                  remark=VALUES(remark),
+                  updated_by=VALUES(updated_by),
+                  audience_pack_id=VALUES(audience_pack_id),
+                  audience_tier_label=VALUES(audience_tier_label),
+                  user_token=COALESCE(account_whitelist.user_token, VALUES(user_token)),
+                  crowd_package=VALUES(crowd_package)
+                """,
+                (
+                    row.account_id,
+                    f"auto-create-{row.account_id}",
+                    "auto_put_tencent",
+                    "configured from account_id/audience_name/bid triple",
+                    "configure_creation_accounts",
+                    "configure_creation_accounts",
+                    audience_pack_id,
+                    row.audience_name,
+                    user_token,
+                    row.audience_name,
+                ),
+            )
+
+            cur.execute(
+                """
+                INSERT INTO ad_creation_account_config
+                  (account_id, enabled, delivery_version, brand_key, feedback_key,
+                   audience_name, audience_pack_id, audience_tier_label,
+                   bid_min_fen, bid_max_fen, bid_amount_fen, daily_budget_fen,
+                   age_min, age_max, audience_source_account_id, audience_grant_status,
+                   remark, created_by, updated_by)
+                VALUES
+                  (%s, TRUE, %s, %s, %s,
+                   %s, %s, %s,
+                   %s, %s, %s, %s,
+                   %s, %s, %s, %s,
+                   %s, %s, %s)
+                ON DUPLICATE KEY UPDATE
+                  enabled=TRUE,
+                  delivery_version=VALUES(delivery_version),
+                  brand_key=VALUES(brand_key),
+                  feedback_key=VALUES(feedback_key),
+                  audience_pack_id=IF(
+                    ad_creation_account_config.audience_name = VALUES(audience_name),
+                    ad_creation_account_config.audience_pack_id,
+                    VALUES(audience_pack_id)
+                  ),
+                  audience_tier_label=VALUES(audience_tier_label),
+                  bid_min_fen=VALUES(bid_min_fen),
+                  bid_max_fen=VALUES(bid_max_fen),
+                  bid_amount_fen=VALUES(bid_amount_fen),
+                  daily_budget_fen=VALUES(daily_budget_fen),
+                  age_min=VALUES(age_min),
+                  age_max=VALUES(age_max),
+                  audience_source_account_id=IF(
+                    ad_creation_account_config.audience_name = VALUES(audience_name),
+                    ad_creation_account_config.audience_source_account_id,
+                    VALUES(audience_source_account_id)
+                  ),
+                  audience_grant_status=IF(
+                    ad_creation_account_config.audience_name = VALUES(audience_name),
+                    ad_creation_account_config.audience_grant_status,
+                    VALUES(audience_grant_status)
+                  ),
+                  audience_verified_at=IF(
+                    ad_creation_account_config.audience_name = VALUES(audience_name),
+                    ad_creation_account_config.audience_verified_at,
+                    NULL
+                  ),
+                  audience_name=VALUES(audience_name),
+                  remark=VALUES(remark),
+                  updated_by=VALUES(updated_by)
+                """,
+                (
+                    row.account_id,
+                    DEFAULT_DELIVERY_VERSION,
+                    DEFAULT_BRAND_KEY,
+                    DEFAULT_FEEDBACK_KEY,
+                    row.audience_name,
+                    audience_pack_id,
+                    row.audience_name,
+                    row.bid_min_fen,
+                    row.bid_max_fen,
+                    row.bid_amount_fen,
+                    row.daily_budget_fen,
+                    DEFAULT_AGE_MIN,
+                    DEFAULT_AGE_MAX,
+                    source_account_id,
+                    grant_status,
+                    "configured from account_id/audience_name/bid triple",
+                    "configure_creation_accounts",
+                    "configure_creation_accounts",
+                ),
+            )
+        conn.commit()
+    finally:
+        conn.close()
+
+    print(
+        f"configured account={row.account_id} audience={row.audience_name} "
+        f"bid={row.bid_min_fen / 100:.2f}-{row.bid_max_fen / 100:.2f} status={grant_status}"
+    )
+
+
+def set_account_automation_enabled(account_id: int, enabled: bool, dry_run: bool = False) -> None:
+    if dry_run:
+        print(f"[dry-run] set account={account_id} enabled={enabled}")
+        return
+
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                UPDATE ad_creation_account_config
+                SET enabled=%s, updated_by='configure_creation_accounts'
+                WHERE account_id=%s
+                """,
+                (1 if enabled else 0, account_id),
+            )
+        conn.commit()
+    finally:
+        conn.close()
+    print(f"set account={account_id} enabled={enabled}")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--account-id", type=int)
+    parser.add_argument("--audience-name")
+    parser.add_argument("--bid", help="元,例如 0.35")
+    parser.add_argument("--csv", type=Path)
+    parser.add_argument("--dry-run", action="store_true")
+    args = parser.parse_args()
+
+    rows = _read_inputs(args)
+    for row in rows:
+        upsert_account_config(row, dry_run=args.dry_run)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 97 - 2
examples/auto_put_ad_mini/db/schema.sql

@@ -24,7 +24,102 @@ CREATE TABLE IF NOT EXISTS account_whitelist (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='广告账户白名单';
 
 -- =====================================================
--- 2. 系统配置表(Key-Value 存储)
+-- 2. 广告创建投放版本模板
+-- =====================================================
+CREATE TABLE IF NOT EXISTS ad_delivery_template (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    delivery_version VARCHAR(100) NOT NULL COMMENT '投放版本',
+    enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用',
+    description VARCHAR(500) DEFAULT NULL COMMENT '模板说明',
+    site_set_json TEXT NOT NULL COMMENT '版位 JSON',
+    location_types_json TEXT NOT NULL COMMENT '地域类型 JSON',
+    region_ids_json MEDIUMTEXT NOT NULL COMMENT '地域 region_id JSON',
+    daily_budget_fen INT NOT NULL COMMENT '单广告日预算(分)',
+    time_series_json MEDIUMTEXT NOT NULL COMMENT '投放时段字符串 JSON',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    updated_by VARCHAR(100) DEFAULT NULL COMMENT '更新人',
+
+    UNIQUE KEY uk_delivery_version (delivery_version),
+    KEY idx_enabled (enabled)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='广告创建投放版本模板';
+
+-- =====================================================
+-- 3. 待投放账户广告创建配置
+-- =====================================================
+CREATE TABLE IF NOT EXISTS ad_creation_account_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
+    enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用创建',
+    delivery_version VARCHAR(100) NOT NULL COMMENT '投放版本',
+    brand_key VARCHAR(100) DEFAULT NULL COMMENT '品牌资产模板key',
+    feedback_key VARCHAR(100) DEFAULT NULL COMMENT '监测链接模板key',
+    audience_name VARCHAR(200) NOT NULL COMMENT '人工配置的人群包名称',
+    audience_pack_id BIGINT DEFAULT NULL COMMENT '系统解析/验证后的人群包ID;NULL表示泛人群',
+    audience_tier_label VARCHAR(100) DEFAULT NULL COMMENT '审批/命名展示用人群标签',
+    bid_min_fen INT NOT NULL COMMENT '最低出价(分)',
+    bid_max_fen INT NOT NULL COMMENT '最高出价(分)',
+    bid_amount_fen INT DEFAULT NULL COMMENT '固定出价(分);NULL则按范围取值',
+    daily_budget_fen INT DEFAULT NULL COMMENT '账户级单广告日预算(分);NULL则使用投放模板默认预算',
+    age_min INT NOT NULL COMMENT '最小年龄',
+    age_max INT NOT NULL COMMENT '最大年龄',
+    audience_source_account_id BIGINT DEFAULT NULL COMMENT '人群包来源账户;默认可由 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID 提供',
+    audience_grant_status VARCHAR(50) DEFAULT NULL COMMENT '授权/验证状态:not_required_no_audience_pack/resolved/grant_submitted/verified',
+    audience_verified_at TIMESTAMP NULL DEFAULT NULL COMMENT '目标账户可见验证时间',
+    remark TEXT DEFAULT NULL COMMENT '备注',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    created_by VARCHAR(100) DEFAULT NULL COMMENT '创建人',
+    updated_by VARCHAR(100) DEFAULT NULL COMMENT '更新人',
+
+    UNIQUE KEY uk_account_id (account_id),
+    KEY idx_enabled (enabled),
+    KEY idx_delivery_version (delivery_version),
+    KEY idx_brand_key (brand_key),
+    KEY idx_feedback_key (feedback_key),
+    KEY idx_audience_name (audience_name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待投放账户广告创建配置';
+
+-- =====================================================
+-- 4. 统一品牌资产模板
+-- =====================================================
+CREATE TABLE IF NOT EXISTS brand_asset_template (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    brand_key VARCHAR(100) NOT NULL COMMENT '品牌模板key',
+    brand_name VARCHAR(100) NOT NULL COMMENT '品牌名称',
+    brand_image_url VARCHAR(1000) NOT NULL COMMENT '品牌图片公网URL',
+    enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用',
+    remark TEXT DEFAULT NULL COMMENT '备注',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    updated_by VARCHAR(100) DEFAULT NULL COMMENT '更新人',
+
+    UNIQUE KEY uk_brand_key (brand_key),
+    KEY idx_enabled (enabled)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一品牌资产模板';
+
+-- =====================================================
+-- 5. 统一监测链接模板
+-- =====================================================
+CREATE TABLE IF NOT EXISTS feedback_asset_template (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    feedback_key VARCHAR(100) NOT NULL COMMENT '监测模板key',
+    feedback_name VARCHAR(200) NOT NULL COMMENT '监测链接组名称',
+    second_category_type VARCHAR(100) NOT NULL COMMENT '二级类目类型,如 WECHAT_MINI_PROGRAM',
+    feedback_type VARCHAR(50) NOT NULL COMMENT '监测类型,如 click',
+    feedback_url TEXT NOT NULL COMMENT '回传链接模板',
+    enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用',
+    remark TEXT DEFAULT NULL COMMENT '备注',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    updated_by VARCHAR(100) DEFAULT NULL COMMENT '更新人',
+
+    UNIQUE KEY uk_feedback_key (feedback_key),
+    KEY idx_enabled (enabled)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一监测链接模板';
+
+-- =====================================================
+-- 6. 系统配置表(Key-Value 存储)
 -- =====================================================
 CREATE TABLE IF NOT EXISTS system_config (
     id INT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
@@ -42,7 +137,7 @@ CREATE TABLE IF NOT EXISTS system_config (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
 
 -- =====================================================
--- 3. 决策历史记录表(可选,用于审计和分析)
+-- 7. 决策历史记录表(可选,用于审计和分析)
 -- =====================================================
 CREATE TABLE IF NOT EXISTS decision_history (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',

+ 101 - 11
examples/auto_put_ad_mini/execute_creation_once.py

@@ -9,7 +9,7 @@
     → 写 outputs/data/creation_pending_{date}.json(追溯 + 独立 apply 用)
 
   Phase 2 — 审批(若 CREATION_APPROVAL_REQUIRED=True):
-    run_approval_workflow 生成 13 列 xlsx → 上传飞书 sheet → 发链接 → 轮询读决策列
+    run_approval_workflow 生成 20 列 xlsx → 上传飞书 sheet → 发链接 → 轮询读决策列
     actions {row_idx: approve/reject/hold}
     → 写回 records["action"]
 
@@ -24,6 +24,7 @@
 import json
 import logging
 import sys
+import time
 from datetime import datetime, timezone
 from pathlib import Path
 
@@ -38,9 +39,9 @@ from config import (  # noqa: E402
     ADS_PER_ACCOUNT,
     CREATION_APPROVAL_REQUIRED,
     CREATION_APPROVAL_TIMEOUT_MINUTES,
-    FIXED_TARGETING_AGE,
     TARGET_CREATIVES_PER_AD,
     WHITELIST_ACCOUNTS,
+    get_creation_account_ids,
 )
 
 # config import 副作用会改 sys.path(把 im-client / agent/tools/builtin 顶到前面),
@@ -55,6 +56,7 @@ from tools.ad_creation import (  # noqa: E402
     enumerate_new_ad_candidates,
     post_ad_with_prepared_body,
 )
+from tools.audience_grant import ensure_account_audience_grant  # noqa: E402
 from tools.creative_creation import (  # noqa: E402
     find_ads_needing_creatives,
     load_excluded_ad_ids_from_adjustment,
@@ -172,8 +174,9 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
     from tools.ad_api import _get
     from tools.im_approval_ad_creation import run_ad_approval_workflow
 
-    if not WHITELIST_ACCOUNTS:
-        logger.error("[phase0] WHITELIST_ACCOUNTS 为空,退出")
+    creation_accounts = get_creation_account_ids()
+    if not creation_accounts:
+        logger.error("[phase0] 待投放账户配置为空,退出")
         return []
 
     # Task 26:NORMAL + SUSPEND 都算占用唯一性槽位(腾讯文档:删除前历史广告占槽位)
@@ -181,10 +184,22 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
 
     pending_ad_records: list[dict] = []
 
-    for account_id in WHITELIST_ACCOUNTS:
+    for account_id in creation_accounts:
         logger.info("=" * 60)
         logger.info("[phase0] 账户 %d 处理开始", account_id)
 
+        try:
+            grant_result = ensure_account_audience_grant(account_id)
+            logger.info(
+                "[phase0]   人群包校验完成: status=%s audience_id=%s audience=%r",
+                grant_result.get("status"),
+                grant_result.get("audience_id"),
+                grant_result.get("audience_name"),
+            )
+        except Exception as e:
+            logger.exception("[phase0] account=%d 人群包授权/验证失败,跳过:%s", account_id, e)
+            continue
+
         # Task 26:查所有非 DELETED 广告,NORMAL+SUSPEND 都算占用槽位
         try:
             r = _get("/adgroups/get", {
@@ -226,10 +241,10 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
             continue
 
         today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
-        age_range = ",".join(
-            f"{a.get('min')}-{a.get('max')}" for a in FIXED_TARGETING_AGE
-        )
         for c in candidates:
+            age_range = ",".join(
+                f"{a.get('min')}-{a.get('max')}" for a in c.age
+            )
             # 2026-06-10 修复:Phase 0 经过飞书审批后,广告应立即 NORMAL 生效
             # build_ad_request_body 的 default SUSPEND 是模块 A dry-run 时代的旧 SOP
             body = build_ad_request_body(c, configured_status="AD_STATUS_NORMAL")
@@ -239,6 +254,7 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
                 "audience_tier_label": c.audience_tier_label,
                 "adgroup_name": c.adgroup_name,
                 "site_set": c.site_set,
+                "delivery_version": c.delivery_version,
                 "wechat_position": c.wechat_position,
                 "bid_amount_fen": c.bid_amount_fen,
                 "age_range": age_range,
@@ -295,14 +311,65 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
     return created
 
 
+def _wait_created_ads_visible(created_ads: list[dict], timeout_seconds: int = 60) -> None:
+    """等待刚创建的广告在 /adgroups/get 中可见。
+
+    腾讯 adgroups/add 成功后,list 接口存在短暂一致性延迟。若立刻进入 Phase 1,
+    可能只扫到部分新广告,导致同一轮只给一条广告补创意。
+    """
+    if not created_ads:
+        return
+
+    from tools.ad_api import _get
+
+    by_account: dict[int, set[int]] = {}
+    for ad in created_ads:
+        by_account.setdefault(int(ad["account_id"]), set()).add(int(ad["adgroup_id"]))
+
+    deadline = time.time() + timeout_seconds
+    remaining = {acct: set(ids) for acct, ids in by_account.items()}
+    while time.time() < deadline and any(remaining.values()):
+        for account_id, ids in list(remaining.items()):
+            if not ids:
+                continue
+            try:
+                resp = _get("/adgroups/get", {
+                    "account_id": account_id,
+                    "page": 1,
+                    "page_size": 100,
+                    "fields": ["adgroup_id", "configured_status", "system_status"],
+                })
+                visible = {
+                    int(item["adgroup_id"])
+                    for item in ((resp.get("data") or {}).get("list") or [])
+                    if item.get("adgroup_id") is not None
+                }
+                ids.difference_update(visible)
+            except Exception as e:
+                logger.warning(
+                    "[phase0] 等待新广告可见失败 account=%d: %s",
+                    account_id, e,
+                )
+        if any(remaining.values()):
+            logger.info("[phase0] 等待新广告在列表接口可见: %s", remaining)
+            time.sleep(5)
+
+    not_visible = {acct: sorted(ids) for acct, ids in remaining.items() if ids}
+    if not_visible:
+        logger.warning("[phase0] 部分新广告仍未在 list 可见,继续 Phase 1: %s", not_visible)
+    else:
+        logger.info("[phase0] 新建广告均已在 list 接口可见")
+
+
 def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict]:
     """Phase 1:扫账户 → 关联点过滤 → 准备 pending records(不 POST 腾讯)。
 
     Returns:
         pending_records — 每个元素是 prepare_one_creative_for_ad 返回的 dict
     """
-    if not WHITELIST_ACCOUNTS:
-        logger.error("[phase1] WHITELIST_ACCOUNTS 为空,Phase 1 退出")
+    creation_accounts = get_creation_account_ids()
+    if not creation_accounts:
+        logger.error("[phase1] 待投放账户配置为空,Phase 1 退出")
         return []
 
     excluded_ad_ids = load_excluded_ad_ids_from_adjustment()
@@ -315,7 +382,7 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
     used_material_ids_global: set = set()
     used_landing_ids_global: set = set()
 
-    for account_id in WHITELIST_ACCOUNTS:
+    for account_id in creation_accounts:
         logger.info("=" * 60)
         logger.info("[phase1] 账户 %d 处理开始", account_id)
 
@@ -409,12 +476,33 @@ def phase2_approval(records: list[dict], xlsx_output_dir: Path) -> dict[int, str
 def run_once() -> dict:
     """完整主循环:Phase 0 → Phase 1 → Phase 2(若开关 True)→ Phase 3。"""
     run_started = datetime.now(timezone.utc).isoformat()
+    sync_stats: dict = {}
+
+    # Phase -1:每日主流程启动时先同步飞书「自动化账户」配置表。
+    logger.info("=" * 60)
+    logger.info("[main] Phase -1 启动 — 同步飞书自动化账户配置")
+    try:
+        from sync_feishu_account_config import sync_from_feishu
+
+        sync_stats = sync_from_feishu()
+        logger.info("[main] Phase -1 完成: %s", sync_stats)
+    except Exception as e:
+        logger.exception("[main] Phase -1 同步飞书配置失败,本轮停止:%s", e)
+        return {
+            "run_started": run_started,
+            "run_finished": datetime.now(timezone.utc).isoformat(),
+            "approval_required": CREATION_APPROVAL_REQUIRED,
+            "sync_stats": {"error": str(e)},
+            "phase0_created_ads": 0,
+            "total": {"phase1_prepared": 0},
+        }
 
     # Phase 0:模块 A 建广告(满足每账户 ADS_PER_ACCOUNT 条)
     logger.info("=" * 60)
     logger.info("[main] Phase 0 启动 — 模块 A 检查 + 建广告")
     created_ads = phase0_create_ads()
     logger.info("[main] Phase 0 完成:本轮新建广告 %d 条", len(created_ads))
+    _wait_created_ads_visible(created_ads)
 
     # Phase 1:模块 B 给所有广告(新+旧)补创意
     logger.info("=" * 60)
@@ -426,6 +514,7 @@ def run_once() -> dict:
             "run_started": run_started,
             "run_finished": datetime.now(timezone.utc).isoformat(),
             "approval_required": CREATION_APPROVAL_REQUIRED,
+            "sync_stats": sync_stats,
             "phase0_created_ads": len(created_ads),
             "total": {"phase1_prepared": 0},
         }
@@ -459,6 +548,7 @@ def run_once() -> dict:
 
     summary["run_started"] = run_started
     summary["run_finished"] = datetime.now(timezone.utc).isoformat()
+    summary["sync_stats"] = sync_stats
     summary["phase0_created_ads"] = len(created_ads)
     summary["phase0_ads"] = created_ads
     return summary

+ 195 - 0
examples/auto_put_ad_mini/sync_feishu_account_config.py

@@ -0,0 +1,195 @@
+"""从飞书「自动化账户」表同步待投放账户配置到 DB。
+
+表格:
+  https://w42nne6hzg.feishu.cn/sheets/D8f4sKEb2hfBnNttX1ucZTklnHf
+
+读取 tab:
+  自动化账户 (sheet_id=y3uCcz)
+
+规则:
+  - 表头在第 2 行。
+  - 仅当「是否自动化执行」为「是」时启用/更新账户。
+  - 「否」会禁用已有的 ad_creation_account_config,不删除历史配置。
+  - 「初始出价」支持固定值 0.35 或范围 0.28-0.31。
+  - 「预算(单广告)」为空/不限制/不限 时使用模板默认预算;数字按元转换为分。
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+import httpx
+from dotenv import load_dotenv
+
+_HERE = Path(__file__).parent
+load_dotenv(_HERE / ".env")
+sys.path.insert(0, str(_HERE.parent.parent))
+sys.path.insert(0, str(_HERE))
+
+from configure_creation_accounts import (  # noqa: E402
+    AccountConfigInput,
+    parse_bid,
+    parse_budget_fen,
+    set_account_automation_enabled,
+    upsert_account_config,
+)
+from tools.feishu_doc import (  # noqa: E402
+    FEISHU_BASE_URL,
+    _auth_headers,
+    _get_tenant_token,
+)
+
+
+DEFAULT_SPREADSHEET_TOKEN = "D8f4sKEb2hfBnNttX1ucZTklnHf"
+DEFAULT_SHEET_ID = "y3uCcz"
+DEFAULT_RANGE = "A1:G300"
+
+HEADER_ROW_INDEX = 1
+DATA_START_INDEX = 2
+
+REQUIRED_COLUMNS = {
+    "账户id": "account_id",
+    "人群包": "audience_name",
+    "初始出价": "bid",
+    "预算(单广告)": "budget",
+    "是否自动化执行": "enabled",
+}
+
+
+def _plain_value(value: Any) -> str:
+    if value is None:
+        return ""
+    if isinstance(value, list):
+        parts: list[str] = []
+        for item in value:
+            if isinstance(item, dict):
+                parts.append(str(item.get("text") or item.get("link") or ""))
+            else:
+                parts.append(str(item))
+        return "".join(parts).strip()
+    return str(value).strip()
+
+
+def _fetch_rows(spreadsheet_token: str, sheet_id: str, cell_range: str) -> list[list[Any]]:
+    token = _get_tenant_token()
+    url = (
+        f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/"
+        f"{spreadsheet_token}/values/{sheet_id}!{cell_range}"
+    )
+    resp = httpx.get(url, headers=_auth_headers(token), timeout=30)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"读取飞书配置表失败:{json.dumps(data, ensure_ascii=False)[:500]}")
+    return ((data.get("data") or {}).get("valueRange") or {}).get("values") or []
+
+
+def _header_map(rows: list[list[Any]]) -> dict[str, int]:
+    if len(rows) <= HEADER_ROW_INDEX:
+        raise RuntimeError("飞书配置表缺少表头行")
+    headers = [_plain_value(v) for v in rows[HEADER_ROW_INDEX]]
+    mapping = {name: idx for idx, name in enumerate(headers) if name}
+    missing = [name for name in REQUIRED_COLUMNS if name not in mapping]
+    if missing:
+        raise RuntimeError(f"飞书配置表缺少列:{missing}; 当前表头={headers}")
+    return mapping
+
+
+def _cell(row: list[Any], idx: int) -> str:
+    return _plain_value(row[idx]) if idx < len(row) else ""
+
+
+def _parse_account_id(raw: str) -> int | None:
+    text = raw.strip()
+    if not text:
+        return None
+    # 飞书数字可能读成 "84545647.0"。
+    try:
+        return int(float(text))
+    except Exception as e:
+        raise ValueError(f"账户id非法:{raw}") from e
+
+
+def sync_from_feishu(
+    spreadsheet_token: str = DEFAULT_SPREADSHEET_TOKEN,
+    sheet_id: str = DEFAULT_SHEET_ID,
+    cell_range: str = DEFAULT_RANGE,
+    dry_run: bool = False,
+    strict: bool = True,
+) -> dict[str, int]:
+    rows = _fetch_rows(spreadsheet_token, sheet_id, cell_range)
+    mapping = _header_map(rows)
+
+    stats = {"enabled": 0, "disabled": 0, "skipped": 0, "errors": 0}
+    enabled_records: list[AccountConfigInput] = []
+    disabled_accounts: list[int] = []
+    for row_num, row in enumerate(rows[DATA_START_INDEX:], start=DATA_START_INDEX + 1):
+        try:
+            account_id = _parse_account_id(_cell(row, mapping["账户id"]))
+            if account_id is None:
+                stats["skipped"] += 1
+                continue
+
+            enabled_text = _cell(row, mapping["是否自动化执行"])
+            enabled = enabled_text == "是"
+            if not enabled:
+                disabled_accounts.append(account_id)
+                stats["disabled"] += 1
+                continue
+
+            audience_name = _cell(row, mapping["人群包"])
+            bid_text = _cell(row, mapping["初始出价"])
+            budget_text = _cell(row, mapping["预算(单广告)"])
+            bid_min, bid_max, bid_amount = parse_bid(bid_text)
+            enabled_records.append(AccountConfigInput(
+                account_id=account_id,
+                audience_name=audience_name,
+                bid_min_fen=bid_min,
+                bid_max_fen=bid_max,
+                bid_amount_fen=bid_amount,
+                daily_budget_fen=parse_budget_fen(budget_text),
+                enabled=True,
+            ))
+            stats["enabled"] += 1
+        except Exception as e:
+            stats["errors"] += 1
+            print(f"row={row_num} ERROR {e}")
+
+    if strict and stats["errors"]:
+        print("sync summary:", json.dumps(stats, ensure_ascii=False))
+        raise RuntimeError(f"飞书配置表存在 {stats['errors']} 行错误,停止本轮自动化")
+
+    for account_id in disabled_accounts:
+        set_account_automation_enabled(account_id, False, dry_run=dry_run)
+    for record in enabled_records:
+        upsert_account_config(record, dry_run=dry_run)
+
+    print("sync summary:", json.dumps(stats, ensure_ascii=False))
+    return stats
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--spreadsheet-token", default=DEFAULT_SPREADSHEET_TOKEN)
+    parser.add_argument("--sheet-id", default=DEFAULT_SHEET_ID)
+    parser.add_argument("--range", default=DEFAULT_RANGE)
+    parser.add_argument("--dry-run", action="store_true")
+    parser.add_argument("--allow-row-errors", action="store_true")
+    args = parser.parse_args()
+
+    stats = sync_from_feishu(
+        spreadsheet_token=args.spreadsheet_token,
+        sheet_id=args.sheet_id,
+        cell_range=args.range,
+        dry_run=args.dry_run,
+        strict=not args.allow_row_errors,
+    )
+    return 1 if stats["errors"] and not args.allow_row_errors else 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 28 - 57
examples/auto_put_ad_mini/tools/ad_creation.py

@@ -44,25 +44,12 @@ from config import (
     DEFAULT_CONVERSION_ID,
     # 搜索场景扩量 · 定向拓展开关
     SEARCH_EXPAND_TARGETING_SWITCH,
-    # 版位
-    AVAILABLE_SITE_SETS,
-    SITE_SET_COMBINATIONS,
-    # 定向
-    FIXED_TARGETING_AGE,
-    FIXED_TARGETING_GENDER,
-    FIXED_TARGETING_LOCATION_TYPES,
-    FIXED_TARGETING_REGION_IDS,
-    # 时段 / 日期 / 预算
-    TIME_SERIES_DEFAULT,
+    # 定向 / 日期
     DEFAULT_END_DATE,
-    DEFAULT_DAILY_BUDGET_FEN,
-    # 一账一包
-    ACCOUNT_AUDIENCE_PACK_MAPPING,
+    # 账户创建配置(DB 单一真相源)
+    get_account_creation_config,
     # 监测链接 / 反馈 ID
     get_account_feedback_id,
-    # 出价区间策略
-    AUDIENCE_BID_RANGES,
-    BID_PICK_STRATEGY,
 )
 
 logger = logging.getLogger(__name__)
@@ -83,6 +70,12 @@ class AdCandidate:
     custom_audience: Optional[list]         # [audience_id] 或 None(不传)
     bid_amount_fen: int                     # 出价(分)
     audience_tier_label: str                # 用于 reason / 审批表展示
+    age: list                               # [{"min":45,"max":66}]
+    location_types: list                    # ["LIVE_IN"]
+    region_ids: list                        # 地域 region_id
+    daily_budget_fen: int
+    time_series: str
+    delivery_version: str
     fingerprint: str                        # 营销内容指纹(本地判重)
     # 版位定投场景 wechat_position(2026-06-09 1 账户 N 广告差异化机制)
     # None = 无定投(走 site_set 默认全场景)/ list[int] = 勾选具体场景 ID
@@ -121,29 +114,6 @@ def compute_fingerprint(
     ).hexdigest()
 
 
-def pick_bid_amount_fen(tier_label: str, strategy: Optional[str] = None) -> int:
-    """从 SOP 出价区间内取一个具体出价(单位:分)。"""
-    strategy = strategy or BID_PICK_STRATEGY
-    bid_range = AUDIENCE_BID_RANGES.get(tier_label)
-    if not bid_range:
-        raise ValueError(
-            f"tier_label '{tier_label}' 不在 AUDIENCE_BID_RANGES 中。"
-            f"可用 keys: {list(AUDIENCE_BID_RANGES.keys())}"
-        )
-    min_yuan, max_yuan = bid_range
-    if strategy == "midpoint":
-        yuan = (min_yuan + max_yuan) / 2
-    elif strategy == "max":
-        yuan = max_yuan
-    elif strategy == "min":
-        yuan = min_yuan
-    else:
-        raise ValueError(
-            f"未知出价策略 '{strategy}',支持:midpoint / max / min"
-        )
-    return int(round(yuan * 100))  # 元 → 分
-
-
 # ═══════════════════════════════════════════
 # 命名 / 摘要
 # ═══════════════════════════════════════════
@@ -210,7 +180,7 @@ def enumerate_new_ad_candidates(
     其他维度(audience pack / age / geo / 优化目标 / 出价类型)都固定。
 
     Args:
-        account_id: 广告账户 ID(从 ACCOUNT_AUDIENCE_PACK_MAPPING 取 pack)
+        account_id: 广告账户 ID(从 DB account_whitelist 取 pack)
         count: 期望产出条数(上限 = 可用 site_set 组合数 - 已存在指纹)
         existing_fingerprints: 已存在的指纹 set,用于跳过
 
@@ -219,16 +189,11 @@ def enumerate_new_ad_candidates(
     """
     existing_fingerprints = existing_fingerprints or set()
 
-    if account_id not in ACCOUNT_AUDIENCE_PACK_MAPPING:
-        raise ValueError(
-            f"account_id {account_id} 不在 ACCOUNT_AUDIENCE_PACK_MAPPING 中。"
-            f"请先在 config.py 配置该账户的 audience pack。"
-        )
-
-    pack_id, tier_label = ACCOUNT_AUDIENCE_PACK_MAPPING[account_id]
+    cfg = get_account_creation_config(account_id)
+    pack_id = cfg["audience_pack_id"]
+    tier_label = cfg["audience_tier_label"]
     custom_audience = [pack_id] if pack_id else None
-
-    bid_amount_fen = pick_bid_amount_fen(tier_label)
+    bid_amount_fen = cfg["bid_amount_fen"]
 
     # 差异化策略(2026-06-09 用户确认 + 2026-06-11 缩减):
     #   广告 #1 wechat_position=None   (无定投,走 site_set 默认全场景)
@@ -237,7 +202,7 @@ def enumerate_new_ad_candidates(
     wechat_position_variants = [None, WECHAT_POSITION_TARGETED_PRESET]
 
     candidates: list[AdCandidate] = []
-    site_set = SITE_SET_COMBINATIONS[0]  # 本期单一 site_set,差异化靠 wechat_position
+    site_set = cfg["site_set"]
     for seq, wp in enumerate(wechat_position_variants, start=1):
         if len(candidates) >= count:
             break
@@ -246,8 +211,8 @@ def enumerate_new_ad_candidates(
             account_id=account_id,
             site_set=site_set,
             custom_audience=custom_audience,
-            age=FIXED_TARGETING_AGE,
-            geo_regions=FIXED_TARGETING_REGION_IDS,
+            age=cfg["age"],
+            geo_regions=cfg["region_ids"],
             wechat_position=wp,
         )
         if fingerprint in existing_fingerprints:
@@ -269,6 +234,12 @@ def enumerate_new_ad_candidates(
                 custom_audience=custom_audience,
                 bid_amount_fen=bid_amount_fen,
                 audience_tier_label=tier_label,
+                age=cfg["age"],
+                location_types=cfg["location_types"],
+                region_ids=cfg["region_ids"],
+                daily_budget_fen=cfg["daily_budget_fen"],
+                time_series=cfg["time_series"],
+                delivery_version=cfg["delivery_version"],
                 fingerprint=fingerprint,
                 wechat_position=wp,
             )
@@ -305,10 +276,10 @@ def build_ad_request_body(
     # 定向 — 固定地域 + 固定年龄 + 可选人群包
     targeting: dict = {
         "geo_location": {
-            "location_types": FIXED_TARGETING_LOCATION_TYPES,
-            "regions": FIXED_TARGETING_REGION_IDS,
+            "location_types": candidate.location_types,
+            "regions": candidate.region_ids,
         },
-        "age": FIXED_TARGETING_AGE,
+        "age": candidate.age,
     }
     if candidate.custom_audience:
         targeting["custom_audience"] = candidate.custom_audience
@@ -343,14 +314,14 @@ def build_ad_request_body(
         "smart_bid_type": SMART_BID_TYPE,
         "bid_strategy": BID_STRATEGY,
         "bid_amount": candidate.bid_amount_fen,
-        "daily_budget": DEFAULT_DAILY_BUDGET_FEN,
+        "daily_budget": candidate.daily_budget_fen,
         "auto_acquisition_enabled": AUTO_ACQUISITION_ENABLED,
         "auto_derived_creative_enabled": AUTO_DERIVED_CREATIVE_ENABLED,
         # === 时段 / 日期 ===
         # end_date 必填,且不接受 "0"。默认设 begin_date + 1 年表示长期
         "begin_date": begin_date,
         "end_date": _add_years(begin_date, 1),
-        "time_series": TIME_SERIES_DEFAULT,
+        "time_series": candidate.time_series,
         # === 版位 ===
         "automatic_site_enabled": False,
         "site_set": candidate.site_set,

+ 290 - 0
examples/auto_put_ad_mini/tools/audience_grant.py

@@ -0,0 +1,290 @@
+"""腾讯广告人群包解析、授权、可见性验证。
+
+Phase 0 创建广告前调用,保证非泛人群账户的 custom_audience 已经在目标账户可见。
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import time
+from typing import Optional
+
+from tools.ad_api import _check, _get, _post
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_AUDIENCE_SOURCE_ACCOUNT_ID = int(
+    os.getenv("TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID", "55615440")
+)
+AUDIENCE_GRANT_BUSINESS_ID = int(os.getenv("TENCENT_AUDIENCE_GRANT_BUSINESS_ID", "0") or 0)
+TARGET_PERMISSION = "GRANT_PERMISSION_TYPE_TARGET"
+
+
+def _fetch_custom_audiences(account_id: int) -> list[dict]:
+    out: list[dict] = []
+    page = 1
+    while True:
+        resp = _get(
+            "/custom_audiences/get",
+            {"account_id": account_id, "page": page, "page_size": 100},
+        )
+        data = _check(resp, "custom_audiences/get")
+        out.extend(data.get("list") or [])
+        page_info = data.get("page_info") or {}
+        total_page = int(page_info.get("total_page") or page)
+        if page >= total_page:
+            break
+        page += 1
+    return out
+
+
+def _is_audience_visible(account_id: int, audience_id: int) -> bool:
+    return any(
+        int(item.get("audience_id") or 0) == int(audience_id)
+        for item in _fetch_custom_audiences(account_id)
+    )
+
+
+def _pick_source_audience(source_account_id: int, audience_name: str) -> dict:
+    items = _fetch_custom_audiences(source_account_id)
+    usable = [
+        item for item in items
+        if item.get("status") == "SUCCESS"
+        and item.get("online_status") == "ONLINE"
+    ]
+
+    def score(item: dict) -> tuple[int, str]:
+        name = item.get("name") or ""
+        if name == audience_name:
+            rank = 3
+        elif name.startswith(f"{audience_name}_"):
+            rank = 2
+        elif audience_name in name:
+            rank = 1
+        else:
+            rank = 0
+        return rank, str(item.get("created_time") or "")
+
+    matches = [item for item in usable if score(item)[0] > 0]
+    if not matches:
+        raise RuntimeError(
+            f"source_account={source_account_id} 未找到 ONLINE/SUCCESS 人群包:"
+            f" audience_name={audience_name!r}"
+        )
+
+    matches.sort(key=score, reverse=True)
+    chosen = matches[0]
+    logger.info(
+        "[audience_grant] source=%d audience_name=%r → audience_id=%s full_name=%r",
+        source_account_id,
+        audience_name,
+        chosen.get("audience_id"),
+        chosen.get("name"),
+    )
+    return chosen
+
+
+def _load_account_config(account_id: int) -> Optional[dict]:
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT account_id, audience_name, audience_pack_id,
+                       audience_source_account_id, audience_grant_status
+                FROM ad_creation_account_config
+                WHERE account_id=%s AND enabled=TRUE
+                """,
+                (account_id,),
+            )
+            return cur.fetchone()
+    finally:
+        conn.close()
+
+
+def _write_audience_state(
+    account_id: int,
+    audience_id: Optional[int],
+    audience_name: str,
+    source_account_id: Optional[int],
+    status: str,
+    verified: bool = False,
+) -> None:
+    from db.connection import get_connection
+
+    verified_sql = "audience_verified_at=NOW()," if verified else ""
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                f"""
+                UPDATE ad_creation_account_config
+                SET audience_pack_id=%s,
+                    audience_tier_label=%s,
+                    audience_source_account_id=%s,
+                    audience_grant_status=%s,
+                    {verified_sql}
+                    updated_by='auto-audience-grant'
+                WHERE account_id=%s
+                """,
+                (audience_id, audience_name, source_account_id, status, account_id),
+            )
+            cur.execute(
+                """
+                UPDATE account_whitelist
+                SET audience_pack_id=%s,
+                    audience_tier_label=%s,
+                    updated_by='auto-audience-grant'
+                WHERE account_id=%s
+                """,
+                (audience_id, audience_name, account_id),
+            )
+        conn.commit()
+    finally:
+        conn.close()
+
+
+def _grant_audience_to_account(
+    source_account_id: int,
+    target_account_id: int,
+    audience_id: int,
+    grant_business_id: int,
+) -> None:
+    body = {
+        "account_id": source_account_id,
+        "audience_id_list": [int(audience_id)],
+        "grant_type": "GRANT_TYPE_BUSINESS",
+        "grant_spec": {
+            "grant_to_business_spec": {
+                "grant_business_id": int(grant_business_id),
+                "grant_scope_type": "GRANT_SCOPE_TYPE_ACCOUNT",
+                "grant_account_permission": [
+                    {
+                        "account_id": int(target_account_id),
+                        "grant_permission_type_list": [TARGET_PERMISSION],
+                    }
+                ],
+            }
+        },
+    }
+    resp = _post("/audience_grant_relations/add", body)
+    if resp.get("code") not in (0, "0"):
+        raise RuntimeError(
+            f"audience_grant_relations/add 失败 code={resp.get('code')} "
+            f"msg={resp.get('message_cn') or resp.get('message')}"
+        )
+
+
+def ensure_account_audience_grant(
+    account_id: int,
+    poll_attempts: int = 12,
+    poll_interval_seconds: int = 5,
+) -> dict:
+    """确保 account_id 的待投放人群包已在目标账户可见。
+
+    Returns:
+        {"status": str, "audience_id": Optional[int], "audience_name": str}
+    """
+    cfg = _load_account_config(account_id)
+    if not cfg:
+        raise RuntimeError(f"account_id={account_id} 未配置 ad_creation_account_config")
+
+    audience_name = (cfg.get("audience_name") or "").strip()
+    if not audience_name:
+        raise RuntimeError(f"account_id={account_id} audience_name 为空")
+
+    if audience_name == "泛人群":
+        _write_audience_state(
+            account_id=account_id,
+            audience_id=None,
+            audience_name=audience_name,
+            source_account_id=None,
+            status="not_required_no_audience_pack",
+            verified=True,
+        )
+        logger.info("[audience_grant] account=%d 泛人群,无需授权", account_id)
+        return {"status": "not_required_no_audience_pack", "audience_id": None, "audience_name": audience_name}
+
+    source_account_id = int(
+        cfg.get("audience_source_account_id") or DEFAULT_AUDIENCE_SOURCE_ACCOUNT_ID
+    )
+    audience_id = cfg.get("audience_pack_id")
+    if audience_id is None:
+        chosen = _pick_source_audience(source_account_id, audience_name)
+        audience_id = int(chosen["audience_id"])
+        _write_audience_state(
+            account_id=account_id,
+            audience_id=audience_id,
+            audience_name=audience_name,
+            source_account_id=source_account_id,
+            status="resolved",
+        )
+    audience_id = int(audience_id)
+
+    if _is_audience_visible(account_id, audience_id):
+        _write_audience_state(
+            account_id=account_id,
+            audience_id=audience_id,
+            audience_name=audience_name,
+            source_account_id=source_account_id,
+            status="verified",
+            verified=True,
+        )
+        logger.info(
+            "[audience_grant] account=%d audience_id=%d 已可见",
+            account_id, audience_id,
+        )
+        return {"status": "verified", "audience_id": audience_id, "audience_name": audience_name}
+
+    if not AUDIENCE_GRANT_BUSINESS_ID:
+        raise RuntimeError(
+            "缺少 TENCENT_AUDIENCE_GRANT_BUSINESS_ID,无法自动授权人群包"
+        )
+
+    logger.info(
+        "[audience_grant] 授权 source=%d audience_id=%d → target=%d bm=%d",
+        source_account_id,
+        audience_id,
+        account_id,
+        AUDIENCE_GRANT_BUSINESS_ID,
+    )
+    _grant_audience_to_account(
+        source_account_id=source_account_id,
+        target_account_id=account_id,
+        audience_id=audience_id,
+        grant_business_id=AUDIENCE_GRANT_BUSINESS_ID,
+    )
+    _write_audience_state(
+        account_id=account_id,
+        audience_id=audience_id,
+        audience_name=audience_name,
+        source_account_id=source_account_id,
+        status="grant_submitted",
+    )
+
+    for i in range(poll_attempts):
+        if i:
+            time.sleep(poll_interval_seconds)
+        if _is_audience_visible(account_id, audience_id):
+            _write_audience_state(
+                account_id=account_id,
+                audience_id=audience_id,
+                audience_name=audience_name,
+                source_account_id=source_account_id,
+                status="verified",
+                verified=True,
+            )
+            logger.info(
+                "[audience_grant] account=%d audience_id=%d 授权后验证通过 attempt=%d",
+                account_id,
+                audience_id,
+                i + 1,
+            )
+            return {"status": "verified", "audience_id": audience_id, "audience_name": audience_name}
+
+    raise RuntimeError(
+        f"人群包授权后目标账户仍不可见: account={account_id} audience_id={audience_id}"
+    )

+ 164 - 48
examples/auto_put_ad_mini/tools/creative_creation.py

@@ -11,7 +11,8 @@
   → MD5 幂等:同账户重复上传返回同 ID(无需本地缓存)
 - brand 组件:**必填**(漏传会 reject code=1800269)
   → brand_image_id 跨账户不可复用(reject code=1530003)
-  → 从 DB account_whitelist 读账户级 brand_name/brand_image_id
+  → 优先从 DB account_whitelist 读账户级 brand_name/brand_image_id
+  → 若缺失,按 ad_creation_account_config.brand_key 读取 brand_asset_template,上传并写回账户
 - dynamic_creative_type: DYNAMIC_CREATIVE_TYPE_PROGRAM(参考样本 9744753978)
 - description: 素材 title 一条(MVP)
 - jump_info / 命名:**piaoquantv xcx/save 接口统一管**
@@ -28,6 +29,7 @@ from typing import Optional
 from config import (
     CREATIVE_DESCRIPTION_COUNT_PER_AD,
     CREATIVE_DESCRIPTION_POOL,
+    LANDING_EXCLUDED_CATEGORIES,
     MARKETING_CARRIER_GH_ID,
     MAX_LANDING_ATTEMPTS_PER_AD,
     MAX_MATERIAL_PER_LANDING,
@@ -38,13 +40,35 @@ from tools.landing_plan import LandingPlanResult, create_landing_plan
 from tools.material_recall import Material, recall_materials_for_video
 from tools.video_recall import (
     LandingVideo,
+    PIAOQUANTV_HOT_FALLBACK_SOURCE,
+    PIAOQUANTV_VIDEO_SOURCE,
     fetch_landing_videos_for_account,
     get_account_crowd_package,
 )
+from tools.video_risk import VideoRiskResult, check_video_risk
 
 logger = logging.getLogger(__name__)
 
 
+def _landing_category_values(v: LandingVideo) -> set[str]:
+    raw = v.category or ""
+    return {part.strip() for part in raw.replace(",", ",").split(",") if part.strip()}
+
+
+def _is_landing_candidate(v: LandingVideo) -> bool:
+    """承接视频基础预筛:语义字段齐全,且内容品类不在黑名单。"""
+    if not (v.point_type and v.standard_element):
+        return False
+    excluded = _landing_category_values(v) & LANDING_EXCLUDED_CATEGORIES
+    if excluded:
+        logger.info(
+            "[creative_creation] landing=%d category=%r 命中排除品类,跳过",
+            v.video_id, v.category,
+        )
+        return False
+    return True
+
+
 def _pick_image_url(material: Material) -> str:
     """从素材里取 image URL — material.cover 优先,fallback 到 raw.imageList[0]"""
     if material.cover:
@@ -222,10 +246,12 @@ def find_existing_creative_by_image(
 
 
 def get_account_brand(account_id: int) -> dict:
-    """从 DB account_whitelist 读账户级 brand 资产。
+    """读取或初始化账户级 brand 资产。
 
     返回 {"brand_name": str, "brand_image_id": str}。
     跨账户 brand_image_id 不可复用,所以一定按 account_id 取。
+    如果 account_whitelist 还没有 brand_image_id,则按待投放配置的 brand_key
+    找统一品牌模板,上传模板 URL 到当前账户,并把新 image_id 写回 DB。
     """
     from db.connection import get_connection
 
@@ -237,17 +263,67 @@ def get_account_brand(account_id: int) -> dict:
                 (account_id,),
             )
             row = cur.fetchone()
+            if row and row.get("brand_name") and row.get("brand_image_id"):
+                return {
+                    "brand_name": row["brand_name"],
+                    "brand_image_id": str(row["brand_image_id"]),
+                }
+
+            cur.execute(
+                """
+                SELECT b.brand_key, b.brand_name, b.brand_image_url
+                FROM ad_creation_account_config c
+                JOIN brand_asset_template b
+                  ON b.brand_key = c.brand_key
+                 AND b.enabled = TRUE
+                WHERE c.account_id=%s
+                  AND c.enabled = TRUE
+                """,
+                (account_id,),
+            )
+            tpl = cur.fetchone()
     finally:
         conn.close()
 
-    if not row or not row.get("brand_name") or not row.get("brand_image_id"):
+    if not tpl or not tpl.get("brand_name") or not tpl.get("brand_image_url"):
         raise RuntimeError(
-            f"account {account_id} 未在 account_whitelist 配置 brand_name/brand_image_id,"
-            f"无法挂创意(腾讯 brand 组件必填)"
+            f"account {account_id} 未配置可用品牌资产模板。"
+            "请在 ad_creation_account_config.brand_key 关联 brand_asset_template"
         )
+
+    brand_name = tpl["brand_name"]
+    brand_image_url = tpl["brand_image_url"]
+    logger.info(
+        "[brand] account=%d 缺 brand_image_id,按模板 %s 上传品牌图",
+        account_id, tpl.get("brand_key"),
+    )
+    brand_image_id = images_add(account_id, brand_image_url)
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                UPDATE account_whitelist
+                SET brand_name=%s,
+                    brand_image_id=%s,
+                    updated_by=%s,
+                    updated_at=CURRENT_TIMESTAMP
+                WHERE account_id=%s
+                """,
+                (brand_name, brand_image_id, "auto-brand-init", account_id),
+            )
+        conn.commit()
+    finally:
+        conn.close()
+
+    logger.info(
+        "[brand] account=%d 已初始化 brand_name=%s brand_image_id=%s",
+        account_id, brand_name, brand_image_id,
+    )
     return {
-        "brand_name": row["brand_name"],
-        "brand_image_id": str(row["brand_image_id"]),
+        "brand_name": brand_name,
+        "brand_image_id": str(brand_image_id),
     }
 
 
@@ -272,11 +348,10 @@ def build_creative_request_body(
         jump_path: 由 xcx/save 返回的 page_url(小程序跳转路径)
         description_contents: 文案列表(可选);None 时自动 random.sample(用于审批表回显)
     """
-    # 文案池随机选 N 条(2026-06-09 用户确认照搬示例 78420850 策略)
-    # 示例所有创意 description × 3 都 ACTIVE;旧策略(素材标题)实测 DENIED + SUSPEND
+    # 默认文案由配置控制;生产当前统一使用"打开看看"。
     if description_contents is None:
         description_count = min(CREATIVE_DESCRIPTION_COUNT_PER_AD, len(CREATIVE_DESCRIPTION_POOL))
-        description_contents = random.sample(CREATIVE_DESCRIPTION_POOL, description_count)
+        description_contents = CREATIVE_DESCRIPTION_POOL[:description_count]
     jump_spec = {
         "page_type": "PAGE_TYPE_WECHAT_MINI_PROGRAM",
         "page_spec": {
@@ -314,13 +389,20 @@ def _pick_landing_and_materials(account_id: int) -> tuple[LandingVideo, list[Mat
     出口:确保 landing.point_type+standard_element 都有值(否则多路召回会全 skip)。
     """
     videos = fetch_landing_videos_for_account(account_id, page_size=50)
-    valid = [v for v in videos if v.point_type and v.standard_element]
+    valid = [v for v in videos if _is_landing_candidate(v)]
     if not valid:
         raise RuntimeError(
             f"account={account_id} 无 pointType+standardElement 都有值的承接视频"
         )
 
     for v in valid:
+        risk = check_video_risk(v.video_id)
+        if not risk.passed:
+            logger.warning(
+                "[creative_creation] landing=%d 风险拦截:%s",
+                v.video_id, risk.reason,
+            )
+            continue
         materials = recall_materials_for_video(v, final_top_n=5)
         if materials:
             return v, materials
@@ -444,7 +526,7 @@ def prepare_one_creative_for_ad(
                                召回后过滤掉这些,避免同广告挂重复素材被腾讯模型降权曝光。
 
     Returns:
-        pending record dict(飞书表格 13 列字段 + Phase 3 POST 用的完整 body + 元数据);
+        pending record dict(飞书表格字段 + Phase 3 POST 用的完整 body + 元数据);
         所有 landing 都召回 0 素材时返回 None。
     """
     from datetime import datetime, timezone
@@ -452,39 +534,69 @@ def prepare_one_creative_for_ad(
     excluded_material_ids = excluded_material_ids or set()
     excluded_landing_ids = excluded_landing_ids or set()
 
-    # 2026-06-10 用户要求:扩大 landing 池(page_size=100,不 [:max_landings] 截)
-    # 因为 used_landing_ids 全局共享后,每次 prepare 候选越来越少,池子太小会撞底
-    videos = fetch_landing_videos_for_account(account_id, page_size=100)
-    valid = [v for v in videos if v.point_type and v.standard_element]
-    # 每次 prepare 仍最多尝试 max_landings 个未用 landing(单次时间预算控制)
-    logger.info(
-        "[prepare_one_creative] account=%d adgroup=%d valid landing=%d (池子) excl_mat=%d excl_landing=%d",
-        account_id, adgroup_id, len(valid),
-        len(excluded_material_ids), len(excluded_landing_ids),
-    )
-
     chosen_landing = None
     chosen_material = None
-    attempts = 0
-    for v in valid:
-        # 2026-06-10:跨广告 landing 去重(全局共享 used set)
-        if v.video_id in excluded_landing_ids:
-            continue
-        attempts += 1
-        if attempts > max_landings:        # ← 单次 prepare 最多 10 次尝试
-            break
-        materials = recall_materials_for_video(v, final_top_n=max_materials_per_landing)
-        # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
-        fresh = [m for m in materials if m.material_id not in excluded_material_ids]
-        if fresh:
-            chosen_landing = v
-            chosen_material = fresh[0]
+    chosen_risk: Optional[VideoRiskResult] = None
+    chosen_landing_source = ""
+
+    # 每个来源最多拉 100 条视频。先主池,主池产不出可用创意时再用同人群包 hot 兜底。
+    source_plan = [("primary", PIAOQUANTV_VIDEO_SOURCE)]
+    if PIAOQUANTV_HOT_FALLBACK_SOURCE != PIAOQUANTV_VIDEO_SOURCE:
+        source_plan.append(("hot", PIAOQUANTV_HOT_FALLBACK_SOURCE))
+
+    for source_label, source in source_plan:
+        videos = fetch_landing_videos_for_account(
+            account_id,
+            page_size=100,
+            source=source,
+            enable_hot_fallback=False,
+        )
+        valid = [v for v in videos if _is_landing_candidate(v)]
+        logger.info(
+            "[prepare_one_creative] account=%d adgroup=%d source=%s valid landing=%d/100 excl_mat=%d excl_landing=%d",
+            account_id, adgroup_id, source_label, len(valid),
+            len(excluded_material_ids), len(excluded_landing_ids),
+        )
+
+        attempts = 0
+        for v in valid:
+            # 2026-06-10:跨广告 landing 去重(全局共享 used set)
+            if v.video_id in excluded_landing_ids:
+                continue
+            attempts += 1
+            if attempts > max_landings:
+                break
+
+            # 2026-06-29:承接视频风险审核。必须在素材召回 / xcx-save 前完成,
+            # 避免高风险 landing 继续产生 plan/rootSourceId 等外部副作用。
+            risk = check_video_risk(v.video_id)
+            if not risk.passed:
+                logger.warning(
+                    "[prepare_one_creative]   landing=%d 风险拦截:%s",
+                    v.video_id, risk.reason,
+                )
+                continue
+
+            materials = recall_materials_for_video(v, final_top_n=max_materials_per_landing)
+            # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
+            fresh = [m for m in materials if m.material_id not in excluded_material_ids]
+            if fresh:
+                chosen_landing = v
+                chosen_material = fresh[0]
+                chosen_risk = risk
+                chosen_landing_source = source_label
+                break
+            if materials:
+                logger.info(
+                    "[prepare_one_creative]   landing=%d 召回 %d 全在 excluded,试下一条",
+                    v.video_id, len(materials),
+                )
+        if chosen_landing and chosen_material:
             break
-        if materials:
-            logger.info(
-                "[prepare_one_creative]   landing=%d 召回 %d 全在 excluded,试下一条",
-                v.video_id, len(materials),
-            )
+        logger.info(
+            "[prepare_one_creative] account=%d adgroup=%d source=%s 未产出可用创意",
+            account_id, adgroup_id, source_label,
+        )
 
     if not chosen_landing or not chosen_material:
         logger.error(
@@ -511,10 +623,9 @@ def prepare_one_creative_for_ad(
     # 读账户 brand
     brand = get_account_brand(account_id)
 
-    # 先 random.sample 文案,再传给 build_body — 这样 record 能存住"实际选了哪 3 条",
-    # 飞书审批表展示一致(不能让 build 内部再 random,否则 record 跟 body 文案不同步)
+    # 先确定文案,再传给 build_body — 这样 record 跟 body 文案一致。
     desc_count = min(CREATIVE_DESCRIPTION_COUNT_PER_AD, len(CREATIVE_DESCRIPTION_POOL))
-    description_contents = random.sample(CREATIVE_DESCRIPTION_POOL, desc_count)
+    description_contents = CREATIVE_DESCRIPTION_POOL[:desc_count]
 
     # 构造完整 POST body(Phase 3 直接用)
     body = build_creative_request_body(
@@ -531,8 +642,8 @@ def prepare_one_creative_for_ad(
     ad_info = _fetch_ad_metadata_for_approval(account_id, adgroup_id)
 
     today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
-    return {
-        # === 飞书表格 13 列(im_approval_creation 用)===
+    rec = {
+        # === 飞书表格字段(im_approval_creation 用)===
         "approval_date": today,
         "account_id": account_id,
         "audience_tier": crowd_package,
@@ -544,6 +655,8 @@ def prepare_one_creative_for_ad(
         "landing_video_id": chosen_landing.video_id,
         "landing_video_url": chosen_landing.video_url,
         "landing_title": chosen_landing.title,
+        "landing_source": chosen_landing_source,
+        "landing_category": chosen_landing.category,
         "material_cover_url": chosen_material.cover,
         # 素材质量字段(2026-06-10 batchByText 升级 — 给 Task 25 飞书表展示用)
         "material_ctr": chosen_material.ctr,
@@ -562,6 +675,9 @@ def prepare_one_creative_for_ad(
         # 文案选择回显(飞书表格"创意文案"列要展示)
         "_description_contents": description_contents,
     }
+    if chosen_risk is not None:
+        rec.update(chosen_risk.to_record_fields())
+    return rec
 
 
 def _fetch_ad_metadata_for_approval(account_id: int, adgroup_id: int) -> dict:
@@ -727,7 +843,7 @@ def try_create_one_creative_with_fallback(
         成功:dynamic_creative_id;穷尽:None(记 error log,主循环继续下一广告)
     """
     videos = fetch_landing_videos_for_account(account_id, page_size=max_landings * 2)
-    valid = [v for v in videos if v.point_type and v.standard_element][:max_landings]
+    valid = [v for v in videos if _is_landing_candidate(v)][:max_landings]
     logger.info(
         "[try_create_one_creative] account=%d adgroup=%d 候选 landing=%d",
         account_id, adgroup_id, len(valid),

+ 21 - 15
examples/auto_put_ad_mini/tools/im_approval_creation.py

@@ -2,13 +2,13 @@
 
 数据流:
   Phase 1 准备 pending records (list[dict])
-    → generate_approval_xlsx          生成 13 列 xlsx(hyperlink/下拉/颜色/冻结)
+    → generate_approval_xlsx          生成 20 列 xlsx(hyperlink/下拉/颜色/冻结)
     → send_approval_to_feishu         上传转飞书 sheet + 发链接到群
-    → poll_approval_actions           轮询读 sheet "决策"列(M 列),拿 approve/reject/hold
+    → poll_approval_actions           轮询读 sheet "决策"列,拿 approve/reject/hold
     → 调用方按 row_idx 匹配回 records 写决策
 
 设计决策(2026-06-09 用户确认):
-  - 13 列:A(灰)5 + B(紫)3 + C(橙)4 + D(绿)1,header 颜色分组
+  - 20 列:A(灰)5 + B(紫)3 + C(橙)11 + D(绿)1,header 颜色分组
   - 素材预览用 HYPERLINK 公式(=IMAGE() 不被飞书支持,write-images API 留作 P3-5)
   - 落地视频列也用 HYPERLINK(点 video_id 跳转 video_url)
   - 决策列下拉:approve / reject / hold
@@ -41,14 +41,15 @@ logger = logging.getLogger(__name__)
 
 FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
 
-# 17 列(中文)— 2026-06-10 加 CTR/成本/曝光数 3 列
+# 20 列(中文)— 2026-06-29 加落地视频风险审核 3 列
 HEADERS = [
     # A 浅灰 5 列
     "日期", "账户ID", "人群包", "广告ID", "广告名称",
     # B 浅紫 3 列
     "出价(元)", "投放版位", "年龄定向",
-    # C 浅橙 8 列(原 5 + 新 CTR/成本/曝光数 3)
-    "落地视频", "落地视频标题", "素材预览", "创意文案",
+    # C 浅橙 11 列(落地视频 + 风险审核 + 素材质量)
+    "落地视频", "落地视频标题", "风险等级", "风险标签", "风险原因",
+    "素材预览", "创意文案",
     "CTR", "成本(元)", "曝光数",
     "创意名(归因)",
     # D 浅绿 1 列
@@ -58,20 +59,21 @@ HEADERS = [
 GROUP_COLORS = [
     ((1, 5), "FFD9D9D9"),    # A 浅灰
     ((6, 8), "FFD9C8E8"),    # B 浅紫
-    ((9, 16), "FFFCD8B4"),   # C 浅橙(扩到 16)
-    ((17, 17), "FFC6E0B4"),  # D 浅绿(第 17 列)
+    ((9, 19), "FFFCD8B4"),   # C 浅橙
+    ((20, 20), "FFC6E0B4"),  # D 浅绿(第 20 列)
 ]
 
 COL_WIDTHS = {
     "A": 12, "B": 14, "C": 16, "D": 16, "E": 28,
     "F": 10, "G": 28, "H": 12,
-    "I": 14, "J": 26, "K": 18, "L": 30,
-    "M": 10, "N": 12, "O": 10,   # 新 3 列:CTR / 成本 / 曝光数
-    "P": 38,                      # 创意名(归因)
-    "Q": 14,                      # 决策
+    "I": 14, "J": 26, "K": 10, "L": 24, "M": 32,
+    "N": 18, "O": 30,
+    "P": 10, "Q": 12, "R": 10,   # CTR / 成本 / 曝光数
+    "S": 38,                      # 创意名(归因)
+    "T": 14,                      # 决策
 }
 
-DECISION_COL_LETTER = "Q"  # 第 17 列(原 N → Q)
+DECISION_COL_LETTER = "T"  # 第 20 列(风险审核列新增后后移)
 VALID_ACTIONS = ("approve", "reject", "hold")
 
 
@@ -83,7 +85,7 @@ def _color_for_col(col_idx: int) -> str:
 
 
 def _format_record_to_row(rec: dict) -> list:
-    """把 pending record 字典转成 xlsx 一行(13 列)。
+    """把 pending record 字典转成 xlsx 一行(20 列)。
 
     record 必含字段:
         approval_date, account_id, audience_tier, adgroup_id, adgroup_name,
@@ -112,6 +114,9 @@ def _format_record_to_row(rec: dict) -> list:
         # 落地视频:HYPERLINK(video_url, video_id)
         f'=HYPERLINK("{rec["landing_video_url"]}","{rec["landing_video_id"]}")',
         rec["landing_title"][:30],
+        rec.get("landing_risk_level", ""),
+        rec.get("landing_risk_tag_ids", ""),
+        rec.get("landing_risk_reason", ""),
         # 素材预览:HYPERLINK(cover_url, "查看素材")— 见模块顶部说明
         f'=HYPERLINK("{rec["material_cover_url"]}","查看素材")',
         # 创意文案(2026-06-09 加列):description 换行展示
@@ -126,7 +131,7 @@ def _format_record_to_row(rec: dict) -> list:
 
 
 def generate_approval_xlsx(records: list[dict], output_path: Path) -> Path:
-    """生成 13 列待审批 xlsx,含 hyperlink/下拉/颜色/冻结。"""
+    """生成 20 列待审批 xlsx,含 hyperlink/下拉/颜色/冻结。"""
     wb = Workbook()
     ws = wb.active
     ws.title = "创意审批"
@@ -224,6 +229,7 @@ async def send_approval_to_feishu(
     if not preamble:
         preamble = (
             "【创意搭建审批】下方在线表格列出本次准备挂的创意。\n"
+            "系统已在创建落地计划前过滤高风险视频,表格中的【风险等级/风险标签/风险原因】为通过项的审核摘要。\n"
             "请在最右侧【决策】列下拉框选择 approve / reject / hold。\n"
             "我们会在 2 小时内或所有行决策完成后,执行 approve 项,跳过 reject / hold。\n"
             "提示:点击【素材预览】或【落地视频】可在浏览器打开查看(若 403 请右键新标签或复制 URL)。"

+ 58 - 0
examples/auto_put_ad_mini/tools/material_recall.py

@@ -70,6 +70,11 @@ POINT_TYPE_TO_SUBSTANCE = {
     "关键点": "KEYPOINT_SUBSTANCE",
     "目的点": "PURPOSE_SUBSTANCE",
 }
+POINT_TYPE_TO_VIDEO_CONFIG = {
+    "灵感点": "VIDEO_INSPIRATION",
+    "关键点": "VIDEO_KEYPOINT",
+    "目的点": "VIDEO_PURPOSE",
+}
 
 # fallback(动态接口失败时用)
 MATERIAL_EFFECTIVE_CONFIG_CODES = ["VIDEO_TOPIC"] + list(POINT_TYPE_TO_SUBSTANCE.values())
@@ -201,6 +206,21 @@ def _pick_query_strategies_for_batch(landing: LandingVideo) -> List[tuple]:
     return strategies
 
 
+def _fallback_config_codes_for_strategy(config_code: str, landing: LandingVideo) -> List[str]:
+    """batchByText 无结果时给 matchByText 的兼容 configCode。
+
+    2026-06-30 实测:batchByText 对 MATERIAL 返回 0 时,旧 matchByText 仍可在
+    VIDEO_KEYPOINT/VIDEO_INSPIRATION 等历史向量字段命中素材。
+    """
+    out = [config_code]
+    video_cc = POINT_TYPE_TO_VIDEO_CONFIG.get(landing.point_type)
+    if video_cc and video_cc not in out:
+        out.append(video_cc)
+    if config_code != "VIDEO_TOPIC" and "VIDEO_TOPIC" not in out:
+        out.append("VIDEO_TOPIC")
+    return out
+
+
 def _call_batch_by_text(
     query_text: str,
     config_codes: List[str],
@@ -380,6 +400,44 @@ def recall_materials_for_video(
         )
         if mats:
             return mats[:final_top_n]
+
+        # batchByText 当前可能对 MATERIAL 返回 0;降级到历史 matchByText 路径。
+        for fallback_cc in _fallback_config_codes_for_strategy(config_code, landing):
+            try:
+                fallback_items = _call_match_by_text(
+                    query_text=query_text,
+                    config_code=fallback_cc,
+                    material_top_n=RECALL_DISPLAY_K,
+                    source_labels=source_labels or RECALL_SOURCE_LABELS,
+                )
+            except Exception as e:
+                logger.error(
+                    "[material_recall] fallback matchByText %s 失败,试下一个:%s",
+                    fallback_cc, e,
+                )
+                continue
+
+            fmats, fn_bl, fn_low_imp, fn_low_ctr = _items_to_materials(
+                fallback_items, RECALL_MIN_IMPRESSIONS, RECALL_MIN_CTR,
+            )
+            logger.info(
+                "[material_recall]     fallback=%s 返回 %d 条,⊘ 黑名单 %d,⊘ imp<=%d %d,⊘ ctr<%.2f %d → 保留 %d",
+                fallback_cc, len(fallback_items), fn_bl,
+                RECALL_MIN_IMPRESSIONS, fn_low_imp,
+                RECALL_MIN_CTR, fn_low_ctr,
+                len(fmats),
+            )
+            if fmats:
+                fmats.sort(
+                    key=lambda m: (
+                        m.ctr or 0,
+                        m.impressions or 0,
+                        m.quality_score or 0,
+                        m.score or 0,
+                    ),
+                    reverse=True,
+                )
+                return fmats[:final_top_n]
         # else 继续下一个策略
 
     logger.info(

+ 74 - 6
examples/auto_put_ad_mini/tools/video_recall.py

@@ -20,10 +20,18 @@
 import logging
 import os
 from dataclasses import dataclass, field
+from pathlib import Path
 from typing import List, Optional
 
 import httpx
 
+try:
+    from dotenv import load_dotenv
+
+    load_dotenv(Path(__file__).resolve().parents[1] / ".env")
+except Exception:
+    pass
+
 logger = logging.getLogger(__name__)
 
 PIAOQUANTV_VIDEO_API = "https://tp-open.piaoquantv.com/contentPlatform/plan/videoContentList"
@@ -31,6 +39,21 @@ PIAOQUANTV_TOKEN = os.getenv(
     "PIAOQUANTV_TOKEN",
     "f6e07dba7fe3476cb31fd3733d607c5c",  # 用户 2026-06-08 提供
 )
+PIAOQUANTV_VIDEO_SOURCE = os.getenv("PIAOQUANTV_VIDEO_SOURCE", "")
+_PIAOQUANTV_VIDEO_TYPE_RAW = os.getenv("PIAOQUANTV_VIDEO_TYPE", "").strip()
+PIAOQUANTV_VIDEO_TYPE = int(_PIAOQUANTV_VIDEO_TYPE_RAW) if _PIAOQUANTV_VIDEO_TYPE_RAW else None
+PIAOQUANTV_HOT_FALLBACK_ENABLED = os.getenv(
+    "PIAOQUANTV_HOT_FALLBACK_ENABLED", "true"
+).strip().lower() not in {"0", "false", "no", "off"}
+PIAOQUANTV_HOT_FALLBACK_SOURCE = os.getenv("PIAOQUANTV_HOT_FALLBACK_SOURCE", "hot")
+
+
+def _normalize_category(raw) -> str:
+    if raw is None:
+        return ""
+    if isinstance(raw, list):
+        return ",".join(str(v).strip() for v in raw if str(v).strip())
+    return str(raw).strip()
 
 
 @dataclass
@@ -51,6 +74,7 @@ class LandingVideo:
     sim: float
     visit_uv: int
     # 召回素材时用的语义维度(对应 material_recall 三策略)
+    category: str                   # 内容品类,例:"早中晚好";用于内容侧过滤/多样性
     standard_element: str           # 例:"煽动性"
     category_name: str              # 例:"情感强度控制"
     demand_content_title: str       # 关联原视频标题
@@ -68,8 +92,8 @@ def fetch_landing_videos(
     crowd_package: str,
     page_size: int = 10,
     page_num: int = 1,
-    video_business_type: int = 5,    # type=5 = 小程序投流(用户确认)
-    source: str = "prior",            # 投放人群需求类型(用户确认)
+    video_business_type: Optional[int] = PIAOQUANTV_VIDEO_TYPE,
+    source: str = PIAOQUANTV_VIDEO_SOURCE,
     title_filter: str = "",
     sort: int = 0,
     timeout: int = 30,
@@ -87,12 +111,14 @@ def fetch_landing_videos(
     body = {
         "title": title_filter,
         "sort": sort,
-        "type": video_business_type,
         "pageNum": page_num,
         "pageSize": page_size,
-        "source": source,
         "crowdPackage": crowd_package,
     }
+    if video_business_type is not None:
+        body["type"] = video_business_type
+    if source is not None:
+        body["source"] = source
     headers = {
         "content-type": "application/json",
         "token": PIAOQUANTV_TOKEN,
@@ -129,6 +155,7 @@ def fetch_landing_videos(
                 rov=float(obj.get("rov") or 0.0),
                 sim=float(obj.get("sim") or 0.0),
                 visit_uv=int(obj.get("visitUv") or 0),
+                category=_normalize_category(obj.get("category")),
                 standard_element=obj.get("standardElement") or "",
                 category_name=obj.get("categoryName") or "",
                 demand_content_title=obj.get("demandContentTitle") or "",
@@ -179,7 +206,48 @@ def get_account_crowd_package(account_id: int) -> str:
 def fetch_landing_videos_for_account(
     account_id: int,
     page_size: int = 10,
+    source: Optional[str] = None,
+    enable_hot_fallback: bool = True,
 ) -> List[LandingVideo]:
-    """根据账户的 crowd_package 字段拉视频。"""
+    """根据账户的 crowd_package 字段拉视频。
+
+    主池不足时用同一个 crowdPackage + source=hot 补充热门视频。
+    """
     crowd_package = get_account_crowd_package(account_id)
-    return fetch_landing_videos(crowd_package=crowd_package, page_size=page_size)
+    selected_source = PIAOQUANTV_VIDEO_SOURCE if source is None else source
+    primary = fetch_landing_videos(
+        crowd_package=crowd_package,
+        page_size=page_size,
+        source=selected_source,
+    )
+    if (
+        source is not None
+        or not enable_hot_fallback
+        or not PIAOQUANTV_HOT_FALLBACK_ENABLED
+        or len(primary) >= page_size
+        or selected_source == PIAOQUANTV_HOT_FALLBACK_SOURCE
+    ):
+        return primary
+
+    missing = page_size - len(primary)
+    logger.info(
+        "[video_recall] primary 不足,用 hot 兜底: account=%d crowd=%r primary=%d need=%d",
+        account_id, crowd_package, len(primary), missing,
+    )
+    fallback = fetch_landing_videos(
+        crowd_package=crowd_package,
+        page_size=missing,
+        source=PIAOQUANTV_HOT_FALLBACK_SOURCE,
+    )
+    seen = {v.video_id for v in primary}
+    merged = list(primary)
+    for v in fallback:
+        if v.video_id in seen:
+            continue
+        merged.append(v)
+        seen.add(v.video_id)
+    logger.info(
+        "[video_recall] primary + hot 合并后 %d 条(account=%d crowd=%r)",
+        len(merged), account_id, crowd_package,
+    )
+    return merged

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

@@ -0,0 +1,148 @@
+"""承接视频风险标签查询与审核。
+
+在模块 B 创建 landing plan(xcx/save)之前调用:
+  video_id → piaoquantv getVideoTagIds → 风险等级 → pass/block
+
+接口返回的 data 是 tag id 列表。只有 config.VIDEO_RISK_TAG_LEVELS 中的 tag
+参与风险等级计算,其他 tag 保留到 unknown_tag_ids 便于排查,但不默认阻断。
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, asdict
+from typing import Any
+
+import httpx
+
+from config import (
+    VIDEO_RISK_API_TIMEOUT_SECONDS,
+    VIDEO_RISK_API_URL,
+    VIDEO_RISK_CHECK_ENABLED,
+    VIDEO_RISK_MAX_ALLOWED_LEVEL,
+    VIDEO_RISK_TAG_LEVELS,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class VideoRiskResult:
+    video_id: int
+    passed: bool
+    max_level: int
+    risk_tag_ids: list[str]
+    unknown_tag_ids: list[str]
+    reason: str
+    raw_tag_ids: list[str]
+
+    def to_record_fields(self) -> dict[str, Any]:
+        return {
+            "landing_risk_passed": self.passed,
+            "landing_risk_level": self.max_level,
+            "landing_risk_tag_ids": ",".join(self.risk_tag_ids),
+            "landing_unknown_tag_ids": ",".join(self.unknown_tag_ids),
+            "landing_risk_reason": self.reason,
+            "_landing_risk": asdict(self),
+        }
+
+
+def _normalize_tag_ids(data: Any) -> list[str]:
+    if data is None:
+        return []
+    if isinstance(data, list):
+        return [str(x) for x in data if x is not None]
+    return [str(data)]
+
+
+def query_video_risk_tags(video_id: int) -> list[str]:
+    """查询视频风险 tag ids。
+
+    Raises:
+        RuntimeError: HTTP / 接口 code 非 0 / data 非预期时抛出。
+    """
+    resp = httpx.post(
+        VIDEO_RISK_API_URL,
+        json={"videoId": int(video_id)},
+        headers={"Content-Type": "application/json"},
+        timeout=VIDEO_RISK_API_TIMEOUT_SECONDS,
+    )
+    resp.raise_for_status()
+    payload = resp.json()
+    if payload.get("code") != 0:
+        raise RuntimeError(
+            f"video risk api failed video_id={video_id} "
+            f"code={payload.get('code')} msg={payload.get('msg')}"
+        )
+    return _normalize_tag_ids(payload.get("data"))
+
+
+def evaluate_video_risk(video_id: int, tag_ids: list[str]) -> VideoRiskResult:
+    """把 tag ids 映射成最高风险等级并判断是否可用。"""
+    risk_tag_ids: list[str] = []
+    unknown_tag_ids: list[str] = []
+    levels: list[int] = []
+
+    for tag_id in tag_ids:
+        level = VIDEO_RISK_TAG_LEVELS.get(str(tag_id))
+        if level is None:
+            unknown_tag_ids.append(str(tag_id))
+            continue
+        risk_tag_ids.append(str(tag_id))
+        levels.append(int(level))
+
+    max_level = max(levels) if levels else 0
+    passed = max_level <= VIDEO_RISK_MAX_ALLOWED_LEVEL
+    if not VIDEO_RISK_CHECK_ENABLED:
+        passed = True
+        reason = "risk check disabled"
+    elif max_level == 0:
+        reason = "no mapped risk tags"
+    elif passed:
+        reason = f"risk level {max_level} <= allowed {VIDEO_RISK_MAX_ALLOWED_LEVEL}"
+    else:
+        reason = f"risk level {max_level} > allowed {VIDEO_RISK_MAX_ALLOWED_LEVEL}"
+
+    return VideoRiskResult(
+        video_id=int(video_id),
+        passed=passed,
+        max_level=max_level,
+        risk_tag_ids=risk_tag_ids,
+        unknown_tag_ids=unknown_tag_ids,
+        reason=reason,
+        raw_tag_ids=[str(x) for x in tag_ids],
+    )
+
+
+def check_video_risk(video_id: int) -> VideoRiskResult:
+    """查询并评估视频风险。
+
+    接口异常时返回 passed=False,让调用方跳过该 landing,避免在风险未知时创建 plan。
+    """
+    if not VIDEO_RISK_CHECK_ENABLED:
+        return evaluate_video_risk(int(video_id), [])
+
+    try:
+        tag_ids = query_video_risk_tags(int(video_id))
+        result = evaluate_video_risk(int(video_id), tag_ids)
+        logger.info(
+            "[video_risk] video_id=%s passed=%s level=%s risk_tags=%s unknown_tags=%s reason=%s",
+            video_id,
+            result.passed,
+            result.max_level,
+            result.risk_tag_ids,
+            result.unknown_tag_ids,
+            result.reason,
+        )
+        return result
+    except Exception as e:
+        logger.warning("[video_risk] video_id=%s 查询失败,跳过该视频:%s", video_id, e)
+        return VideoRiskResult(
+            video_id=int(video_id),
+            passed=False,
+            max_level=999,
+            risk_tag_ids=[],
+            unknown_tag_ids=[],
+            reason=f"risk api error: {e}",
+            raw_tag_ids=[],
+        )

+ 75 - 0
tests/test_video_risk.py

@@ -0,0 +1,75 @@
+"""Unit tests for module B landing-video risk evaluation.
+
+The production module imports `config` from auto_put_ad_mini, which pulls the
+full agent runtime. These tests inject a minimal config module so the pure risk
+mapping logic can be tested without external services or optional dependencies.
+"""
+
+import importlib
+import sys
+import types
+import unittest
+
+
+def _load_video_risk_with_fake_config(max_allowed_level: int = 1):
+    fake_config = types.ModuleType("config")
+    fake_config.VIDEO_RISK_API_TIMEOUT_SECONDS = 10
+    fake_config.VIDEO_RISK_API_URL = "https://example.invalid/video-risk"
+    fake_config.VIDEO_RISK_CHECK_ENABLED = True
+    fake_config.VIDEO_RISK_MAX_ALLOWED_LEVEL = max_allowed_level
+    fake_config.VIDEO_RISK_TAG_LEVELS = {
+        "85856": 1,
+        "85862": 2,
+        "85863": 3,
+        "85864": 4,
+        "85865": 5,
+        "85866": 6,
+        "85867": 7,
+        "85868": 8,
+        "85869": 9,
+        "85870": 10,
+    }
+
+    old_config = sys.modules.get("config")
+    sys.modules["config"] = fake_config
+    sys.modules.pop("examples.auto_put_ad_mini.tools.video_risk", None)
+    try:
+        return importlib.import_module("examples.auto_put_ad_mini.tools.video_risk")
+    finally:
+        if old_config is None:
+            sys.modules.pop("config", None)
+        else:
+            sys.modules["config"] = old_config
+
+
+class VideoRiskEvaluationTest(unittest.TestCase):
+    def test_level_one_passes_when_threshold_is_one(self):
+        video_risk = _load_video_risk_with_fake_config(max_allowed_level=1)
+        result = video_risk.evaluate_video_risk(123, ["85856"])
+        self.assertTrue(result.passed)
+        self.assertEqual(result.max_level, 1)
+
+    def test_level_two_is_blocked_when_threshold_is_one(self):
+        video_risk = _load_video_risk_with_fake_config(max_allowed_level=1)
+        result = video_risk.evaluate_video_risk(123, ["85862"])
+        self.assertFalse(result.passed)
+        self.assertEqual(result.max_level, 2)
+
+    def test_sample_level_seven_is_blocked(self):
+        video_risk = _load_video_risk_with_fake_config(max_allowed_level=1)
+        result = video_risk.evaluate_video_risk(71187017, ["85867", "85967", "88404"])
+        self.assertFalse(result.passed)
+        self.assertEqual(result.max_level, 7)
+        self.assertEqual(result.risk_tag_ids, ["85867"])
+        self.assertEqual(result.unknown_tag_ids, ["85967", "88404"])
+
+    def test_unknown_tags_do_not_block_by_default(self):
+        video_risk = _load_video_risk_with_fake_config(max_allowed_level=1)
+        result = video_risk.evaluate_video_risk(123, ["99999"])
+        self.assertTrue(result.passed)
+        self.assertEqual(result.max_level, 0)
+        self.assertEqual(result.unknown_tag_ids, ["99999"])
+
+
+if __name__ == "__main__":
+    unittest.main()