"""腾讯广告人群包解析、授权、可见性验证。 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" AUDIENCE_NAME_ALIASES = { # 业务配置名保留给内容服务 crowdPackage 使用;腾讯侧真实人群包命名是 R_330+。 "回流330以上人群": ["R_330+", "R330+"], } def _audience_lookup_names(audience_name: str) -> list[str]: """返回人群包名称及其别名列表(用于兼容业务配置名与腾讯侧真实命名)。""" names = [audience_name] for alias in AUDIENCE_NAME_ALIASES.get(audience_name, []): if alias and alias not in names: names.append(alias) return names 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: """在源账户中按名称匹配度挑选可用(SUCCESS + ONLINE)的人群包,找不到则抛错。""" items = _fetch_custom_audiences(source_account_id) usable = [ item for item in items if item.get("status") == "SUCCESS" and item.get("online_status") == "ONLINE" ] lookup_names = _audience_lookup_names(audience_name) def score_one(item_name: str, lookup_name: str) -> int: if item_name == lookup_name: return 3 if item_name.startswith(f"{lookup_name}_"): return 2 if lookup_name in item_name: return 1 return 0 def score(item: dict) -> tuple[int, str]: name = item.get("name") or "" rank = max(score_one(name, lookup_name) for lookup_name in lookup_names) 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}, lookup_names={lookup_names!r}" ) matches.sort(key=score, reverse=True) chosen = matches[0] logger.info( "[audience_grant] source=%d audience_name=%r lookup_names=%r → audience_id=%s full_name=%r", source_account_id, audience_name, lookup_names, chosen.get("audience_id"), chosen.get("name"), ) return chosen def _load_account_config(account_id: int) -> Optional[dict]: """从 ad_creation_account_config 表加载账户的人群包配置(仅 enabled=TRUE)。""" 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: """将人群包解析/授权状态回写到 ad_creation_account_config 和 account_whitelist 表。""" 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: """调用 audience_grant_relations/add 将源账户人群包授权给目标账户,失败抛错。""" 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}" )