audience_grant.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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. names = [audience_name]
  22. for alias in AUDIENCE_NAME_ALIASES.get(audience_name, []):
  23. if alias and alias not in names:
  24. names.append(alias)
  25. return names
  26. def _fetch_custom_audiences(account_id: int) -> list[dict]:
  27. out: list[dict] = []
  28. page = 1
  29. while True:
  30. resp = _get(
  31. "/custom_audiences/get",
  32. {"account_id": account_id, "page": page, "page_size": 100},
  33. )
  34. data = _check(resp, "custom_audiences/get")
  35. out.extend(data.get("list") or [])
  36. page_info = data.get("page_info") or {}
  37. total_page = int(page_info.get("total_page") or page)
  38. if page >= total_page:
  39. break
  40. page += 1
  41. return out
  42. def _is_audience_visible(account_id: int, audience_id: int) -> bool:
  43. return any(
  44. int(item.get("audience_id") or 0) == int(audience_id)
  45. for item in _fetch_custom_audiences(account_id)
  46. )
  47. def _pick_source_audience(source_account_id: int, audience_name: str) -> dict:
  48. items = _fetch_custom_audiences(source_account_id)
  49. usable = [
  50. item for item in items
  51. if item.get("status") == "SUCCESS"
  52. and item.get("online_status") == "ONLINE"
  53. ]
  54. lookup_names = _audience_lookup_names(audience_name)
  55. def score_one(item_name: str, lookup_name: str) -> int:
  56. if item_name == lookup_name:
  57. return 3
  58. if item_name.startswith(f"{lookup_name}_"):
  59. return 2
  60. if lookup_name in item_name:
  61. return 1
  62. return 0
  63. def score(item: dict) -> tuple[int, str]:
  64. name = item.get("name") or ""
  65. rank = max(score_one(name, lookup_name) for lookup_name in lookup_names)
  66. return rank, str(item.get("created_time") or "")
  67. matches = [item for item in usable if score(item)[0] > 0]
  68. if not matches:
  69. raise RuntimeError(
  70. f"source_account={source_account_id} 未找到 ONLINE/SUCCESS 人群包:"
  71. f" audience_name={audience_name!r}, lookup_names={lookup_names!r}"
  72. )
  73. matches.sort(key=score, reverse=True)
  74. chosen = matches[0]
  75. logger.info(
  76. "[audience_grant] source=%d audience_name=%r lookup_names=%r → audience_id=%s full_name=%r",
  77. source_account_id,
  78. audience_name,
  79. lookup_names,
  80. chosen.get("audience_id"),
  81. chosen.get("name"),
  82. )
  83. return chosen
  84. def _load_account_config(account_id: int) -> Optional[dict]:
  85. from db.connection import get_connection
  86. conn = get_connection()
  87. try:
  88. with conn.cursor() as cur:
  89. cur.execute(
  90. """
  91. SELECT account_id, audience_name, audience_pack_id,
  92. audience_source_account_id, audience_grant_status
  93. FROM ad_creation_account_config
  94. WHERE account_id=%s AND enabled=TRUE
  95. """,
  96. (account_id,),
  97. )
  98. return cur.fetchone()
  99. finally:
  100. conn.close()
  101. def _write_audience_state(
  102. account_id: int,
  103. audience_id: Optional[int],
  104. audience_name: str,
  105. source_account_id: Optional[int],
  106. status: str,
  107. verified: bool = False,
  108. ) -> None:
  109. from db.connection import get_connection
  110. verified_sql = "audience_verified_at=NOW()," if verified else ""
  111. conn = get_connection()
  112. try:
  113. with conn.cursor() as cur:
  114. cur.execute(
  115. f"""
  116. UPDATE ad_creation_account_config
  117. SET audience_pack_id=%s,
  118. audience_tier_label=%s,
  119. audience_source_account_id=%s,
  120. audience_grant_status=%s,
  121. {verified_sql}
  122. updated_by='auto-audience-grant'
  123. WHERE account_id=%s
  124. """,
  125. (audience_id, audience_name, source_account_id, status, account_id),
  126. )
  127. cur.execute(
  128. """
  129. UPDATE account_whitelist
  130. SET audience_pack_id=%s,
  131. audience_tier_label=%s,
  132. updated_by='auto-audience-grant'
  133. WHERE account_id=%s
  134. """,
  135. (audience_id, audience_name, account_id),
  136. )
  137. conn.commit()
  138. finally:
  139. conn.close()
  140. def _grant_audience_to_account(
  141. source_account_id: int,
  142. target_account_id: int,
  143. audience_id: int,
  144. grant_business_id: int,
  145. ) -> None:
  146. body = {
  147. "account_id": source_account_id,
  148. "audience_id_list": [int(audience_id)],
  149. "grant_type": "GRANT_TYPE_BUSINESS",
  150. "grant_spec": {
  151. "grant_to_business_spec": {
  152. "grant_business_id": int(grant_business_id),
  153. "grant_scope_type": "GRANT_SCOPE_TYPE_ACCOUNT",
  154. "grant_account_permission": [
  155. {
  156. "account_id": int(target_account_id),
  157. "grant_permission_type_list": [TARGET_PERMISSION],
  158. }
  159. ],
  160. }
  161. },
  162. }
  163. resp = _post("/audience_grant_relations/add", body)
  164. if resp.get("code") not in (0, "0"):
  165. raise RuntimeError(
  166. f"audience_grant_relations/add 失败 code={resp.get('code')} "
  167. f"msg={resp.get('message_cn') or resp.get('message')}"
  168. )
  169. def ensure_account_audience_grant(
  170. account_id: int,
  171. poll_attempts: int = 12,
  172. poll_interval_seconds: int = 5,
  173. ) -> dict:
  174. """确保 account_id 的待投放人群包已在目标账户可见。
  175. Returns:
  176. {"status": str, "audience_id": Optional[int], "audience_name": str}
  177. """
  178. cfg = _load_account_config(account_id)
  179. if not cfg:
  180. raise RuntimeError(f"account_id={account_id} 未配置 ad_creation_account_config")
  181. audience_name = (cfg.get("audience_name") or "").strip()
  182. if not audience_name:
  183. raise RuntimeError(f"account_id={account_id} audience_name 为空")
  184. if audience_name == "泛人群":
  185. _write_audience_state(
  186. account_id=account_id,
  187. audience_id=None,
  188. audience_name=audience_name,
  189. source_account_id=None,
  190. status="not_required_no_audience_pack",
  191. verified=True,
  192. )
  193. logger.info("[audience_grant] account=%d 泛人群,无需授权", account_id)
  194. return {"status": "not_required_no_audience_pack", "audience_id": None, "audience_name": audience_name}
  195. source_account_id = int(
  196. cfg.get("audience_source_account_id") or DEFAULT_AUDIENCE_SOURCE_ACCOUNT_ID
  197. )
  198. audience_id = cfg.get("audience_pack_id")
  199. if audience_id is None:
  200. chosen = _pick_source_audience(source_account_id, audience_name)
  201. audience_id = int(chosen["audience_id"])
  202. _write_audience_state(
  203. account_id=account_id,
  204. audience_id=audience_id,
  205. audience_name=audience_name,
  206. source_account_id=source_account_id,
  207. status="resolved",
  208. )
  209. audience_id = int(audience_id)
  210. if _is_audience_visible(account_id, audience_id):
  211. _write_audience_state(
  212. account_id=account_id,
  213. audience_id=audience_id,
  214. audience_name=audience_name,
  215. source_account_id=source_account_id,
  216. status="verified",
  217. verified=True,
  218. )
  219. logger.info(
  220. "[audience_grant] account=%d audience_id=%d 已可见",
  221. account_id, audience_id,
  222. )
  223. return {"status": "verified", "audience_id": audience_id, "audience_name": audience_name}
  224. if not AUDIENCE_GRANT_BUSINESS_ID:
  225. raise RuntimeError(
  226. "缺少 TENCENT_AUDIENCE_GRANT_BUSINESS_ID,无法自动授权人群包"
  227. )
  228. logger.info(
  229. "[audience_grant] 授权 source=%d audience_id=%d → target=%d bm=%d",
  230. source_account_id,
  231. audience_id,
  232. account_id,
  233. AUDIENCE_GRANT_BUSINESS_ID,
  234. )
  235. _grant_audience_to_account(
  236. source_account_id=source_account_id,
  237. target_account_id=account_id,
  238. audience_id=audience_id,
  239. grant_business_id=AUDIENCE_GRANT_BUSINESS_ID,
  240. )
  241. _write_audience_state(
  242. account_id=account_id,
  243. audience_id=audience_id,
  244. audience_name=audience_name,
  245. source_account_id=source_account_id,
  246. status="grant_submitted",
  247. )
  248. for i in range(poll_attempts):
  249. if i:
  250. time.sleep(poll_interval_seconds)
  251. if _is_audience_visible(account_id, audience_id):
  252. _write_audience_state(
  253. account_id=account_id,
  254. audience_id=audience_id,
  255. audience_name=audience_name,
  256. source_account_id=source_account_id,
  257. status="verified",
  258. verified=True,
  259. )
  260. logger.info(
  261. "[audience_grant] account=%d audience_id=%d 授权后验证通过 attempt=%d",
  262. account_id,
  263. audience_id,
  264. i + 1,
  265. )
  266. return {"status": "verified", "audience_id": audience_id, "audience_name": audience_name}
  267. raise RuntimeError(
  268. f"人群包授权后目标账户仍不可见: account={account_id} audience_id={audience_id}"
  269. )