creative_rejection_cleanup.py 86 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146
  1. """Apply creative-review cleanup rules and notify agency groups."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import logging
  6. import os
  7. import threading
  8. from collections import defaultdict
  9. from concurrent.futures import ThreadPoolExecutor, as_completed
  10. from datetime import date, datetime, timedelta
  11. from pathlib import Path
  12. from typing import Any, Callable
  13. from zoneinfo import ZoneInfo
  14. import pandas as pd
  15. from openpyxl import Workbook
  16. from openpyxl.styles import Alignment, Font, PatternFill
  17. from openpyxl.utils import get_column_letter
  18. from db.connection import get_connection
  19. from roi_control.agency_delivery import publish_agency_reports, resolve_agency_webhook
  20. from roi_control.config import AgencyWebhookConfig
  21. from roi_control.feishu import RoiFeishuPublisher
  22. from storage import advisory_lock, initialize_schema
  23. from tools.creative_review import (
  24. fetch_dynamic_creative_review_results,
  25. parse_review_result,
  26. review_granularity_fields,
  27. status_desc,
  28. )
  29. logger = logging.getLogger(__name__)
  30. SHANGHAI = ZoneInfo("Asia/Shanghai")
  31. REPORT_VERSION = "creative_rejection_cleanup_v11"
  32. OPERATOR_SUMMARY_ROUTE = "投放调控汇总"
  33. DENIED_SYSTEM_STATUS = "DYNAMIC_CREATIVE_STATUS_DENIED"
  34. DELETED_STATUS = "AD_STATUS_DELETED"
  35. CREATIVE_DENIED_STATUS = "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  36. CREATIVE_PARTIAL_NORMAL_STATUS = "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  37. DELETE_CREATIVE = "DELETE_CREATIVE"
  38. ALERT_ONLY = "ALERT_ONLY"
  39. DEFAULT_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN = 50.0
  40. DEFAULT_WECHAT_MINI_PROGRAM_COST_THRESHOLD_YUAN = 100.0
  41. DEFAULT_DELETE_CLAIM_STALE_MINUTES = 30
  42. AGENCY_REPORT_COLUMNS = (
  43. "代理名称",
  44. "账户ID",
  45. "账户名称",
  46. "广告ID",
  47. "广告名称",
  48. "创意ID",
  49. "创意名称",
  50. "近3天累计历史消耗(元)",
  51. "配置状态",
  52. "创意审核状态",
  53. "审核不通过原因",
  54. "执行操作",
  55. )
  56. OPERATOR_REPORT_COLUMNS = (
  57. "代理名称",
  58. "账户ID",
  59. "账户名称",
  60. "广告ID",
  61. "广告名称",
  62. "创意ID",
  63. "创意名称",
  64. "近3天累计历史消耗(元)",
  65. "消耗日期范围",
  66. "配置状态",
  67. "创意审核状态",
  68. "元素粒度审核状态",
  69. "元素粒度审核不通过原因",
  70. "版位粒度审核状态",
  71. "版位粒度审核不通过原因",
  72. "审核不通过原因",
  73. "检查时间",
  74. "执行操作",
  75. "操作判断原因",
  76. )
  77. # Compatibility for callers that treat the agency report as the default report.
  78. REPORT_COLUMNS = AGENCY_REPORT_COLUMNS
  79. def _json(value: Any) -> str:
  80. return json.dumps(value, ensure_ascii=False, default=str)
  81. def _is_rejected_status(value: Any) -> bool:
  82. upper = str(value or "").strip().upper()
  83. return "REJECT" in upper or "DENIED" in upper
  84. def has_rejected_wechat_mini_program_element(raw_result: dict | None) -> bool:
  85. """Return whether a rejected element is explicitly named 微信小程序."""
  86. raw = raw_result if isinstance(raw_result, dict) else {}
  87. for element in raw.get("element_result_list") or []:
  88. if not isinstance(element, dict):
  89. continue
  90. element_name = "".join(str(element.get("element_name") or "").split())
  91. if element_name != "微信小程序":
  92. continue
  93. if any(
  94. _is_rejected_status(element.get(field))
  95. for field in ("review_status", "system_status")
  96. ):
  97. return True
  98. return False
  99. def partial_creative_cost_threshold_fen() -> int:
  100. raw = os.getenv(
  101. "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN",
  102. str(DEFAULT_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN),
  103. )
  104. try:
  105. yuan = float(raw)
  106. except (TypeError, ValueError) as exc:
  107. raise ValueError(
  108. "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN must be numeric"
  109. ) from exc
  110. if yuan < 0:
  111. raise ValueError(
  112. "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN must not be negative"
  113. )
  114. return int(round(yuan * 100))
  115. def wechat_mini_program_cost_threshold_fen() -> int:
  116. raw = os.getenv(
  117. "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN",
  118. str(DEFAULT_WECHAT_MINI_PROGRAM_COST_THRESHOLD_YUAN),
  119. )
  120. try:
  121. yuan = float(raw)
  122. except (TypeError, ValueError) as exc:
  123. raise ValueError(
  124. "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN must be numeric"
  125. ) from exc
  126. if yuan < 0:
  127. raise ValueError(
  128. "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN must not be negative"
  129. )
  130. return int(round(yuan * 100))
  131. def determine_cleanup_action(
  132. creative: dict[str, Any],
  133. raw_result: dict | None,
  134. *,
  135. recent_cost_fen: int | None = None,
  136. cost_threshold_fen: int = 5000,
  137. wechat_cost_threshold_fen: int = 10000,
  138. spend_error: str | None = None,
  139. ) -> dict[str, Any] | None:
  140. """Choose one of the three whole-creative delete rules or manual alert."""
  141. approval_status = str(creative.get("creative_set_approval_status") or "")
  142. if approval_status == CREATIVE_DENIED_STATUS:
  143. return {
  144. "cleanup_action": DELETE_CREATIVE,
  145. "component_ids": [],
  146. "element_ids": [],
  147. "action_reason": "创意审核状态为审核拒绝",
  148. "recent_cost_fen": recent_cost_fen,
  149. }
  150. if approval_status == CREATIVE_PARTIAL_NORMAL_STATUS:
  151. if has_rejected_wechat_mini_program_element(raw_result):
  152. if spend_error or recent_cost_fen is None:
  153. reason = (
  154. "部分投放中且微信小程序元素审核拒绝,"
  155. "近3天历史消耗读取失败,需人工判断"
  156. )
  157. if spend_error:
  158. reason = f"{reason}:{spend_error}"
  159. return {
  160. "cleanup_action": ALERT_ONLY,
  161. "component_ids": [],
  162. "element_ids": [],
  163. "action_reason": reason,
  164. "recent_cost_fen": None,
  165. }
  166. if recent_cost_fen >= wechat_cost_threshold_fen:
  167. return {
  168. "cleanup_action": ALERT_ONLY,
  169. "component_ids": [],
  170. "element_ids": [],
  171. "action_reason": (
  172. "部分投放中且微信小程序元素审核拒绝,近3天历史消耗"
  173. f"{recent_cost_fen / 100:.2f}元不低于"
  174. f"{wechat_cost_threshold_fen / 100:.2f}元,需人工判断是否删除"
  175. ),
  176. "recent_cost_fen": recent_cost_fen,
  177. }
  178. return {
  179. "cleanup_action": DELETE_CREATIVE,
  180. "component_ids": [],
  181. "element_ids": [],
  182. "action_reason": (
  183. "部分投放中且微信小程序元素审核拒绝,近3天历史消耗"
  184. f"{recent_cost_fen / 100:.2f}元低于"
  185. f"{wechat_cost_threshold_fen / 100:.2f}元"
  186. ),
  187. "recent_cost_fen": recent_cost_fen,
  188. }
  189. if spend_error or recent_cost_fen is None:
  190. reason = "部分投放中,近3天历史消耗读取失败,需人工判断"
  191. if spend_error:
  192. reason = f"{reason}:{spend_error}"
  193. return {
  194. "cleanup_action": ALERT_ONLY,
  195. "component_ids": [],
  196. "element_ids": [],
  197. "action_reason": reason,
  198. "recent_cost_fen": None,
  199. }
  200. if recent_cost_fen < cost_threshold_fen:
  201. return {
  202. "cleanup_action": DELETE_CREATIVE,
  203. "component_ids": [],
  204. "element_ids": [],
  205. "action_reason": (
  206. f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元"
  207. f"低于{cost_threshold_fen / 100:.2f}元"
  208. ),
  209. "recent_cost_fen": recent_cost_fen,
  210. }
  211. return {
  212. "cleanup_action": ALERT_ONLY,
  213. "component_ids": [],
  214. "element_ids": [],
  215. "action_reason": (
  216. f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元,"
  217. "需人工判断是否删除"
  218. ),
  219. "recent_cost_fen": recent_cost_fen,
  220. }
  221. return None
  222. def _as_int(value: Any) -> int | None:
  223. try:
  224. number = int(value)
  225. except (TypeError, ValueError):
  226. return None
  227. return number if number > 0 else None
  228. def _agency_name(value: Any) -> str:
  229. if value is None or pd.isna(value):
  230. return ""
  231. return "".join(str(value or "").split())
  232. def _env_flag(name: str, default: bool = False) -> bool:
  233. raw = os.getenv(name)
  234. if raw is None:
  235. return default
  236. return raw.strip().lower() in {"1", "true", "yes", "on"}
  237. def resolve_end_date(client, requested=None, *, now=None):
  238. from roi_control.data_source import resolve_end_date as resolve
  239. return resolve(client, requested, now=now)
  240. def date_window(end_date: str) -> tuple[str, str]:
  241. end = datetime.strptime(end_date, "%Y%m%d")
  242. return (end - timedelta(days=2)).strftime("%Y%m%d"), end_date
  243. def fetch_daily_data(client, start_date: str, end_date: str) -> pd.DataFrame:
  244. from roi_control.data_source import fetch_daily_data as fetch
  245. return fetch(client, start_date, end_date)
  246. def fetch_recent_spend_accounts(client, start_date: str, end_date: str) -> list[dict]:
  247. from roi_control.data_source import fetch_recent_spend_accounts as fetch
  248. return fetch(client, start_date, end_date)
  249. def fetch_account_agency_fallbacks(client, account_ids: list[int]) -> dict[int, str]:
  250. from roi_control.data_source import fetch_account_agency_fallbacks as fetch
  251. return fetch(client, account_ids)
  252. def prefetch_account_access_tokens(account_ids: list[int]) -> dict[int, str]:
  253. from tools.ad_api import prefetch_access_tokens
  254. return prefetch_access_tokens(account_ids)
  255. def build_agency_context(daily: pd.DataFrame) -> dict[str, dict]:
  256. """Build latest-date creative mappings and unambiguous account fallbacks."""
  257. creative_agency_sets: dict[tuple[int, int], set[str]] = defaultdict(set)
  258. creative_agency_dates: dict[tuple[int, int], str] = {}
  259. account_agencies: dict[int, set[str]] = defaultdict(set)
  260. account_agency_dates: dict[int, str] = {}
  261. account_names: dict[int, str] = {}
  262. account_name_dates: dict[int, str] = {}
  263. if daily.empty:
  264. return {
  265. "creative_agencies": {},
  266. "account_agencies": {},
  267. "fallback_account_agencies": {},
  268. "account_names": account_names,
  269. }
  270. miniapp = daily[daily["entity_type"].eq("self")]
  271. for _, row in miniapp.iterrows():
  272. account_id = _as_int(row.get("账号id"))
  273. creative_id = _as_int(row.get("创意id"))
  274. agency = _agency_name(row.get("代理名称"))
  275. row_date = str(row.get("dt") or "")
  276. if account_id is None:
  277. continue
  278. account_name = str(row.get("账号名称") or "").strip()
  279. if account_name and row_date >= account_name_dates.get(account_id, ""):
  280. account_names[account_id] = account_name
  281. account_name_dates[account_id] = row_date
  282. if agency:
  283. account_date = account_agency_dates.get(account_id, "")
  284. if row_date > account_date:
  285. account_agencies[account_id] = {agency}
  286. account_agency_dates[account_id] = row_date
  287. elif row_date == account_date:
  288. account_agencies[account_id].add(agency)
  289. if creative_id is not None:
  290. key = (account_id, creative_id)
  291. current_date = creative_agency_dates.get(key, "")
  292. if row_date > current_date:
  293. creative_agency_sets[key] = {agency}
  294. creative_agency_dates[key] = row_date
  295. elif row_date == current_date:
  296. creative_agency_sets[key].add(agency)
  297. return {
  298. "creative_agencies": {
  299. key: next(iter(agencies))
  300. for key, agencies in creative_agency_sets.items()
  301. if len(agencies) == 1
  302. },
  303. "account_agencies": {
  304. account_id: next(iter(agencies))
  305. for account_id, agencies in account_agencies.items()
  306. if len(agencies) == 1
  307. },
  308. "fallback_account_agencies": {},
  309. "account_names": account_names,
  310. }
  311. def _resolve_agency(
  312. context: dict[str, dict],
  313. account_id: int,
  314. creative_id: int,
  315. ) -> str:
  316. return str(
  317. context["creative_agencies"].get((account_id, creative_id))
  318. or context["account_agencies"].get(account_id)
  319. or context.get("fallback_account_agencies", {}).get(account_id)
  320. or ""
  321. )
  322. def upsert_cleanup_candidate(record: dict[str, Any]) -> dict[str, Any]:
  323. component_ids_json = _json(record.get("component_ids") or [])
  324. element_ids_json = _json(record.get("element_ids") or [])
  325. review_result_json = _json(record.get("review_result") or {})
  326. pre_state_json = _json(record.get("pre_state") or {})
  327. cleanup_status = (
  328. "ALERT_PENDING"
  329. if record["cleanup_action"] == ALERT_ONLY
  330. else "DISCOVERED"
  331. )
  332. insert_values = (
  333. record["account_id"],
  334. record.get("account_name"),
  335. record.get("agency_name"),
  336. record["adgroup_id"],
  337. record.get("adgroup_name"),
  338. record["dynamic_creative_id"],
  339. record.get("dynamic_creative_name"),
  340. record["check_date"],
  341. record["cleanup_action"],
  342. component_ids_json,
  343. element_ids_json,
  344. record.get("recent_cost_fen"),
  345. record.get("cost_start_date"),
  346. record.get("cost_end_date"),
  347. record.get("action_reason") or record["reject_reason"],
  348. record["reject_reason"],
  349. review_result_json,
  350. pre_state_json,
  351. cleanup_status,
  352. )
  353. connection = get_connection()
  354. try:
  355. with connection.cursor() as cursor:
  356. cursor.execute(
  357. """
  358. INSERT INTO creative_rejection_cleanup_item
  359. (account_id, account_name, agency_name, adgroup_id,
  360. adgroup_name, dynamic_creative_id, dynamic_creative_name,
  361. check_date,
  362. cleanup_action, target_component_ids_json,
  363. target_element_ids_json, recent_cost_fen,
  364. cost_start_date, cost_end_date, action_reason, reject_reason,
  365. review_result_json, pre_state_json, cleanup_status)
  366. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  367. ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
  368. """,
  369. insert_values,
  370. )
  371. cursor.execute(
  372. """
  373. UPDATE creative_rejection_cleanup_item AS item
  374. JOIN (
  375. SELECT
  376. %s AS account_id, %s AS account_name, %s AS agency_name,
  377. %s AS adgroup_id, %s AS adgroup_name,
  378. %s AS dynamic_creative_id, %s AS dynamic_creative_name,
  379. %s AS check_date, %s AS cleanup_action,
  380. %s AS target_component_ids_json,
  381. %s AS target_element_ids_json, %s AS recent_cost_fen,
  382. %s AS cost_start_date, %s AS cost_end_date,
  383. %s AS action_reason, %s AS reject_reason,
  384. %s AS review_result_json, %s AS pre_state_json,
  385. %s AS cleanup_status
  386. ) AS incoming
  387. ON incoming.account_id=item.account_id
  388. AND incoming.dynamic_creative_id=item.dynamic_creative_id
  389. AND incoming.check_date=item.check_date
  390. SET
  391. item.account_name=COALESCE(
  392. NULLIF(incoming.account_name,''), item.account_name
  393. ),
  394. item.agency_name=COALESCE(
  395. NULLIF(incoming.agency_name,''), item.agency_name
  396. ),
  397. item.adgroup_id=incoming.adgroup_id,
  398. item.adgroup_name=COALESCE(
  399. NULLIF(incoming.adgroup_name,''), item.adgroup_name
  400. ),
  401. item.dynamic_creative_name=COALESCE(
  402. NULLIF(incoming.dynamic_creative_name,''),
  403. item.dynamic_creative_name
  404. ),
  405. item.action_reason=incoming.action_reason,
  406. item.reject_reason=incoming.reject_reason,
  407. item.review_result_json=incoming.review_result_json,
  408. item.pre_state_json=incoming.pre_state_json,
  409. notified_at=CASE
  410. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  411. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  412. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  413. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  414. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  415. THEN NULL ELSE item.notified_at
  416. END,
  417. item.agency_notified_at=CASE
  418. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  419. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  420. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  421. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  422. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  423. THEN NULL ELSE item.agency_notified_at
  424. END,
  425. item.operator_notified_at=CASE
  426. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  427. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  428. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  429. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  430. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  431. THEN NULL ELSE item.operator_notified_at
  432. END,
  433. item.deleted_at=CASE
  434. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  435. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  436. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  437. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  438. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  439. THEN NULL ELSE item.deleted_at
  440. END,
  441. item.readback_json=CASE
  442. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  443. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  444. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  445. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  446. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  447. THEN NULL ELSE item.readback_json
  448. END,
  449. item.cleanup_status=CASE
  450. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  451. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  452. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  453. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  454. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  455. THEN incoming.cleanup_status
  456. WHEN item.cleanup_status='SKIPPED_REVIEW_NOT_RECONFIRMED'
  457. THEN 'DISCOVERED'
  458. ELSE item.cleanup_status
  459. END,
  460. item.target_component_ids_json=incoming.target_component_ids_json,
  461. item.target_element_ids_json=incoming.target_element_ids_json,
  462. item.recent_cost_fen=incoming.recent_cost_fen,
  463. item.cost_start_date=incoming.cost_start_date,
  464. item.cost_end_date=incoming.cost_end_date,
  465. item.cleanup_action=incoming.cleanup_action
  466. WHERE item.cleanup_status NOT IN ('DELETING','CREATIVE_DELETED')
  467. """,
  468. insert_values,
  469. )
  470. cursor.execute(
  471. """
  472. SELECT * FROM creative_rejection_cleanup_item
  473. WHERE account_id=%s AND dynamic_creative_id=%s AND check_date=%s
  474. """,
  475. (
  476. record["account_id"],
  477. record["dynamic_creative_id"],
  478. record["check_date"],
  479. ),
  480. )
  481. return cursor.fetchone()
  482. finally:
  483. connection.close()
  484. def load_retryable_cleanup_items() -> list[dict[str, Any]]:
  485. stale_minutes = int(
  486. os.getenv(
  487. "DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES",
  488. str(DEFAULT_DELETE_CLAIM_STALE_MINUTES),
  489. )
  490. )
  491. if stale_minutes <= 0:
  492. raise ValueError(
  493. "DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES must be positive"
  494. )
  495. connection = get_connection()
  496. try:
  497. with connection.cursor() as cursor:
  498. cursor.execute(
  499. """
  500. SELECT item.*
  501. FROM creative_rejection_cleanup_item item
  502. JOIN (
  503. SELECT account_id, dynamic_creative_id, MAX(check_date) AS check_date
  504. FROM creative_rejection_cleanup_item
  505. GROUP BY account_id, dynamic_creative_id
  506. ) latest
  507. ON latest.account_id=item.account_id
  508. AND latest.dynamic_creative_id=item.dynamic_creative_id
  509. AND latest.check_date=item.check_date
  510. WHERE (
  511. item.cleanup_status IN
  512. ('DISCOVERED','DEFERRED','FAILED','WRITE_OUTCOME_UNKNOWN')
  513. OR (
  514. item.cleanup_status='DELETING'
  515. AND item.updated_at < DATE_SUB(NOW(), INTERVAL %s MINUTE)
  516. )
  517. )
  518. AND item.cleanup_action='DELETE_CREATIVE'
  519. ORDER BY item.id
  520. """,
  521. (stale_minutes,),
  522. )
  523. return list(cursor.fetchall())
  524. finally:
  525. connection.close()
  526. def claim_cleanup_item(item_id: int) -> bool:
  527. """Atomically claim a retryable deletion row for this run."""
  528. stale_minutes = int(
  529. os.getenv(
  530. "DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES",
  531. str(DEFAULT_DELETE_CLAIM_STALE_MINUTES),
  532. )
  533. )
  534. if stale_minutes <= 0:
  535. raise ValueError(
  536. "DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES must be positive"
  537. )
  538. connection = get_connection()
  539. try:
  540. with connection.cursor() as cursor:
  541. cursor.execute(
  542. """
  543. UPDATE creative_rejection_cleanup_item
  544. SET cleanup_status='DELETING', error_message=NULL, updated_at=NOW()
  545. WHERE id=%s
  546. AND cleanup_action='DELETE_CREATIVE'
  547. AND (
  548. cleanup_status IN
  549. ('DISCOVERED','DEFERRED','FAILED','WRITE_OUTCOME_UNKNOWN')
  550. OR (
  551. cleanup_status='DELETING'
  552. AND updated_at < DATE_SUB(NOW(), INTERVAL %s MINUTE)
  553. )
  554. )
  555. """,
  556. (item_id, stale_minutes),
  557. )
  558. return cursor.rowcount == 1
  559. finally:
  560. connection.close()
  561. def update_cleanup_item(item_id: int, **values: Any) -> bool:
  562. expected_cleanup_status = values.pop("_expected_cleanup_status", None)
  563. allowed = {
  564. "agency_name",
  565. "cleanup_action",
  566. "target_component_ids_json",
  567. "target_element_ids_json",
  568. "recent_cost_fen",
  569. "cost_start_date",
  570. "cost_end_date",
  571. "action_reason",
  572. "reject_reason",
  573. "review_result_json",
  574. "cleanup_status",
  575. "error_message",
  576. "pre_state_json",
  577. "readback_json",
  578. "deleted_at",
  579. "agency_notified_at",
  580. "operator_notified_at",
  581. "notified_at",
  582. }
  583. unknown = set(values) - allowed
  584. if unknown:
  585. raise ValueError(f"Unsupported cleanup fields: {sorted(unknown)}")
  586. if not values:
  587. return False
  588. assignments = ", ".join(f"{name}=%s" for name in values)
  589. connection = get_connection()
  590. try:
  591. with connection.cursor() as cursor:
  592. where = "WHERE id=%s"
  593. params = [*values.values(), item_id]
  594. if expected_cleanup_status is not None:
  595. if isinstance(expected_cleanup_status, (tuple, list, set, frozenset)):
  596. statuses = list(expected_cleanup_status)
  597. if not statuses:
  598. return False
  599. placeholders = ",".join(["%s"] * len(statuses))
  600. where += f" AND cleanup_status IN ({placeholders})"
  601. params.extend(statuses)
  602. else:
  603. where += " AND cleanup_status=%s"
  604. params.append(expected_cleanup_status)
  605. cursor.execute(
  606. f"UPDATE creative_rejection_cleanup_item SET {assignments} {where}",
  607. params,
  608. )
  609. return cursor.rowcount > 0
  610. finally:
  611. connection.close()
  612. def load_pending_notification_items(
  613. *,
  614. include_discovered: bool = False,
  615. ) -> list[dict[str, Any]]:
  616. connection = get_connection()
  617. try:
  618. with connection.cursor() as cursor:
  619. statuses = "'CREATIVE_DELETED','ALERT_PENDING'"
  620. if include_discovered:
  621. statuses += ",'DISCOVERED'"
  622. cursor.execute(
  623. f"""
  624. SELECT * FROM creative_rejection_cleanup_item
  625. WHERE cleanup_status IN ({statuses})
  626. AND (
  627. agency_notified_at IS NULL
  628. OR operator_notified_at IS NULL
  629. )
  630. ORDER BY check_date, agency_name, account_id, adgroup_id,
  631. dynamic_creative_id
  632. """
  633. )
  634. return list(cursor.fetchall())
  635. finally:
  636. connection.close()
  637. def load_unnotified_deleted_items(
  638. *,
  639. include_discovered: bool = False,
  640. ) -> list[dict[str, Any]]:
  641. """Compatibility entry point for pending cleanup notifications."""
  642. return load_pending_notification_items(include_discovered=include_discovered)
  643. def _mark_cleanup_channel_notified(
  644. item_ids: list[int],
  645. notified_at: datetime,
  646. *,
  647. channel: str,
  648. ) -> None:
  649. if not item_ids:
  650. return
  651. placeholders = ",".join(["%s"] * len(item_ids))
  652. if channel not in {"agency", "operator"}:
  653. raise ValueError(f"Unsupported cleanup notification channel: {channel}")
  654. channel_column = f"{channel}_notified_at"
  655. other_column = (
  656. "operator_notified_at" if channel == "agency" else "agency_notified_at"
  657. )
  658. connection = get_connection()
  659. try:
  660. with connection.cursor() as cursor:
  661. cursor.execute(
  662. f"""
  663. UPDATE creative_rejection_cleanup_item
  664. SET {channel_column}=%s,
  665. notified_at=CASE
  666. WHEN {other_column} IS NOT NULL THEN %s
  667. ELSE NULL
  668. END
  669. WHERE id IN ({placeholders}) AND {channel_column} IS NULL
  670. """,
  671. [notified_at, notified_at, *item_ids],
  672. )
  673. finally:
  674. connection.close()
  675. def mark_cleanup_items_agency_notified(
  676. item_ids: list[int], notified_at: datetime
  677. ) -> None:
  678. _mark_cleanup_channel_notified(item_ids, notified_at, channel="agency")
  679. def mark_cleanup_items_operator_notified(
  680. item_ids: list[int], notified_at: datetime
  681. ) -> None:
  682. _mark_cleanup_channel_notified(item_ids, notified_at, channel="operator")
  683. def mark_cleanup_items_notified(
  684. item_ids: list[int], notified_at: datetime
  685. ) -> None:
  686. """Compatibility entry point for the agency notification channel."""
  687. mark_cleanup_items_agency_notified(item_ids, notified_at)
  688. def _update_owned_cleanup_item(item_id: int, **values: Any) -> bool:
  689. """Update a row only while this run owns its DELETING claim."""
  690. return update_cleanup_item(
  691. item_id,
  692. _expected_cleanup_status="DELETING",
  693. **values,
  694. )
  695. def upsert_cleanup_delivery(record: dict[str, Any]) -> dict[str, Any]:
  696. connection = get_connection()
  697. try:
  698. with connection.cursor() as cursor:
  699. cursor.execute(
  700. """
  701. INSERT INTO creative_rejection_delivery
  702. (run_id, agency_name, agency_report_version, file_path,
  703. file_sha256, creative_rows, ad_rows,
  704. route_fingerprint, status)
  705. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'PENDING')
  706. ON DUPLICATE KEY UPDATE
  707. status=CASE
  708. WHEN status='SENT' THEN status
  709. WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
  710. THEN 'PENDING'
  711. ELSE status
  712. END,
  713. error_message=CASE
  714. WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
  715. THEN NULL ELSE error_message
  716. END,
  717. file_path=IF(status='SENT', file_path, VALUES(file_path)),
  718. file_sha256=IF(status='SENT', file_sha256, VALUES(file_sha256)),
  719. creative_rows=VALUES(creative_rows),
  720. ad_rows=VALUES(ad_rows),
  721. route_fingerprint=VALUES(route_fingerprint)
  722. """,
  723. (
  724. record["run_id"],
  725. record["agency_name"],
  726. record["agency_report_version"],
  727. record["file_path"],
  728. record["file_sha256"],
  729. record["creative_rows"],
  730. record["ad_rows"],
  731. record.get("route_fingerprint"),
  732. ),
  733. )
  734. cursor.execute(
  735. """
  736. SELECT * FROM creative_rejection_delivery
  737. WHERE run_id=%s AND agency_name=%s AND agency_report_version=%s
  738. """,
  739. (
  740. record["run_id"],
  741. record["agency_name"],
  742. record["agency_report_version"],
  743. ),
  744. )
  745. return cursor.fetchone()
  746. finally:
  747. connection.close()
  748. def update_cleanup_delivery(delivery_id: int, **values: Any) -> None:
  749. allowed = {
  750. "status",
  751. "sheet_token",
  752. "sheet_url",
  753. "response_code",
  754. "error_message",
  755. "sent_at",
  756. }
  757. increment_attempt = bool(values.pop("increment_attempt", False))
  758. unknown = set(values) - allowed
  759. if unknown:
  760. raise ValueError(f"Unsupported delivery fields: {sorted(unknown)}")
  761. assignments = [f"{name}=%s" for name in values]
  762. params = list(values.values())
  763. if increment_attempt:
  764. assignments.append("attempt_count=attempt_count+1")
  765. if not assignments:
  766. return
  767. connection = get_connection()
  768. try:
  769. with connection.cursor() as cursor:
  770. cursor.execute(
  771. f"UPDATE creative_rejection_delivery SET {', '.join(assignments)} WHERE id=%s",
  772. [*params, delivery_id],
  773. )
  774. finally:
  775. connection.close()
  776. def _file_sha256(path: Path) -> str:
  777. digest = hashlib.sha256()
  778. with path.open("rb") as handle:
  779. for chunk in iter(lambda: handle.read(1024 * 1024), b""):
  780. digest.update(chunk)
  781. return digest.hexdigest()
  782. def publish_cleanup_operator_summary(
  783. *,
  784. run_id: str,
  785. report: dict[str, object],
  786. chat_id: str,
  787. publisher: RoiFeishuPublisher,
  788. now: datetime,
  789. ) -> dict[str, object]:
  790. """Idempotently upload and send the all-agency report to the operator group."""
  791. path = Path(str(report["report"]))
  792. delivery_id: int | None = None
  793. try:
  794. if not path.is_file():
  795. raise FileNotFoundError(path)
  796. target_chat_id = str(chat_id or "").strip()
  797. if not target_chat_id:
  798. raise RuntimeError("FEISHU_AD_PROJECT_CHAT_ID 未配置")
  799. delivery = upsert_cleanup_delivery(
  800. {
  801. "run_id": run_id,
  802. "agency_name": OPERATOR_SUMMARY_ROUTE,
  803. "agency_report_version": str(report["report_version"]),
  804. "file_path": str(path),
  805. "file_sha256": _file_sha256(path),
  806. "creative_rows": int(report.get("creative_rows") or 0),
  807. "ad_rows": 0,
  808. "route_fingerprint": hashlib.sha256(
  809. target_chat_id.encode("utf-8")
  810. ).hexdigest(),
  811. }
  812. )
  813. delivery_id = int(delivery["id"])
  814. if delivery.get("status") == "SENT":
  815. return {
  816. "route": OPERATOR_SUMMARY_ROUTE,
  817. "status": "SENT",
  818. "sheet_url": str(delivery.get("sheet_url") or ""),
  819. "reused": True,
  820. }
  821. sheet_url = str(delivery.get("sheet_url") or "")
  822. sheet_token = str(delivery.get("sheet_token") or "")
  823. if not sheet_url or not sheet_token:
  824. imported = publisher.upload_workbook(path)
  825. sheet_url = imported["url"]
  826. sheet_token = imported["sheet_token"]
  827. update_cleanup_delivery(
  828. delivery_id,
  829. status="UPLOADED",
  830. sheet_token=sheet_token,
  831. sheet_url=sheet_url,
  832. )
  833. message_id = publisher.send_report_card(
  834. title=str(report.get("title") or path.stem),
  835. content=(
  836. (
  837. f"本次预演共 **{int(report.get('creative_rows') or 0)}** 条创意,"
  838. "未执行任何删除,包含建议删除项及需人工判断项。"
  839. )
  840. if report.get("dry_run")
  841. else (
  842. f"本批次共 **{int(report.get('creative_rows') or 0)}** 条创意,"
  843. "包含各代理自动删除及需人工判断的完整汇总。"
  844. )
  845. ),
  846. sheet_url=sheet_url,
  847. chat_id=target_chat_id,
  848. button_text="查看全部处理明细",
  849. )
  850. update_cleanup_delivery(
  851. delivery_id,
  852. status="SENT",
  853. response_code=message_id,
  854. sent_at=now,
  855. increment_attempt=True,
  856. )
  857. return {
  858. "route": OPERATOR_SUMMARY_ROUTE,
  859. "status": "SENT",
  860. "sheet_url": sheet_url,
  861. "reused": False,
  862. }
  863. except Exception as exc:
  864. if delivery_id is not None:
  865. try:
  866. update_cleanup_delivery(
  867. delivery_id,
  868. status="FAILED",
  869. error_message=str(exc),
  870. increment_attempt=True,
  871. )
  872. except Exception as audit_exc:
  873. logger.error("operator summary audit failed: %s", audit_exc)
  874. logger.error("operator summary delivery failed: %s", exc)
  875. return {
  876. "route": OPERATOR_SUMMARY_ROUTE,
  877. "status": "FAILED",
  878. "error": str(exc),
  879. }
  880. def _reject_reason(raw_result: dict[str, Any] | None, system_status: str) -> str:
  881. if raw_result:
  882. parsed = parse_review_result(raw_result)
  883. reasons = list(parsed.reject_messages)
  884. reasons.extend(fact.reason for fact in parsed.rejection_facts)
  885. unique = list(dict.fromkeys(reason.strip() for reason in reasons if reason.strip()))
  886. if unique:
  887. return ";".join(unique)
  888. if system_status == DENIED_SYSTEM_STATUS:
  889. return "腾讯正式审核未通过(接口未返回具体原因)"
  890. return "腾讯正式审核未通过"
  891. def _cleanup_reason(
  892. action: dict[str, Any],
  893. raw_result: dict[str, Any] | None,
  894. system_status: str,
  895. ) -> str:
  896. if action["cleanup_action"] == DELETE_CREATIVE:
  897. return "创意审核状态为 CREATIVE_SET_APPROVAL_STATUS_DENIED"
  898. return _reject_reason(raw_result, system_status)
  899. def _json_object(value: Any) -> dict[str, Any]:
  900. if isinstance(value, dict):
  901. return value
  902. if not value:
  903. return {}
  904. try:
  905. parsed = json.loads(str(value))
  906. except (TypeError, ValueError, json.JSONDecodeError):
  907. return {}
  908. return parsed if isinstance(parsed, dict) else {}
  909. def _display_date(value: Any) -> str:
  910. if isinstance(value, (date, datetime)):
  911. return value.strftime("%Y-%m-%d")
  912. return str(value or "")[:10]
  913. def _write_report(
  914. path: Path,
  915. rows: list[dict[str, Any]],
  916. *,
  917. columns: tuple[str, ...],
  918. ) -> None:
  919. workbook = Workbook()
  920. sheet = workbook.active
  921. sheet.title = "审核不通过创意清理"
  922. sheet.append(list(columns))
  923. for row in rows:
  924. raw_result = _json_object(
  925. row.get("review_result") or row.get("review_result_json")
  926. )
  927. pre_state = _json_object(row.get("pre_state") or row.get("pre_state_json"))
  928. granular = review_granularity_fields(raw_result)
  929. action = str(row.get("cleanup_action") or "")
  930. if (
  931. action == DELETE_CREATIVE
  932. and row.get("cleanup_status") == "DISCOVERED"
  933. ):
  934. execution_action = "建议删除创意(未执行)"
  935. elif action == DELETE_CREATIVE:
  936. execution_action = "删除创意"
  937. else:
  938. execution_action = "需人工判断"
  939. checked_at = (
  940. row.get("checked_at")
  941. or row.get("created_at")
  942. or row.get("updated_at")
  943. or row.get("deleted_at")
  944. )
  945. recent_cost_fen = row.get("recent_cost_fen")
  946. recent_cost_yuan = (
  947. ""
  948. if recent_cost_fen is None
  949. else f"{int(recent_cost_fen) / 100:.2f}"
  950. )
  951. cost_start = _display_date(row.get("cost_start_date"))
  952. cost_end = _display_date(row.get("cost_end_date"))
  953. values = {
  954. "代理名称": row.get("agency_name") or "",
  955. "账户ID": str(row["account_id"]),
  956. "账户名称": row.get("account_name") or "",
  957. "广告ID": str(row["adgroup_id"]),
  958. "广告名称": row.get("adgroup_name") or "",
  959. "创意ID": str(row["dynamic_creative_id"]),
  960. "创意名称": row.get("dynamic_creative_name") or "",
  961. "近3天累计历史消耗(元)": recent_cost_yuan,
  962. "消耗日期范围": (
  963. f"{cost_start} ~ {cost_end}" if cost_start and cost_end else ""
  964. ),
  965. "执行操作": execution_action,
  966. "操作判断原因": row.get("action_reason") or "",
  967. "配置状态": status_desc(
  968. pre_state.get("configured_status")
  969. or row.get("configured_status")
  970. ),
  971. "创意审核状态": status_desc(
  972. pre_state.get("creative_set_approval_status")
  973. or row.get("creative_set_approval_status")
  974. ),
  975. "元素粒度审核状态": granular["element_review_status"],
  976. "元素粒度审核不通过原因": granular["element_reject_reason"],
  977. "版位粒度审核状态": granular["site_review_status"],
  978. "版位粒度审核不通过原因": granular["site_reject_reason"],
  979. "审核不通过原因": row["reject_reason"],
  980. "检查时间": (
  981. checked_at.strftime("%Y-%m-%d %H:%M:%S")
  982. if isinstance(checked_at, (date, datetime))
  983. else str(checked_at or "")
  984. ),
  985. }
  986. sheet.append([values[column] for column in columns])
  987. header_fill = PatternFill("solid", fgColor="C65911")
  988. for cell in sheet[1]:
  989. cell.fill = header_fill
  990. cell.font = Font(color="FFFFFF", bold=True)
  991. cell.alignment = Alignment(horizontal="center", vertical="center")
  992. widths = {
  993. "代理名称": 22,
  994. "账户ID": 14,
  995. "账户名称": 22,
  996. "广告ID": 14,
  997. "广告名称": 30,
  998. "创意ID": 16,
  999. "创意名称": 30,
  1000. "近3天累计历史消耗(元)": 22,
  1001. "消耗日期范围": 24,
  1002. "执行操作": 16,
  1003. "操作判断原因": 60,
  1004. "配置状态": 20,
  1005. "创意审核状态": 24,
  1006. "元素粒度审核状态": 40,
  1007. "元素粒度审核不通过原因": 60,
  1008. "版位粒度审核状态": 40,
  1009. "版位粒度审核不通过原因": 60,
  1010. "审核不通过原因": 60,
  1011. "检查时间": 20,
  1012. }
  1013. for index, column in enumerate(columns, start=1):
  1014. sheet.column_dimensions[get_column_letter(index)].width = widths[column]
  1015. for row in sheet.iter_rows(min_row=2):
  1016. for cell in row:
  1017. cell.alignment = Alignment(vertical="top", wrap_text=True)
  1018. for id_column in ("账户ID", "广告ID", "创意ID"):
  1019. row[columns.index(id_column)].number_format = "@"
  1020. sheet.freeze_panes = "A2"
  1021. sheet.auto_filter.ref = sheet.dimensions
  1022. path.parent.mkdir(parents=True, exist_ok=True)
  1023. workbook.save(path)
  1024. def write_cleanup_reports(
  1025. rows: list[dict[str, Any]],
  1026. output_dir: Path,
  1027. report_date: str,
  1028. ) -> tuple[str, list[dict[str, object]], dict[str, list[int]]]:
  1029. grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
  1030. for row in rows:
  1031. grouped[str(row["agency_name"])].append(row)
  1032. def digest_for(report_rows: list[dict[str, Any]], destination: str) -> str:
  1033. digest_input = "|".join(
  1034. ":".join(
  1035. [
  1036. str(row["id"]),
  1037. str(row.get("cleanup_action") or ""),
  1038. str(row.get("cleanup_status") or ""),
  1039. str(row.get("recent_cost_fen")),
  1040. _display_date(row.get("cost_end_date")),
  1041. str(row.get("reject_reason") or ""),
  1042. ]
  1043. )
  1044. for row in sorted(report_rows, key=lambda value: int(value["id"]))
  1045. )
  1046. return hashlib.sha256(
  1047. f"{report_date}|{destination}|{digest_input}".encode("utf-8")
  1048. ).hexdigest()[:12]
  1049. digest = digest_for(rows, "all")
  1050. run_id = f"reject_{report_date}_{digest}"
  1051. reports: list[dict[str, object]] = []
  1052. item_ids: dict[str, list[int]] = {}
  1053. for agency, agency_rows in sorted(grouped.items()):
  1054. agency_digest = digest_for(agency_rows, agency)
  1055. safe_agency = agency.replace("/", "_").replace("\\", "_")
  1056. path = output_dir / (
  1057. f"{report_date}_{safe_agency}_创意审核异常处理_{agency_digest}.xlsx"
  1058. )
  1059. dry_run = any(
  1060. row.get("cleanup_status") == "DISCOVERED"
  1061. for row in agency_rows
  1062. )
  1063. _write_report(path, agency_rows, columns=AGENCY_REPORT_COLUMNS)
  1064. reports.append(
  1065. {
  1066. "agency_name": agency,
  1067. "report_version": REPORT_VERSION,
  1068. "report": str(path),
  1069. "title": (
  1070. f"{report_date}_{agency}_创意审核异常处理预演通知"
  1071. if dry_run
  1072. else f"{report_date}_{agency}_创意审核异常处理通知"
  1073. ),
  1074. "creative_rows": len(agency_rows),
  1075. "ad_rows": 0,
  1076. "notification_type": (
  1077. "creative_rejection_dry_run"
  1078. if dry_run
  1079. else "creative_rejection_cleanup"
  1080. ),
  1081. "run_id": f"reject_{report_date}_{agency_digest}",
  1082. }
  1083. )
  1084. item_ids[agency] = [int(row["id"]) for row in agency_rows]
  1085. return run_id, reports, item_ids
  1086. def write_cleanup_operator_summary(
  1087. rows: list[dict[str, Any]],
  1088. output_dir: Path,
  1089. report_date: str,
  1090. run_id: str,
  1091. ) -> dict[str, object]:
  1092. digest_input = "|".join(
  1093. ":".join(
  1094. [
  1095. str(row["id"]),
  1096. str(row.get("cleanup_action") or ""),
  1097. str(row.get("cleanup_status") or ""),
  1098. str(row.get("recent_cost_fen")),
  1099. _display_date(row.get("cost_end_date")),
  1100. str(row.get("reject_reason") or ""),
  1101. ]
  1102. )
  1103. for row in sorted(rows, key=lambda value: int(value["id"]))
  1104. )
  1105. digest = hashlib.sha256(
  1106. f"{report_date}|{OPERATOR_SUMMARY_ROUTE}|{digest_input}".encode("utf-8")
  1107. ).hexdigest()[:12]
  1108. path = output_dir / (
  1109. f"{report_date}_投放调控_创意审核异常处理汇总_{digest}.xlsx"
  1110. )
  1111. dry_run = any(row.get("cleanup_status") == "DISCOVERED" for row in rows)
  1112. _write_report(path, rows, columns=OPERATOR_REPORT_COLUMNS)
  1113. return {
  1114. "report_version": f"{REPORT_VERSION}_operator_summary",
  1115. "report": str(path),
  1116. "title": (
  1117. f"{report_date}_创意审核异常处理预演汇总通知"
  1118. if dry_run
  1119. else f"{report_date}_创意审核异常处理汇总通知"
  1120. ),
  1121. "creative_rows": len(rows),
  1122. "run_id": f"reject_{report_date}_{digest}",
  1123. "dry_run": dry_run,
  1124. }
  1125. def _chunks(values: list[int], size: int) -> list[list[int]]:
  1126. return [values[offset : offset + size] for offset in range(0, len(values), size)]
  1127. def _scan_one_account(
  1128. account: dict[str, Any],
  1129. *,
  1130. tencent,
  1131. review_fetcher: Callable[[int, list[int]], list[dict]],
  1132. spend_start_date: date,
  1133. spend_end_date: date,
  1134. ) -> tuple[
  1135. list[dict],
  1136. dict[int, dict],
  1137. dict[int, dict],
  1138. dict[int, int],
  1139. str | None,
  1140. int,
  1141. str | None,
  1142. ]:
  1143. account_id = int(account["account_id"])
  1144. try:
  1145. creatives = tencent.get_dynamic_creatives(account_id)
  1146. ads = {
  1147. int(ad["adgroup_id"]): ad
  1148. for ad in tencent.get_ads(account_id)
  1149. if _as_int(ad.get("adgroup_id")) is not None
  1150. }
  1151. ids = [
  1152. int(row["dynamic_creative_id"])
  1153. for row in creatives
  1154. if _as_int(row.get("dynamic_creative_id")) is not None
  1155. and row.get("configured_status") != DELETED_STATUS
  1156. ]
  1157. raw_by_id: dict[int, dict] = {}
  1158. for batch in _chunks(ids, 100):
  1159. for raw in review_fetcher(account_id, batch):
  1160. creative_id = _as_int(raw.get("dynamic_creative_id"))
  1161. if creative_id is not None:
  1162. raw_by_id[creative_id] = raw
  1163. cost_by_id: dict[int, int] = {}
  1164. spend_error = None
  1165. partial_ids = [
  1166. int(row["dynamic_creative_id"])
  1167. for row in creatives
  1168. if _as_int(row.get("dynamic_creative_id")) is not None
  1169. and row.get("configured_status") != DELETED_STATUS
  1170. and row.get("creative_set_approval_status")
  1171. == CREATIVE_PARTIAL_NORMAL_STATUS
  1172. ]
  1173. if partial_ids:
  1174. try:
  1175. cost_by_id = tencent.get_dynamic_creative_costs(
  1176. account_id,
  1177. partial_ids,
  1178. spend_start_date,
  1179. spend_end_date,
  1180. )
  1181. except Exception as exc:
  1182. spend_error = str(exc)
  1183. logger.exception(
  1184. "creative cost scan failed account=%d start=%s end=%s",
  1185. account_id,
  1186. spend_start_date,
  1187. spend_end_date,
  1188. )
  1189. return creatives, ads, raw_by_id, cost_by_id, spend_error, len(ids), None
  1190. except Exception as exc:
  1191. return [], {}, {}, {}, None, 0, f"account={account_id} scan failed: {exc}"
  1192. def cleanup_precondition_failure(
  1193. item: dict[str, Any],
  1194. scanned_accounts: set[int],
  1195. confirmed_actions: dict[tuple[int, int], dict[str, Any]],
  1196. ) -> tuple[str, str] | None:
  1197. """Fail closed unless this run reconfirmed the exact cleanup action."""
  1198. account_id = int(item["account_id"])
  1199. creative_id = int(item["dynamic_creative_id"])
  1200. if account_id not in scanned_accounts:
  1201. return "DEFERRED", "本轮账户审核结果扫描失败,未执行删除"
  1202. confirmed = confirmed_actions.get((account_id, creative_id))
  1203. if not confirmed:
  1204. return (
  1205. "SKIPPED_REVIEW_NOT_RECONFIRMED",
  1206. "本轮未按新规则再次确认清理动作",
  1207. )
  1208. item_action = str(item.get("cleanup_action") or "")
  1209. if item_action != DELETE_CREATIVE:
  1210. return "SKIPPED_REVIEW_NOT_RECONFIRMED", "当前规则不允许删除拒审元素组件"
  1211. if item_action != confirmed["cleanup_action"]:
  1212. return "SKIPPED_REVIEW_NOT_RECONFIRMED", "本轮清理动作与候选记录不一致"
  1213. return None
  1214. def _same_cleanup_action(
  1215. expected_action: str,
  1216. actual: dict[str, Any] | None,
  1217. ) -> bool:
  1218. return bool(
  1219. expected_action == DELETE_CREATIVE
  1220. and actual
  1221. and actual.get("cleanup_action") == DELETE_CREATIVE
  1222. )
  1223. def run_rejected_creative_cleanup(
  1224. *,
  1225. output_dir: Path,
  1226. now: datetime | None = None,
  1227. tencent=None,
  1228. odps=None,
  1229. review_fetcher: Callable[[int, list[int]], list[dict]] | None = None,
  1230. publisher: RoiFeishuPublisher | None = None,
  1231. notifier=None,
  1232. ) -> dict[str, Any]:
  1233. """Scan recent-spend accounts and apply the current creative-review rules."""
  1234. effective_now = now or datetime.now(SHANGHAI)
  1235. if effective_now.tzinfo is None:
  1236. effective_now = effective_now.replace(tzinfo=SHANGHAI)
  1237. apply_enabled = _env_flag("DAILY_REJECTED_CREATIVE_APPLY_ENABLED")
  1238. webhook_config = AgencyWebhookConfig.from_env()
  1239. if apply_enabled and not webhook_config.enabled:
  1240. raise RuntimeError(
  1241. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1 requires "
  1242. "ROI_AGENCY_WEBHOOK_ENABLED=1"
  1243. )
  1244. if apply_enabled and not os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip():
  1245. raise RuntimeError(
  1246. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1 requires "
  1247. "FEISHU_AD_PROJECT_CHAT_ID"
  1248. )
  1249. initialize_schema()
  1250. if odps is None:
  1251. from roi_control.odps_client import ODPSClient
  1252. odps_client = ODPSClient(project=os.getenv("ODPS_PROJECT", "loghubods"))
  1253. else:
  1254. odps_client = odps
  1255. end_date = resolve_end_date(odps_client, now=effective_now)
  1256. start_date, end_date = date_window(end_date)
  1257. spend_end_date = effective_now.date() - timedelta(days=1)
  1258. spend_start_date = spend_end_date - timedelta(days=2)
  1259. cost_threshold_fen = partial_creative_cost_threshold_fen()
  1260. wechat_cost_threshold_fen = wechat_mini_program_cost_threshold_fen()
  1261. daily = fetch_daily_data(odps_client, start_date, end_date)
  1262. context = build_agency_context(daily)
  1263. accounts = fetch_recent_spend_accounts(odps_client, start_date, end_date)
  1264. unresolved_account_ids = [
  1265. int(account["account_id"])
  1266. for account in accounts
  1267. if int(account["account_id"]) not in context["account_agencies"]
  1268. ]
  1269. try:
  1270. context["fallback_account_agencies"] = fetch_account_agency_fallbacks(
  1271. odps_client,
  1272. unresolved_account_ids,
  1273. )
  1274. except Exception:
  1275. logger.exception(
  1276. "account agency fallback query failed accounts=%d",
  1277. len(unresolved_account_ids),
  1278. )
  1279. owned_tencent = tencent is None
  1280. if tencent is None:
  1281. from tencent_client import TencentClient
  1282. client = TencentClient()
  1283. else:
  1284. client = tencent
  1285. account_ids = [int(account["account_id"]) for account in accounts]
  1286. prefetched_tokens = prefetch_account_access_tokens(account_ids)
  1287. seed_tokens = getattr(client, "seed_access_tokens", None)
  1288. if callable(seed_tokens):
  1289. seed_tokens(prefetched_tokens)
  1290. fetch_reviews = review_fetcher or fetch_dynamic_creative_review_results
  1291. discovered = 0
  1292. scanned = 0
  1293. scanned_accounts: set[int] = set()
  1294. confirmed_actions: dict[tuple[int, int], dict[str, Any]] = {}
  1295. scan_errors: list[str] = []
  1296. try:
  1297. scan_workers = int(os.getenv("TENCENT_AD_ACCOUNT_SCAN_WORKERS", "8"))
  1298. if scan_workers < 1:
  1299. raise ValueError("TENCENT_AD_ACCOUNT_SCAN_WORKERS must be at least 1")
  1300. workers = min(scan_workers, len(accounts), 32) if accounts else 1
  1301. def run_scan(account: dict[str, Any]):
  1302. if owned_tencent:
  1303. from tencent_client import TencentClient
  1304. scan_client = TencentClient()
  1305. scan_client.seed_access_tokens(prefetched_tokens)
  1306. else:
  1307. scan_client = client
  1308. try:
  1309. return _scan_one_account(
  1310. account,
  1311. tencent=scan_client,
  1312. review_fetcher=fetch_reviews,
  1313. spend_start_date=spend_start_date,
  1314. spend_end_date=spend_end_date,
  1315. )
  1316. finally:
  1317. if scan_client is not client:
  1318. scan_client.session.close()
  1319. logger.info("account scan started accounts=%d workers=%d", len(accounts), workers)
  1320. scan_results = []
  1321. with ThreadPoolExecutor(
  1322. max_workers=workers,
  1323. thread_name_prefix="creative-scan",
  1324. ) as executor:
  1325. futures = {executor.submit(run_scan, account): account for account in accounts}
  1326. for completed, future in enumerate(as_completed(futures), start=1):
  1327. account = futures[future]
  1328. account_id = int(account["account_id"])
  1329. try:
  1330. (
  1331. creatives,
  1332. ads,
  1333. raw_by_id,
  1334. cost_by_id,
  1335. spend_error,
  1336. account_scanned,
  1337. error,
  1338. ) = future.result()
  1339. except Exception as exc:
  1340. creatives, ads, raw_by_id, cost_by_id = [], {}, {}, {}
  1341. spend_error, account_scanned = None, 0
  1342. error = f"account={account_id} scan failed: {exc}"
  1343. scanned += account_scanned
  1344. if error:
  1345. logger.error(error)
  1346. scan_errors.append(error)
  1347. else:
  1348. scanned_accounts.add(account_id)
  1349. scan_results.append(
  1350. (
  1351. account,
  1352. creatives,
  1353. ads,
  1354. raw_by_id,
  1355. cost_by_id,
  1356. spend_error,
  1357. )
  1358. )
  1359. logger.info(
  1360. "account scan progress=%d/%d account=%d creatives=%d error=%s",
  1361. completed,
  1362. len(accounts),
  1363. account_id,
  1364. account_scanned,
  1365. bool(error),
  1366. )
  1367. def process_creative(task):
  1368. (
  1369. account,
  1370. account_id,
  1371. creative,
  1372. ads,
  1373. raw_by_id,
  1374. cost_by_id,
  1375. spend_error,
  1376. ) = task
  1377. creative_id = _as_int(creative.get("dynamic_creative_id"))
  1378. adgroup_id = _as_int(creative.get("adgroup_id"))
  1379. if creative_id is None or adgroup_id is None:
  1380. return None
  1381. raw_result = raw_by_id.get(creative_id)
  1382. system_status = str(creative.get("system_status") or "")
  1383. is_partial = (
  1384. creative.get("creative_set_approval_status")
  1385. == CREATIVE_PARTIAL_NORMAL_STATUS
  1386. )
  1387. action = determine_cleanup_action(
  1388. creative,
  1389. raw_result,
  1390. recent_cost_fen=cost_by_id.get(creative_id),
  1391. cost_threshold_fen=cost_threshold_fen,
  1392. wechat_cost_threshold_fen=wechat_cost_threshold_fen,
  1393. spend_error=(spend_error if is_partial else None),
  1394. )
  1395. if action is None:
  1396. return None
  1397. ad = ads.get(adgroup_id) or {}
  1398. upsert_cleanup_candidate(
  1399. {
  1400. "account_id": account_id,
  1401. "account_name": context["account_names"].get(account_id)
  1402. or account.get("account_name")
  1403. or "",
  1404. "agency_name": _resolve_agency(
  1405. context, account_id, creative_id
  1406. ),
  1407. "adgroup_id": adgroup_id,
  1408. "adgroup_name": ad.get("adgroup_name") or "",
  1409. "dynamic_creative_id": creative_id,
  1410. "dynamic_creative_name": creative.get(
  1411. "dynamic_creative_name"
  1412. )
  1413. or "",
  1414. "check_date": effective_now.date(),
  1415. **action,
  1416. "action_reason": action.get("action_reason")
  1417. or _cleanup_reason(action, raw_result, system_status),
  1418. "reject_reason": _reject_reason(raw_result, system_status),
  1419. "cost_start_date": spend_start_date,
  1420. "cost_end_date": spend_end_date,
  1421. "review_result": raw_result or {},
  1422. "pre_state": creative,
  1423. }
  1424. )
  1425. return account_id, creative_id, action
  1426. tasks = [
  1427. (
  1428. account,
  1429. int(account["account_id"]),
  1430. creative,
  1431. ads,
  1432. raw_by_id,
  1433. cost_by_id,
  1434. spend_error,
  1435. )
  1436. for account, creatives, ads, raw_by_id, cost_by_id, spend_error
  1437. in scan_results
  1438. for creative in creatives
  1439. ]
  1440. process_workers = (
  1441. min(
  1442. int(os.getenv("TENCENT_AD_CREATIVE_PROCESS_WORKERS", "8")),
  1443. len(tasks),
  1444. 32,
  1445. )
  1446. if tasks
  1447. else 1
  1448. )
  1449. logger.info(
  1450. "creative processing started creatives=%d workers=%d",
  1451. len(tasks),
  1452. process_workers,
  1453. )
  1454. with ThreadPoolExecutor(
  1455. max_workers=process_workers,
  1456. thread_name_prefix="creative-process",
  1457. ) as executor:
  1458. futures = {
  1459. executor.submit(process_creative, task): task for task in tasks
  1460. }
  1461. for completed, future in enumerate(as_completed(futures), start=1):
  1462. task = futures[future]
  1463. account_id = task[1]
  1464. creative_id = _as_int(task[2].get("dynamic_creative_id"))
  1465. try:
  1466. result = future.result()
  1467. except Exception as exc:
  1468. error = (
  1469. "creative processing failed "
  1470. f"account={account_id} creative={creative_id}: {exc}"
  1471. )
  1472. scan_errors.append(error)
  1473. logger.exception(error)
  1474. continue
  1475. if result is None:
  1476. continue
  1477. account_id, creative_id, action = result
  1478. confirmed_actions[(account_id, creative_id)] = action
  1479. discovered += 1
  1480. if completed % 500 == 0:
  1481. logger.info(
  1482. "creative processing progress=%d/%d confirmed=%d",
  1483. completed,
  1484. len(tasks),
  1485. discovered,
  1486. )
  1487. deleted = 0
  1488. deferred = 0
  1489. delete_errors: list[str] = []
  1490. write_lock_name = os.getenv(
  1491. "RTC_DB_LOCK_NAME", "tencent_realtime_control"
  1492. )
  1493. retryable_items = load_retryable_cleanup_items()
  1494. # 阶段一(锁外):纯前置判断,无腾讯写。通过者进入 deletable_items。
  1495. deletable_items: list[dict[str, Any]] = []
  1496. for item in retryable_items if apply_enabled else []:
  1497. item_id = int(item["id"])
  1498. account_id = int(item["account_id"])
  1499. creative_id = int(item["dynamic_creative_id"])
  1500. snapshot_status = str(item.get("cleanup_status") or "")
  1501. if snapshot_status == "DELETING":
  1502. # A stale claim is recovered only after obtaining the global
  1503. # Tencent write lock; do not mutate a potentially live owner.
  1504. deletable_items.append(item)
  1505. continue
  1506. if item.get("cleanup_action") != DELETE_CREATIVE:
  1507. update_cleanup_item(
  1508. item_id,
  1509. _expected_cleanup_status=snapshot_status,
  1510. cleanup_status="SKIPPED_REVIEW_NOT_RECONFIRMED",
  1511. error_message="当前规则仅允许删除整条创意",
  1512. )
  1513. continue
  1514. agency = str(item.get("agency_name") or "") or _resolve_agency(
  1515. context, account_id, creative_id
  1516. )
  1517. if agency and agency != item.get("agency_name"):
  1518. update_cleanup_item(
  1519. item_id,
  1520. _expected_cleanup_status=snapshot_status,
  1521. agency_name=agency,
  1522. )
  1523. item["agency_name"] = agency
  1524. webhook_url = resolve_agency_webhook(
  1525. agency, webhook_config.webhooks or {}
  1526. )
  1527. if not agency or not webhook_url:
  1528. reason = (
  1529. "代理商归属为空,禁止自动删除"
  1530. if not agency
  1531. else f"代理商 {agency} 未配置通知群,禁止自动删除"
  1532. )
  1533. updated = update_cleanup_item(
  1534. item_id,
  1535. _expected_cleanup_status=snapshot_status,
  1536. cleanup_status="DEFERRED",
  1537. error_message=reason,
  1538. )
  1539. if updated is not False:
  1540. deferred += 1
  1541. continue
  1542. if item.get("cleanup_status") not in {
  1543. "WRITE_OUTCOME_UNKNOWN",
  1544. "DELETING",
  1545. }:
  1546. precondition_failure = cleanup_precondition_failure(
  1547. item,
  1548. scanned_accounts,
  1549. confirmed_actions,
  1550. )
  1551. if precondition_failure:
  1552. status, reason = precondition_failure
  1553. updated = update_cleanup_item(
  1554. item_id,
  1555. _expected_cleanup_status=snapshot_status,
  1556. cleanup_status=status,
  1557. error_message=reason,
  1558. )
  1559. if status == "DEFERRED" and updated is not False:
  1560. deferred += 1
  1561. continue
  1562. deletable_items.append(item)
  1563. # 阶段二(锁内并发):整个删除批次持锁一次,锁内并发回读/复审/删除。
  1564. # 每 worker 用独立 TencentClient(requests.Session 非线程安全),
  1565. # 避免共享 session 并发导致不可预测行为(与扫描阶段一致)。
  1566. if deletable_items:
  1567. configured_delete_workers = int(
  1568. os.getenv("TENCENT_AD_DELETE_WORKERS", "4")
  1569. )
  1570. if configured_delete_workers < 1:
  1571. raise ValueError("TENCENT_AD_DELETE_WORKERS must be at least 1")
  1572. delete_workers = min(
  1573. configured_delete_workers,
  1574. len(deletable_items),
  1575. 16,
  1576. )
  1577. if not owned_tencent:
  1578. # A caller-supplied client may wrap a requests.Session and is
  1579. # not assumed to be thread-safe.
  1580. delete_workers = 1
  1581. delete_worker_local = threading.local()
  1582. delete_worker_clients = []
  1583. delete_worker_clients_lock = threading.Lock()
  1584. def delete_one(item, delete_client):
  1585. """锁内单条创意:回读 → 复审 → 删除;返回 (deleted, deferred, error)。"""
  1586. item_id = int(item["id"])
  1587. account_id = int(item["account_id"])
  1588. creative_id = int(item["dynamic_creative_id"])
  1589. try:
  1590. try:
  1591. before = delete_client.get_dynamic_creative(
  1592. account_id, creative_id
  1593. )
  1594. except Exception as read_exc:
  1595. if (
  1596. item.get("cleanup_action") == DELETE_CREATIVE
  1597. and str(read_exc).startswith(
  1598. "Dynamic creative not found:"
  1599. )
  1600. ):
  1601. updated = _update_owned_cleanup_item(
  1602. item_id,
  1603. cleanup_status="CREATIVE_DELETED",
  1604. error_message=None,
  1605. readback_json=_json({"deleted_from_listing": True}),
  1606. deleted_at=effective_now,
  1607. )
  1608. if updated is not False:
  1609. return 1, 0, None
  1610. return 0, 0, (
  1611. f"account={account_id} creative={creative_id}: "
  1612. "delete result ignored because claim ownership was lost"
  1613. )
  1614. raise
  1615. if item.get("cleanup_status") in {
  1616. "WRITE_OUTCOME_UNKNOWN",
  1617. "DELETING",
  1618. }:
  1619. precondition_failure = cleanup_precondition_failure(
  1620. item,
  1621. scanned_accounts,
  1622. confirmed_actions,
  1623. )
  1624. if precondition_failure:
  1625. status, reason = precondition_failure
  1626. _update_owned_cleanup_item(
  1627. item_id,
  1628. cleanup_status=status,
  1629. error_message=reason,
  1630. )
  1631. if status == "DEFERRED":
  1632. return 0, 1, None
  1633. return 0, 0, None
  1634. action = str(item.get("cleanup_action") or "")
  1635. approval_status = str(
  1636. before.get("creative_set_approval_status") or ""
  1637. )
  1638. fresh_raw = None
  1639. if approval_status == CREATIVE_DENIED_STATUS:
  1640. fresh_action = determine_cleanup_action(before, None)
  1641. else:
  1642. fresh_results = fetch_reviews(account_id, [creative_id])
  1643. fresh_raw = next(
  1644. (
  1645. result
  1646. for result in fresh_results
  1647. if _as_int(result.get("dynamic_creative_id"))
  1648. == creative_id
  1649. ),
  1650. None,
  1651. )
  1652. fresh_cost_fen = None
  1653. fresh_spend_error = None
  1654. if approval_status == CREATIVE_PARTIAL_NORMAL_STATUS:
  1655. try:
  1656. fresh_cost_fen = (
  1657. delete_client.get_dynamic_creative_costs(
  1658. account_id,
  1659. [creative_id],
  1660. spend_start_date,
  1661. spend_end_date,
  1662. ).get(creative_id, 0)
  1663. )
  1664. except Exception as spend_exc:
  1665. fresh_spend_error = str(spend_exc)
  1666. fresh_action = determine_cleanup_action(
  1667. before,
  1668. fresh_raw,
  1669. recent_cost_fen=fresh_cost_fen,
  1670. cost_threshold_fen=cost_threshold_fen,
  1671. wechat_cost_threshold_fen=wechat_cost_threshold_fen,
  1672. spend_error=fresh_spend_error,
  1673. )
  1674. if not _same_cleanup_action(action, fresh_action):
  1675. if (
  1676. fresh_action
  1677. and fresh_action.get("cleanup_action") == ALERT_ONLY
  1678. ):
  1679. _update_owned_cleanup_item(
  1680. item_id,
  1681. cleanup_action=ALERT_ONLY,
  1682. target_component_ids_json="[]",
  1683. target_element_ids_json="[]",
  1684. recent_cost_fen=fresh_action.get("recent_cost_fen"),
  1685. cost_start_date=spend_start_date,
  1686. cost_end_date=spend_end_date,
  1687. action_reason=fresh_action["action_reason"],
  1688. reject_reason=_reject_reason(
  1689. fresh_raw,
  1690. str(before.get("system_status") or ""),
  1691. ),
  1692. review_result_json=_json(fresh_raw or {}),
  1693. cleanup_status="ALERT_PENDING",
  1694. error_message=None,
  1695. pre_state_json=_json(before),
  1696. readback_json=None,
  1697. deleted_at=None,
  1698. notified_at=None,
  1699. )
  1700. return 0, 0, None
  1701. updated = _update_owned_cleanup_item(
  1702. item_id,
  1703. cleanup_status="SKIPPED_REVIEW_NOT_RECONFIRMED",
  1704. error_message="写锁内回读发现整创意删除条件已变化",
  1705. pre_state_json=_json(before),
  1706. )
  1707. return 0, 0, None
  1708. if action == DELETE_CREATIVE:
  1709. readback = delete_client.delete_dynamic_creative(
  1710. account_id, creative_id
  1711. )
  1712. updated = _update_owned_cleanup_item(
  1713. item_id,
  1714. cleanup_status="CREATIVE_DELETED",
  1715. error_message=None,
  1716. pre_state_json=_json(before),
  1717. readback_json=_json(readback),
  1718. deleted_at=effective_now,
  1719. )
  1720. if updated is not False:
  1721. return 1, 0, None
  1722. return 0, 0, (
  1723. f"account={account_id} creative={creative_id}: "
  1724. "delete result ignored because claim ownership was lost"
  1725. )
  1726. return 0, 0, None
  1727. except Exception as exc:
  1728. from tencent_client import (
  1729. PostWriteVerificationError,
  1730. TencentWriteOutcomeUnknownError,
  1731. TencentWriteRateLimitedError,
  1732. )
  1733. outcome_unknown = isinstance(
  1734. exc,
  1735. (
  1736. TencentWriteOutcomeUnknownError,
  1737. PostWriteVerificationError,
  1738. ),
  1739. )
  1740. rate_limited = isinstance(exc, TencentWriteRateLimitedError)
  1741. error = f"account={account_id} creative={creative_id}: {exc}"
  1742. try:
  1743. _update_owned_cleanup_item(
  1744. item_id,
  1745. cleanup_status=(
  1746. "DEFERRED"
  1747. if rate_limited
  1748. else (
  1749. "WRITE_OUTCOME_UNKNOWN"
  1750. if outcome_unknown
  1751. else "FAILED"
  1752. )
  1753. ),
  1754. error_message=str(exc)[:4000],
  1755. )
  1756. except Exception as update_exc:
  1757. logger.exception(
  1758. "creative delete failure status update failed "
  1759. "account=%d creative=%d",
  1760. account_id,
  1761. creative_id,
  1762. )
  1763. error += f"; status_update_failed={update_exc}"
  1764. return 0, int(rate_limited), error
  1765. def run_delete(item):
  1766. if owned_tencent:
  1767. delete_client = getattr(delete_worker_local, "client", None)
  1768. if delete_client is None:
  1769. from tencent_client import TencentClient
  1770. delete_client = TencentClient()
  1771. delete_client.seed_access_tokens(prefetched_tokens)
  1772. delete_worker_local.client = delete_client
  1773. with delete_worker_clients_lock:
  1774. delete_worker_clients.append(delete_client)
  1775. else:
  1776. delete_client = client
  1777. return delete_one(item, delete_client)
  1778. with advisory_lock(write_lock_name) as acquired:
  1779. if not acquired:
  1780. for item in deletable_items:
  1781. snapshot_status = str(item.get("cleanup_status") or "")
  1782. if snapshot_status == "DELETING":
  1783. continue
  1784. updated = update_cleanup_item(
  1785. int(item["id"]),
  1786. _expected_cleanup_status=snapshot_status,
  1787. cleanup_status="DEFERRED",
  1788. error_message="腾讯写锁被实时调控占用",
  1789. )
  1790. if updated is not False:
  1791. deferred += 1
  1792. else:
  1793. claimed_items = []
  1794. for item in deletable_items:
  1795. try:
  1796. if claim_cleanup_item(int(item["id"])):
  1797. claimed_items.append(item)
  1798. except Exception as exc:
  1799. error = (
  1800. "creative delete claim failed "
  1801. f"account={item['account_id']} "
  1802. f"creative={item['dynamic_creative_id']}: {exc}"
  1803. )
  1804. delete_errors.append(error)
  1805. logger.exception(error)
  1806. executable_items = []
  1807. for item in claimed_items:
  1808. agency = str(item.get("agency_name") or "") or _resolve_agency(
  1809. context,
  1810. int(item["account_id"]),
  1811. int(item["dynamic_creative_id"]),
  1812. )
  1813. webhook_url = resolve_agency_webhook(
  1814. agency, webhook_config.webhooks or {}
  1815. )
  1816. if agency and webhook_url:
  1817. if agency != item.get("agency_name"):
  1818. _update_owned_cleanup_item(
  1819. int(item["id"]),
  1820. agency_name=agency,
  1821. )
  1822. item["agency_name"] = agency
  1823. executable_items.append(item)
  1824. continue
  1825. reason = (
  1826. "代理商归属为空,禁止自动删除"
  1827. if not agency
  1828. else f"代理商 {agency} 未配置通知群,禁止自动删除"
  1829. )
  1830. updated = _update_owned_cleanup_item(
  1831. int(item["id"]),
  1832. cleanup_status="DEFERRED",
  1833. error_message=reason,
  1834. )
  1835. if updated is not False:
  1836. deferred += 1
  1837. logger.info(
  1838. "creative delete started candidates=%d claimed=%d workers=%d",
  1839. len(deletable_items),
  1840. len(executable_items),
  1841. delete_workers,
  1842. )
  1843. if executable_items:
  1844. try:
  1845. with ThreadPoolExecutor(
  1846. max_workers=min(delete_workers, len(executable_items)),
  1847. thread_name_prefix="creative-delete",
  1848. ) as executor:
  1849. futures = {
  1850. executor.submit(run_delete, item): item
  1851. for item in executable_items
  1852. }
  1853. for future in as_completed(futures):
  1854. item = futures[future]
  1855. try:
  1856. d_deleted, d_deferred, d_error = future.result()
  1857. except Exception as exc:
  1858. d_deleted = 0
  1859. d_deferred = 0
  1860. d_error = (
  1861. "creative delete worker failed "
  1862. f"account={item['account_id']} "
  1863. f"creative={item['dynamic_creative_id']}: {exc}"
  1864. )
  1865. logger.exception(d_error)
  1866. try:
  1867. _update_owned_cleanup_item(
  1868. int(item["id"]),
  1869. cleanup_status="FAILED",
  1870. error_message=str(exc)[:4000],
  1871. )
  1872. except Exception as update_exc:
  1873. logger.exception(
  1874. "creative delete worker failure status "
  1875. "update failed account=%s creative=%s",
  1876. item["account_id"],
  1877. item["dynamic_creative_id"],
  1878. )
  1879. d_error += (
  1880. f"; status_update_failed={update_exc}"
  1881. )
  1882. deleted += d_deleted
  1883. deferred += d_deferred
  1884. if d_error:
  1885. delete_errors.append(d_error)
  1886. finally:
  1887. for delete_worker_client in delete_worker_clients:
  1888. delete_worker_client.session.close()
  1889. deliveries: list[dict[str, object]] = []
  1890. operator_deliveries: list[dict[str, object]] = []
  1891. notification_errors: list[str] = []
  1892. def publish_pending_notifications(
  1893. pending_notifications: list[dict[str, Any]],
  1894. ) -> None:
  1895. owned_publisher = publisher is None
  1896. sheet_publisher = publisher or RoiFeishuPublisher(require_chat_ids=False)
  1897. try:
  1898. rows_by_date: dict[str, list[dict[str, Any]]] = defaultdict(list)
  1899. for row in pending_notifications:
  1900. rows_by_date[_display_date(row.get("check_date"))].append(row)
  1901. for check_date, daily_rows in sorted(rows_by_date.items()):
  1902. report_date = check_date.replace("-", "")
  1903. report_dir = output_dir / report_date
  1904. agency_rows = [
  1905. row
  1906. for row in daily_rows
  1907. if row.get("agency_notified_at") is None
  1908. and str(row.get("agency_name") or "").strip()
  1909. ]
  1910. run_id = f"reject_{report_date}_{REPORT_VERSION}"
  1911. if agency_rows:
  1912. run_id, reports, report_item_ids = write_cleanup_reports(
  1913. agency_rows,
  1914. report_dir,
  1915. report_date,
  1916. )
  1917. for report in reports:
  1918. daily_deliveries = publish_agency_reports(
  1919. run_id=str(report.get("run_id") or run_id),
  1920. reports=[report],
  1921. config=webhook_config,
  1922. publisher=sheet_publisher,
  1923. notifier=notifier,
  1924. now=effective_now,
  1925. upsert_delivery=upsert_cleanup_delivery,
  1926. update_delivery=update_cleanup_delivery,
  1927. )
  1928. deliveries.extend(daily_deliveries)
  1929. for outcome in daily_deliveries:
  1930. agency_name = str(outcome["agency_name"])
  1931. if outcome.get("status") == "SENT":
  1932. mark_cleanup_items_notified(
  1933. report_item_ids.get(agency_name, []),
  1934. effective_now,
  1935. )
  1936. else:
  1937. notification_errors.append(
  1938. f"agency={agency_name}: "
  1939. f"{outcome.get('error') or outcome.get('reason') or outcome.get('status')}"
  1940. )
  1941. operator_rows = [
  1942. row
  1943. for row in daily_rows
  1944. if row.get("operator_notified_at") is None
  1945. ]
  1946. if operator_rows:
  1947. operator_report = write_cleanup_operator_summary(
  1948. operator_rows,
  1949. report_dir,
  1950. report_date,
  1951. run_id,
  1952. )
  1953. operator_outcome = publish_cleanup_operator_summary(
  1954. run_id=str(operator_report["run_id"]),
  1955. report=operator_report,
  1956. chat_id=os.getenv("FEISHU_AD_PROJECT_CHAT_ID", ""),
  1957. publisher=sheet_publisher,
  1958. now=effective_now,
  1959. )
  1960. operator_deliveries.append(operator_outcome)
  1961. if operator_outcome.get("status") == "SENT":
  1962. mark_cleanup_items_operator_notified(
  1963. [int(row["id"]) for row in operator_rows],
  1964. effective_now,
  1965. )
  1966. else:
  1967. notification_errors.append(
  1968. f"operator={check_date}: "
  1969. f"{operator_outcome.get('error') or operator_outcome.get('status')}"
  1970. )
  1971. finally:
  1972. if owned_publisher:
  1973. sheet_publisher.close()
  1974. pending_notification_probe = load_unnotified_deleted_items(
  1975. include_discovered=not apply_enabled,
  1976. )
  1977. if pending_notification_probe and webhook_config.enabled:
  1978. notification_lock_name = os.getenv(
  1979. "DAILY_REJECTED_CREATIVE_NOTIFICATION_LOCK_NAME",
  1980. "ad_rejected_creative_notification",
  1981. )
  1982. with advisory_lock(notification_lock_name) as acquired:
  1983. if not acquired:
  1984. logger.info(
  1985. "creative cleanup notification skipped: lock busy name=%s",
  1986. notification_lock_name,
  1987. )
  1988. else:
  1989. pending_notifications = load_unnotified_deleted_items(
  1990. include_discovered=not apply_enabled,
  1991. )
  1992. if pending_notifications:
  1993. publish_pending_notifications(pending_notifications)
  1994. return {
  1995. "apply_enabled": apply_enabled,
  1996. "account_scope": "opengid_recent_3d_spend",
  1997. "account_scope_start_date": start_date,
  1998. "account_scope_end_date": end_date,
  1999. "accounts": len(accounts),
  2000. "account_ids": account_ids,
  2001. "tokens_prefetched": len(prefetched_tokens),
  2002. "creatives_scanned": scanned,
  2003. "rejected_discovered": discovered,
  2004. "pending_cleanup": len(retryable_items) if not apply_enabled else 0,
  2005. "deleted": deleted,
  2006. "deferred": deferred,
  2007. "scan_errors": scan_errors,
  2008. "delete_errors": delete_errors,
  2009. "notification_errors": notification_errors,
  2010. "deliveries": deliveries,
  2011. "operator_deliveries": operator_deliveries,
  2012. }
  2013. finally:
  2014. if owned_tencent:
  2015. client.session.close()