audience_grant.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. """腾讯广告人群包解析、授权、可见性验证。
  2. Phase 0 创建广告前调用,保证非泛人群账户的 custom_audience 已经在目标账户可见。
  3. """
  4. from __future__ import annotations
  5. import logging
  6. import os
  7. import time
  8. from typing import Optional
  9. from tools.ad_api import _check, _get, _post
  10. logger = logging.getLogger(__name__)
  11. DEFAULT_AUDIENCE_SOURCE_ACCOUNT_ID = int(
  12. os.getenv("TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID", "55615440")
  13. )
  14. AUDIENCE_GRANT_BUSINESS_ID = int(os.getenv("TENCENT_AUDIENCE_GRANT_BUSINESS_ID", "0") or 0)
  15. TARGET_PERMISSION = "GRANT_PERMISSION_TYPE_TARGET"
  16. AUDIENCE_NAME_ALIASES = {
  17. # 业务配置名保留给内容服务 crowdPackage 使用;腾讯侧真实人群包命名是 R_330+。
  18. "回流330以上人群": ["R_330+", "R330+"],
  19. }
  20. def _audience_lookup_names(audience_name: str) -> list[str]:
  21. """返回人群包名称及其别名列表(用于兼容业务配置名与腾讯侧真实命名)。"""
  22. names = [audience_name]
  23. for alias in AUDIENCE_NAME_ALIASES.get(audience_name, []):
  24. if alias and alias not in names:
  25. names.append(alias)
  26. return names
  27. def _fetch_custom_audiences(account_id: int) -> list[dict]:
  28. """分页拉取指定账户下的全部自定义人群包列表。"""
  29. out: list[dict] = []
  30. page = 1
  31. while True:
  32. resp = _get(
  33. "/custom_audiences/get",
  34. {"account_id": account_id, "page": page, "page_size": 100},
  35. )
  36. data = _check(resp, "custom_audiences/get")
  37. out.extend(data.get("list") or [])
  38. page_info = data.get("page_info") or {}
  39. total_page = int(page_info.get("total_page") or page)
  40. if page >= total_page:
  41. break
  42. page += 1
  43. return out
  44. def _is_audience_visible(account_id: int, audience_id: int) -> bool:
  45. """判断指定人群包在目标账户是否可见。"""
  46. return any(
  47. int(item.get("audience_id") or 0) == int(audience_id)
  48. for item in _fetch_custom_audiences(account_id)
  49. )
  50. def _pick_source_audience(source_account_id: int, audience_name: str) -> dict:
  51. """在源账户中按名称匹配度挑选可用(SUCCESS + ONLINE)的人群包,找不到则抛错。"""
  52. items = _fetch_custom_audiences(source_account_id)
  53. usable = [
  54. item for item in items
  55. if item.get("status") == "SUCCESS"
  56. and item.get("online_status") == "ONLINE"
  57. ]
  58. lookup_names = _audience_lookup_names(audience_name)
  59. def score_one(item_name: str, lookup_name: str) -> int:
  60. if item_name == lookup_name:
  61. return 3
  62. if item_name.startswith(f"{lookup_name}_"):
  63. return 2
  64. if lookup_name in item_name:
  65. return 1
  66. return 0
  67. def score(item: dict) -> tuple[int, str]:
  68. name = item.get("name") or ""
  69. rank = max(score_one(name, lookup_name) for lookup_name in lookup_names)
  70. return rank, str(item.get("created_time") or "")
  71. matches = [item for item in usable if score(item)[0] > 0]
  72. if not matches:
  73. raise RuntimeError(
  74. f"source_account={source_account_id} 未找到 ONLINE/SUCCESS 人群包:"
  75. f" audience_name={audience_name!r}, lookup_names={lookup_names!r}"
  76. )
  77. matches.sort(key=score, reverse=True)
  78. chosen = matches[0]
  79. logger.info(
  80. "[audience_grant] source=%d audience_name=%r lookup_names=%r → audience_id=%s full_name=%r",
  81. source_account_id,
  82. audience_name,
  83. lookup_names,
  84. chosen.get("audience_id"),
  85. chosen.get("name"),
  86. )
  87. return chosen
  88. def _load_account_config(account_id: int) -> Optional[dict]:
  89. """从 ad_creation_account_config 表加载账户的人群包配置(仅 enabled=TRUE)。"""
  90. from db.connection import get_connection
  91. conn = get_connection()
  92. try:
  93. with conn.cursor() as cur:
  94. cur.execute(
  95. """
  96. SELECT account_id, audience_name, audience_pack_id,
  97. audience_source_account_id, audience_grant_status
  98. FROM ad_creation_account_config
  99. WHERE account_id=%s AND enabled=TRUE
  100. """,
  101. (account_id,),
  102. )
  103. return cur.fetchone()
  104. finally:
  105. conn.close()
  106. def _write_audience_state(
  107. account_id: int,
  108. audience_id: Optional[int],
  109. audience_name: str,
  110. source_account_id: Optional[int],
  111. status: str,
  112. verified: bool = False,
  113. ) -> None:
  114. """将人群包解析/授权状态回写到 ad_creation_account_config 和 account_whitelist 表。"""
  115. from db.connection import get_connection
  116. verified_sql = "audience_verified_at=NOW()," if verified else ""
  117. conn = get_connection()
  118. try:
  119. with conn.cursor() as cur:
  120. cur.execute(
  121. f"""
  122. UPDATE ad_creation_account_config
  123. SET audience_pack_id=%s,
  124. audience_tier_label=%s,
  125. audience_source_account_id=%s,
  126. audience_grant_status=%s,
  127. {verified_sql}
  128. updated_by='auto-audience-grant'
  129. WHERE account_id=%s
  130. """,
  131. (audience_id, audience_name, source_account_id, status, account_id),
  132. )
  133. cur.execute(
  134. """
  135. UPDATE account_whitelist
  136. SET audience_pack_id=%s,
  137. audience_tier_label=%s,
  138. updated_by='auto-audience-grant'
  139. WHERE account_id=%s
  140. """,
  141. (audience_id, audience_name, account_id),
  142. )
  143. conn.commit()
  144. finally:
  145. conn.close()
  146. def _grant_audience_to_account(
  147. source_account_id: int,
  148. target_account_id: int,
  149. audience_id: int,
  150. grant_business_id: int,
  151. ) -> None:
  152. """调用 audience_grant_relations/add 将源账户人群包授权给目标账户,失败抛错。"""
  153. body = {
  154. "account_id": source_account_id,
  155. "audience_id_list": [int(audience_id)],
  156. "grant_type": "GRANT_TYPE_BUSINESS",
  157. "grant_spec": {
  158. "grant_to_business_spec": {
  159. "grant_business_id": int(grant_business_id),
  160. "grant_scope_type": "GRANT_SCOPE_TYPE_ACCOUNT",
  161. "grant_account_permission": [
  162. {
  163. "account_id": int(target_account_id),
  164. "grant_permission_type_list": [TARGET_PERMISSION],
  165. }
  166. ],
  167. }
  168. },
  169. }
  170. resp = _post("/audience_grant_relations/add", body)
  171. if resp.get("code") not in (0, "0"):
  172. raise RuntimeError(
  173. f"audience_grant_relations/add 失败 code={resp.get('code')} "
  174. f"msg={resp.get('message_cn') or resp.get('message')}"
  175. )
  176. def ensure_account_audience_grant(
  177. account_id: int,
  178. poll_attempts: int = 12,
  179. poll_interval_seconds: int = 5,
  180. ) -> dict:
  181. """确保 account_id 的待投放人群包已在目标账户可见。
  182. Returns:
  183. {"status": str, "audience_id": Optional[int], "audience_name": str}
  184. """
  185. cfg = _load_account_config(account_id)
  186. if not cfg:
  187. raise RuntimeError(f"account_id={account_id} 未配置 ad_creation_account_config")
  188. audience_name = (cfg.get("audience_name") or "").strip()
  189. if not audience_name:
  190. raise RuntimeError(f"account_id={account_id} audience_name 为空")
  191. if audience_name == "泛人群":
  192. _write_audience_state(
  193. account_id=account_id,
  194. audience_id=None,
  195. audience_name=audience_name,
  196. source_account_id=None,
  197. status="not_required_no_audience_pack",
  198. verified=True,
  199. )
  200. logger.info("[audience_grant] account=%d 泛人群,无需授权", account_id)
  201. return {"status": "not_required_no_audience_pack", "audience_id": None, "audience_name": audience_name}
  202. source_account_id = int(
  203. cfg.get("audience_source_account_id") or DEFAULT_AUDIENCE_SOURCE_ACCOUNT_ID
  204. )
  205. audience_id = cfg.get("audience_pack_id")
  206. if audience_id is None:
  207. chosen = _pick_source_audience(source_account_id, audience_name)
  208. audience_id = int(chosen["audience_id"])
  209. _write_audience_state(
  210. account_id=account_id,
  211. audience_id=audience_id,
  212. audience_name=audience_name,
  213. source_account_id=source_account_id,
  214. status="resolved",
  215. )
  216. audience_id = int(audience_id)
  217. if _is_audience_visible(account_id, audience_id):
  218. _write_audience_state(
  219. account_id=account_id,
  220. audience_id=audience_id,
  221. audience_name=audience_name,
  222. source_account_id=source_account_id,
  223. status="verified",
  224. verified=True,
  225. )
  226. logger.info(
  227. "[audience_grant] account=%d audience_id=%d 已可见",
  228. account_id, audience_id,
  229. )
  230. return {"status": "verified", "audience_id": audience_id, "audience_name": audience_name}
  231. if not AUDIENCE_GRANT_BUSINESS_ID:
  232. raise RuntimeError(
  233. "缺少 TENCENT_AUDIENCE_GRANT_BUSINESS_ID,无法自动授权人群包"
  234. )
  235. logger.info(
  236. "[audience_grant] 授权 source=%d audience_id=%d → target=%d bm=%d",
  237. source_account_id,
  238. audience_id,
  239. account_id,
  240. AUDIENCE_GRANT_BUSINESS_ID,
  241. )
  242. _grant_audience_to_account(
  243. source_account_id=source_account_id,
  244. target_account_id=account_id,
  245. audience_id=audience_id,
  246. grant_business_id=AUDIENCE_GRANT_BUSINESS_ID,
  247. )
  248. _write_audience_state(
  249. account_id=account_id,
  250. audience_id=audience_id,
  251. audience_name=audience_name,
  252. source_account_id=source_account_id,
  253. status="grant_submitted",
  254. )
  255. for i in range(poll_attempts):
  256. if i:
  257. time.sleep(poll_interval_seconds)
  258. if _is_audience_visible(account_id, audience_id):
  259. _write_audience_state(
  260. account_id=account_id,
  261. audience_id=audience_id,
  262. audience_name=audience_name,
  263. source_account_id=source_account_id,
  264. status="verified",
  265. verified=True,
  266. )
  267. logger.info(
  268. "[audience_grant] account=%d audience_id=%d 授权后验证通过 attempt=%d",
  269. account_id,
  270. audience_id,
  271. i + 1,
  272. )
  273. return {"status": "verified", "audience_id": audience_id, "audience_name": audience_name}
  274. raise RuntimeError(
  275. f"人群包授权后目标账户仍不可见: account={account_id} audience_id={audience_id}"
  276. )