creative_material_usage.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """Creative material usage reservation for duplicate control.
  2. Usage is written when a pending creative is prepared, before Tencent POST.
  3. Review/task tables remain responsible for submitted Tencent creatives.
  4. """
  5. from __future__ import annotations
  6. import json
  7. import logging
  8. import os
  9. from typing import Iterable
  10. logger = logging.getLogger(__name__)
  11. CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS = int(
  12. os.getenv("CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS", "7")
  13. )
  14. CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS = int(
  15. os.getenv("CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS", "7")
  16. )
  17. CREATE_USAGE_TABLE_SQL = """
  18. CREATE TABLE IF NOT EXISTS creative_material_usage (
  19. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  20. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  21. adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
  22. crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
  23. landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,用于同人群包近期排重',
  24. material_id VARCHAR(100) NOT NULL COMMENT '内部素材ID',
  25. material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
  26. dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
  27. status VARCHAR(50) NOT NULL DEFAULT 'prepared' COMMENT 'prepared/submitted/failed',
  28. source VARCHAR(50) DEFAULT NULL COMMENT 'primary/hot',
  29. raw_record MEDIUMTEXT DEFAULT NULL COMMENT '准备记录JSON',
  30. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  31. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  32. KEY idx_crowd_material_created (crowd_package, material_id, created_at),
  33. KEY idx_crowd_landing_created (crowd_package, landing_video_id, created_at),
  34. KEY idx_crowd_account_ad_material (crowd_package, account_id, adgroup_id, material_id),
  35. KEY idx_account_created (account_id, created_at),
  36. KEY idx_status_created (status, created_at)
  37. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意素材使用历史/占位'
  38. """
  39. def ensure_usage_table() -> None:
  40. from db.connection import get_connection
  41. conn = get_connection()
  42. try:
  43. with conn.cursor() as cur:
  44. cur.execute(CREATE_USAGE_TABLE_SQL)
  45. cur.execute("SHOW INDEX FROM creative_material_usage WHERE Key_name = 'idx_crowd_landing_created'")
  46. if not cur.fetchall():
  47. cur.execute(
  48. """
  49. ALTER TABLE creative_material_usage
  50. ADD INDEX idx_crowd_landing_created (crowd_package, landing_video_id, created_at)
  51. """
  52. )
  53. conn.commit()
  54. finally:
  55. conn.close()
  56. def load_recent_used_material_ids(
  57. crowd_package: str,
  58. lookback_days: int = CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS,
  59. ) -> set[str]:
  60. """Load recent material reservations for the same crowd package."""
  61. if not crowd_package:
  62. return set()
  63. ensure_usage_table()
  64. from db.connection import get_connection
  65. conn = get_connection()
  66. try:
  67. with conn.cursor() as cur:
  68. cur.execute(
  69. """
  70. SELECT DISTINCT material_id
  71. FROM (
  72. SELECT material_id
  73. FROM creative_material_usage
  74. WHERE crowd_package=%s
  75. AND material_id IS NOT NULL
  76. AND material_id <> ''
  77. AND status IN ('posted_ok', 'rejected')
  78. AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
  79. UNION
  80. SELECT material_id
  81. FROM creative_creation_task
  82. WHERE material_id IS NOT NULL
  83. AND material_id <> ''
  84. AND review_status IN ('approved', 'rejected')
  85. AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
  86. AND JSON_UNQUOTE(JSON_EXTRACT(raw_record, '$.audience_tier'))=%s
  87. ) t
  88. """,
  89. (crowd_package, int(lookback_days), int(lookback_days), crowd_package),
  90. )
  91. rows = cur.fetchall() or []
  92. finally:
  93. conn.close()
  94. return {str(row["material_id"]) for row in rows if row.get("material_id")}
  95. def load_recent_used_landing_video_ids(
  96. crowd_package: str,
  97. lookback_days: int = CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS,
  98. ) -> set[int]:
  99. """Load recent landing-video reservations for the same crowd package."""
  100. if not crowd_package:
  101. return set()
  102. ensure_usage_table()
  103. from db.connection import get_connection
  104. conn = get_connection()
  105. try:
  106. with conn.cursor() as cur:
  107. cur.execute(
  108. """
  109. SELECT DISTINCT landing_video_id
  110. FROM (
  111. SELECT landing_video_id
  112. FROM creative_material_usage
  113. WHERE crowd_package=%s
  114. AND landing_video_id IS NOT NULL
  115. AND landing_video_id > 0
  116. AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
  117. UNION
  118. SELECT landing_video_id
  119. FROM creative_creation_task
  120. WHERE landing_video_id IS NOT NULL
  121. AND landing_video_id > 0
  122. AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
  123. AND JSON_UNQUOTE(JSON_EXTRACT(raw_record, '$.audience_tier'))=%s
  124. ) t
  125. """,
  126. (crowd_package, int(lookback_days), int(lookback_days), crowd_package),
  127. )
  128. rows = cur.fetchall() or []
  129. finally:
  130. conn.close()
  131. out: set[int] = set()
  132. for row in rows:
  133. try:
  134. out.add(int(row["landing_video_id"]))
  135. except (TypeError, ValueError):
  136. continue
  137. return out
  138. def record_prepared_material_usage(record: dict, status: str = "prepared") -> None:
  139. """Reserve a material once Phase 1 has produced a pending creative."""
  140. material_id = str(record.get("_material_id") or "").strip()
  141. crowd_package = str(record.get("audience_tier") or "").strip()
  142. if not material_id or not crowd_package:
  143. return
  144. ensure_usage_table()
  145. from db.connection import get_connection
  146. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  147. conn = get_connection()
  148. try:
  149. with conn.cursor() as cur:
  150. cur.execute(
  151. """
  152. INSERT INTO creative_material_usage
  153. (account_id, adgroup_id, crowd_package, landing_video_id,
  154. material_id, material_image_id, dynamic_creative_id,
  155. status, source, raw_record)
  156. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  157. """,
  158. (
  159. int(record["account_id"]),
  160. int(record.get("adgroup_id") or 0) or None,
  161. crowd_package,
  162. record.get("landing_video_id"),
  163. material_id,
  164. str(record.get("_material_image_id") or ""),
  165. record.get("dynamic_creative_id"),
  166. status,
  167. record.get("landing_source"),
  168. raw_record,
  169. ),
  170. )
  171. conn.commit()
  172. finally:
  173. conn.close()
  174. def update_material_usage_status(
  175. record: dict,
  176. status: str,
  177. dynamic_creative_id: int | str | None = None,
  178. error: str = "",
  179. ) -> None:
  180. """Update the latest usage reservation for this crowd_package + material_id."""
  181. material_id = str(record.get("_material_id") or "").strip()
  182. crowd_package = str(record.get("audience_tier") or "").strip()
  183. if not material_id or not crowd_package:
  184. return
  185. ensure_usage_table()
  186. from db.connection import get_connection
  187. normalized_status = {
  188. "reject": "rejected",
  189. "approve": "approved",
  190. "hold": "no_result",
  191. "skip": "no_result",
  192. }.get(status, status)
  193. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  194. conn = get_connection()
  195. try:
  196. with conn.cursor() as cur:
  197. cur.execute(
  198. """
  199. UPDATE creative_material_usage
  200. SET status=%s,
  201. dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
  202. raw_record=%s,
  203. updated_at=CURRENT_TIMESTAMP
  204. WHERE crowd_package=%s
  205. AND material_id=%s
  206. AND account_id=%s
  207. AND adgroup_id <=> %s
  208. ORDER BY id DESC
  209. LIMIT 1
  210. """,
  211. (
  212. normalized_status,
  213. int(dynamic_creative_id) if dynamic_creative_id else None,
  214. raw_record if not error else json.dumps(
  215. {**record, "usage_error": error[:2000]},
  216. ensure_ascii=False,
  217. default=str,
  218. ),
  219. crowd_package,
  220. material_id,
  221. int(record["account_id"]),
  222. int(record.get("adgroup_id") or 0) or None,
  223. ),
  224. )
  225. conn.commit()
  226. finally:
  227. conn.close()
  228. def merge_used_material_ids(*sets: Iterable[str]) -> set[str]:
  229. out: set[str] = set()
  230. for values in sets:
  231. out.update(str(v) for v in values if str(v))
  232. return out