creative_material_usage.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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. CREATE_USAGE_TABLE_SQL = """
  15. CREATE TABLE IF NOT EXISTS creative_material_usage (
  16. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  17. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  18. adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
  19. crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
  20. landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,仅审计不默认排重',
  21. material_id VARCHAR(100) NOT NULL COMMENT '内部素材ID',
  22. material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
  23. dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
  24. status VARCHAR(50) NOT NULL DEFAULT 'prepared' COMMENT 'prepared/submitted/failed',
  25. source VARCHAR(50) DEFAULT NULL COMMENT 'primary/hot',
  26. raw_record MEDIUMTEXT DEFAULT NULL COMMENT '准备记录JSON',
  27. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  28. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  29. KEY idx_crowd_material_created (crowd_package, material_id, created_at),
  30. KEY idx_crowd_account_ad_material (crowd_package, account_id, adgroup_id, material_id),
  31. KEY idx_account_created (account_id, created_at),
  32. KEY idx_status_created (status, created_at)
  33. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意素材使用历史/占位'
  34. """
  35. def ensure_usage_table() -> None:
  36. from db.connection import get_connection
  37. conn = get_connection()
  38. try:
  39. with conn.cursor() as cur:
  40. cur.execute(CREATE_USAGE_TABLE_SQL)
  41. conn.commit()
  42. finally:
  43. conn.close()
  44. def load_recent_used_material_ids(
  45. crowd_package: str,
  46. lookback_days: int = CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS,
  47. ) -> set[str]:
  48. """Load recent material reservations for the same crowd package."""
  49. if not crowd_package:
  50. return set()
  51. ensure_usage_table()
  52. from db.connection import get_connection
  53. conn = get_connection()
  54. try:
  55. with conn.cursor() as cur:
  56. cur.execute(
  57. """
  58. SELECT DISTINCT material_id
  59. FROM (
  60. SELECT material_id
  61. FROM creative_material_usage
  62. WHERE crowd_package=%s
  63. AND material_id IS NOT NULL
  64. AND material_id <> ''
  65. AND status IN ('posted_ok', 'rejected')
  66. AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
  67. UNION
  68. SELECT material_id
  69. FROM creative_creation_task
  70. WHERE material_id IS NOT NULL
  71. AND material_id <> ''
  72. AND review_status IN ('approved', 'rejected')
  73. AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
  74. AND JSON_UNQUOTE(JSON_EXTRACT(raw_record, '$.audience_tier'))=%s
  75. ) t
  76. """,
  77. (crowd_package, int(lookback_days), int(lookback_days), crowd_package),
  78. )
  79. rows = cur.fetchall() or []
  80. finally:
  81. conn.close()
  82. return {str(row["material_id"]) for row in rows if row.get("material_id")}
  83. def record_prepared_material_usage(record: dict, status: str = "prepared") -> None:
  84. """Reserve a material once Phase 1 has produced a pending creative."""
  85. material_id = str(record.get("_material_id") or "").strip()
  86. crowd_package = str(record.get("audience_tier") or "").strip()
  87. if not material_id or not crowd_package:
  88. return
  89. ensure_usage_table()
  90. from db.connection import get_connection
  91. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  92. conn = get_connection()
  93. try:
  94. with conn.cursor() as cur:
  95. cur.execute(
  96. """
  97. INSERT INTO creative_material_usage
  98. (account_id, adgroup_id, crowd_package, landing_video_id,
  99. material_id, material_image_id, dynamic_creative_id,
  100. status, source, raw_record)
  101. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  102. """,
  103. (
  104. int(record["account_id"]),
  105. int(record.get("adgroup_id") or 0) or None,
  106. crowd_package,
  107. record.get("landing_video_id"),
  108. material_id,
  109. str(record.get("_material_image_id") or ""),
  110. record.get("dynamic_creative_id"),
  111. status,
  112. record.get("landing_source"),
  113. raw_record,
  114. ),
  115. )
  116. conn.commit()
  117. finally:
  118. conn.close()
  119. def update_material_usage_status(
  120. record: dict,
  121. status: str,
  122. dynamic_creative_id: int | str | None = None,
  123. error: str = "",
  124. ) -> None:
  125. """Update the latest usage reservation for this crowd_package + material_id."""
  126. material_id = str(record.get("_material_id") or "").strip()
  127. crowd_package = str(record.get("audience_tier") or "").strip()
  128. if not material_id or not crowd_package:
  129. return
  130. ensure_usage_table()
  131. from db.connection import get_connection
  132. normalized_status = {
  133. "reject": "rejected",
  134. "approve": "approved",
  135. "hold": "no_result",
  136. "skip": "no_result",
  137. }.get(status, status)
  138. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  139. conn = get_connection()
  140. try:
  141. with conn.cursor() as cur:
  142. cur.execute(
  143. """
  144. UPDATE creative_material_usage
  145. SET status=%s,
  146. dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
  147. raw_record=%s,
  148. updated_at=CURRENT_TIMESTAMP
  149. WHERE crowd_package=%s
  150. AND material_id=%s
  151. AND account_id=%s
  152. AND adgroup_id <=> %s
  153. ORDER BY id DESC
  154. LIMIT 1
  155. """,
  156. (
  157. normalized_status,
  158. int(dynamic_creative_id) if dynamic_creative_id else None,
  159. raw_record if not error else json.dumps(
  160. {**record, "usage_error": error[:2000]},
  161. ensure_ascii=False,
  162. default=str,
  163. ),
  164. crowd_package,
  165. material_id,
  166. int(record["account_id"]),
  167. int(record.get("adgroup_id") or 0) or None,
  168. ),
  169. )
  170. conn.commit()
  171. finally:
  172. conn.close()
  173. def merge_used_material_ids(*sets: Iterable[str]) -> set[str]:
  174. out: set[str] = set()
  175. for values in sets:
  176. out.update(str(v) for v in values if str(v))
  177. return out