creative_material_usage.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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. """确保 creative_material_usage 表存在(不存在则创建)。"""
  37. from db.connection import get_connection
  38. conn = get_connection()
  39. try:
  40. with conn.cursor() as cur:
  41. cur.execute(CREATE_USAGE_TABLE_SQL)
  42. conn.commit()
  43. finally:
  44. conn.close()
  45. def load_recent_used_material_ids(
  46. crowd_package: str,
  47. lookback_days: int = CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS,
  48. ) -> set[str]:
  49. """Load recent material reservations for the same crowd package."""
  50. if not crowd_package:
  51. return set()
  52. ensure_usage_table()
  53. from db.connection import get_connection
  54. conn = get_connection()
  55. try:
  56. with conn.cursor() as cur:
  57. cur.execute(
  58. """
  59. SELECT DISTINCT material_id
  60. FROM (
  61. SELECT material_id
  62. FROM creative_material_usage
  63. WHERE crowd_package=%s
  64. AND material_id IS NOT NULL
  65. AND material_id <> ''
  66. AND status IN ('posted_ok', 'rejected')
  67. AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
  68. UNION
  69. SELECT material_id
  70. FROM creative_creation_task
  71. WHERE material_id IS NOT NULL
  72. AND material_id <> ''
  73. AND review_status IN ('approved', 'rejected')
  74. AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
  75. AND JSON_UNQUOTE(JSON_EXTRACT(raw_record, '$.audience_tier'))=%s
  76. ) t
  77. """,
  78. (crowd_package, int(lookback_days), int(lookback_days), crowd_package),
  79. )
  80. rows = cur.fetchall() or []
  81. finally:
  82. conn.close()
  83. return {str(row["material_id"]) for row in rows if row.get("material_id")}
  84. def record_prepared_material_usage(record: dict, status: str = "prepared") -> None:
  85. """Reserve a material once Phase 1 has produced a pending creative."""
  86. material_id = str(record.get("_material_id") or "").strip()
  87. crowd_package = str(record.get("audience_tier") or "").strip()
  88. if not material_id or not crowd_package:
  89. return
  90. ensure_usage_table()
  91. from db.connection import get_connection
  92. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  93. conn = get_connection()
  94. try:
  95. with conn.cursor() as cur:
  96. cur.execute(
  97. """
  98. INSERT INTO creative_material_usage
  99. (account_id, adgroup_id, crowd_package, landing_video_id,
  100. material_id, material_image_id, dynamic_creative_id,
  101. status, source, raw_record)
  102. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  103. """,
  104. (
  105. int(record["account_id"]),
  106. int(record.get("adgroup_id") or 0) or None,
  107. crowd_package,
  108. record.get("landing_video_id"),
  109. material_id,
  110. str(record.get("_material_image_id") or ""),
  111. record.get("dynamic_creative_id"),
  112. status,
  113. record.get("landing_source"),
  114. raw_record,
  115. ),
  116. )
  117. conn.commit()
  118. finally:
  119. conn.close()
  120. def update_material_usage_status(
  121. record: dict,
  122. status: str,
  123. dynamic_creative_id: int | str | None = None,
  124. error: str = "",
  125. ) -> None:
  126. """Update the latest usage reservation for this crowd_package + material_id."""
  127. material_id = str(record.get("_material_id") or "").strip()
  128. crowd_package = str(record.get("audience_tier") or "").strip()
  129. if not material_id or not crowd_package:
  130. return
  131. ensure_usage_table()
  132. from db.connection import get_connection
  133. normalized_status = {
  134. "reject": "rejected",
  135. "approve": "approved",
  136. "hold": "no_result",
  137. "skip": "no_result",
  138. }.get(status, status)
  139. raw_record = json.dumps(record, ensure_ascii=False, default=str)
  140. conn = get_connection()
  141. try:
  142. with conn.cursor() as cur:
  143. cur.execute(
  144. """
  145. UPDATE creative_material_usage
  146. SET status=%s,
  147. dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
  148. raw_record=%s,
  149. updated_at=CURRENT_TIMESTAMP
  150. WHERE crowd_package=%s
  151. AND material_id=%s
  152. AND account_id=%s
  153. AND adgroup_id <=> %s
  154. ORDER BY id DESC
  155. LIMIT 1
  156. """,
  157. (
  158. normalized_status,
  159. int(dynamic_creative_id) if dynamic_creative_id else None,
  160. raw_record if not error else json.dumps(
  161. {**record, "usage_error": error[:2000]},
  162. ensure_ascii=False,
  163. default=str,
  164. ),
  165. crowd_package,
  166. material_id,
  167. int(record["account_id"]),
  168. int(record.get("adgroup_id") or 0) or None,
  169. ),
  170. )
  171. conn.commit()
  172. finally:
  173. conn.close()
  174. def merge_used_material_ids(*sets: Iterable[str]) -> set[str]:
  175. """合并多个素材 ID 集合,统一转字符串并去重(过滤空值)。"""
  176. out: set[str] = set()
  177. for values in sets:
  178. out.update(str(v) for v in values if str(v))
  179. return out