creative_rejection_cleanup.py 195 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753
  1. """先清理符合规则的创意,再清理零消耗广告,并隔离不同通知渠道。"""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import logging
  6. import math
  7. import os
  8. import threading
  9. import time
  10. from collections import defaultdict
  11. from concurrent.futures import ThreadPoolExecutor, as_completed
  12. from datetime import date, datetime, timedelta
  13. from pathlib import Path
  14. from typing import Any, Callable
  15. from zoneinfo import ZoneInfo
  16. import pandas as pd
  17. from openpyxl import Workbook
  18. from openpyxl.styles import Alignment, Font, PatternFill
  19. from openpyxl.utils import get_column_letter
  20. from db.connection import get_connection
  21. from roi_control.agency_delivery import publish_agency_reports, resolve_agency_webhook
  22. from roi_control.config import AgencyWebhookConfig
  23. from roi_control.feishu import RoiFeishuPublisher
  24. from storage import advisory_lock, initialize_schema
  25. from tools.creative_review import (
  26. fetch_dynamic_creative_review_results,
  27. parse_review_result,
  28. review_granularity_fields,
  29. status_desc,
  30. )
  31. logger = logging.getLogger(__name__)
  32. SHANGHAI = ZoneInfo("Asia/Shanghai")
  33. REPORT_VERSION = "creative_rejection_cleanup_v13"
  34. OPERATOR_SUMMARY_ROUTE = "投放调控汇总"
  35. PERFORMANCE_SUMMARY_ROUTE = "长期未起量清理汇总"
  36. PERFORMANCE_REPORT_VERSION = (
  37. "creative_rejection_v13_performance_internal_acct_meta_v2"
  38. )
  39. DENIED_SYSTEM_STATUS = "DYNAMIC_CREATIVE_STATUS_DENIED"
  40. DELETED_STATUS = "AD_STATUS_DELETED"
  41. DYNAMIC_CREATIVE_DELETED_STATUS = "DYNAMIC_CREATIVE_STATUS_DELETED"
  42. CREATIVE_DENIED_STATUS = "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  43. CREATIVE_NORMAL_STATUS = "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  44. CREATIVE_PARTIAL_NORMAL_STATUS = "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  45. DELETE_CREATIVE = "DELETE_CREATIVE"
  46. ALERT_ONLY = "ALERT_ONLY"
  47. REVIEW_DENIED_RULE = "REVIEW_DENIED"
  48. REVIEW_PARTIAL_RULE = "REVIEW_PARTIAL"
  49. PERFORMANCE_NEW_RULE = "PERFORMANCE_NEW_ZERO_DELIVERY"
  50. PERFORMANCE_OLD_LOW_RULE = "PERFORMANCE_OLD_LOW_EXPOSURE_ZERO_COST"
  51. PERFORMANCE_OLD_HIGH_RULE = "PERFORMANCE_OLD_HIGH_EXPOSURE_ZERO_COST"
  52. PERFORMANCE_AD_ZERO_SPEND_RULE = "PERFORMANCE_AD_3D_ZERO_SPEND"
  53. PERFORMANCE_RULE_PREFIX = "PERFORMANCE_"
  54. DELETE_AD = "DELETE_AD"
  55. DEFAULT_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN = 50.0
  56. DEFAULT_WECHAT_MINI_PROGRAM_COST_THRESHOLD_YUAN = 100.0
  57. DEFAULT_DELETE_CLAIM_STALE_MINUTES = 30
  58. DEFAULT_PERFORMANCE_NEW_MIN_AGE_DAYS = 5
  59. DEFAULT_PERFORMANCE_NEW_MAX_AGE_DAYS = 7
  60. DEFAULT_PERFORMANCE_NEW_IMPRESSIONS_THRESHOLD = 100
  61. DEFAULT_PERFORMANCE_OLD_DAILY_IMPRESSIONS_THRESHOLD = 100.0
  62. DEFAULT_PERFORMANCE_WINDOW_DAYS = 7
  63. DEFAULT_AD_PERFORMANCE_WINDOW_DAYS = 3
  64. DEFAULT_AD_PERFORMANCE_MIN_AGE_DAYS = 5
  65. AGENCY_REPORT_COLUMNS = (
  66. "代理名称",
  67. "账户ID",
  68. "账户名称",
  69. "广告ID",
  70. "广告名称",
  71. "创意ID",
  72. "创意名称",
  73. "近3天累计历史消耗(元)",
  74. "配置状态",
  75. "创意审核状态",
  76. "审核不通过原因",
  77. "执行操作",
  78. )
  79. OPERATOR_REPORT_COLUMNS = (
  80. "代理名称",
  81. "账户ID",
  82. "账户名称",
  83. "广告ID",
  84. "广告名称",
  85. "创意ID",
  86. "创意名称",
  87. "清理规则",
  88. "创意搭建时间",
  89. "创意年龄(天)",
  90. "规则窗口累计曝光",
  91. "规则窗口日均曝光",
  92. "规则窗口累计消耗(元)",
  93. "规则指标日期范围",
  94. "近3天累计历史消耗(元)",
  95. "消耗日期范围",
  96. "配置状态",
  97. "创意审核状态",
  98. "元素粒度审核状态",
  99. "元素粒度审核不通过原因",
  100. "版位粒度审核状态",
  101. "版位粒度审核不通过原因",
  102. "审核不通过原因",
  103. "检查时间",
  104. "执行操作",
  105. "操作判断原因",
  106. )
  107. PERFORMANCE_REPORT_COLUMNS = (
  108. "清理对象",
  109. "账户ID",
  110. "账户名称",
  111. "代理商昵称",
  112. "广告ID",
  113. "广告名称",
  114. "创意ID",
  115. "创意名称",
  116. "清理规则",
  117. "对象创建时间",
  118. "对象年龄(天)",
  119. "规则窗口累计曝光",
  120. "规则窗口日均曝光",
  121. "规则窗口累计消耗(元)",
  122. "广告近3日日均消耗(元)",
  123. "规则指标日期范围",
  124. "配置状态",
  125. "创意审核状态",
  126. "检查时间",
  127. "执行操作",
  128. "操作判断原因",
  129. )
  130. # 兼容仍将代理商报表视为默认报表的旧调用方。
  131. REPORT_COLUMNS = AGENCY_REPORT_COLUMNS
  132. def _operator_summary_chat_id() -> str:
  133. """读取投放运营清理汇总使用的内部群 ID。"""
  134. return os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip()
  135. def _json(value: Any) -> str:
  136. return json.dumps(value, ensure_ascii=False, default=str)
  137. def _is_rejected_status(value: Any) -> bool:
  138. upper = str(value or "").strip().upper()
  139. return "REJECT" in upper or "DENIED" in upper
  140. def _is_deleted_creative(creative: dict[str, Any]) -> bool:
  141. return (
  142. creative.get("configured_status") == DELETED_STATUS
  143. or creative.get("system_status") == DYNAMIC_CREATIVE_DELETED_STATUS
  144. )
  145. def _is_deleted_ad(ad: dict[str, Any]) -> bool:
  146. """所有读取链路统一使用腾讯广告的显式删除字段判断状态。"""
  147. from tencent_client import is_deleted_ad
  148. return is_deleted_ad(ad)
  149. def has_rejected_wechat_mini_program_element(raw_result: dict | None) -> bool:
  150. """判断审核结果中是否存在明确名为“微信小程序”的拒审元素。"""
  151. raw = raw_result if isinstance(raw_result, dict) else {}
  152. for element in raw.get("element_result_list") or []:
  153. if not isinstance(element, dict):
  154. continue
  155. element_name = "".join(str(element.get("element_name") or "").split())
  156. if element_name != "微信小程序":
  157. continue
  158. if any(
  159. _is_rejected_status(element.get(field))
  160. for field in ("review_status", "system_status")
  161. ):
  162. return True
  163. return False
  164. def partial_creative_cost_threshold_fen() -> int:
  165. raw = os.getenv(
  166. "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN",
  167. str(DEFAULT_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN),
  168. )
  169. try:
  170. yuan = float(raw)
  171. except (TypeError, ValueError) as exc:
  172. raise ValueError(
  173. "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN must be numeric"
  174. ) from exc
  175. if yuan < 0:
  176. raise ValueError(
  177. "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN must not be negative"
  178. )
  179. return int(round(yuan * 100))
  180. def wechat_mini_program_cost_threshold_fen() -> int:
  181. raw = os.getenv(
  182. "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN",
  183. str(DEFAULT_WECHAT_MINI_PROGRAM_COST_THRESHOLD_YUAN),
  184. )
  185. try:
  186. yuan = float(raw)
  187. except (TypeError, ValueError) as exc:
  188. raise ValueError(
  189. "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN must be numeric"
  190. ) from exc
  191. if yuan < 0:
  192. raise ValueError(
  193. "DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN must not be negative"
  194. )
  195. return int(round(yuan * 100))
  196. def performance_cleanup_config() -> dict[str, int | float]:
  197. """读取并校验长期未起量清理阈值。"""
  198. names_and_defaults: tuple[tuple[str, int | float, type], ...] = (
  199. (
  200. "DAILY_UNDERPERFORMING_CREATIVE_NEW_MIN_AGE_DAYS",
  201. DEFAULT_PERFORMANCE_NEW_MIN_AGE_DAYS,
  202. int,
  203. ),
  204. (
  205. "DAILY_UNDERPERFORMING_CREATIVE_NEW_MAX_AGE_DAYS",
  206. DEFAULT_PERFORMANCE_NEW_MAX_AGE_DAYS,
  207. int,
  208. ),
  209. (
  210. "DAILY_UNDERPERFORMING_CREATIVE_NEW_IMPRESSIONS_THRESHOLD",
  211. DEFAULT_PERFORMANCE_NEW_IMPRESSIONS_THRESHOLD,
  212. int,
  213. ),
  214. (
  215. "DAILY_UNDERPERFORMING_CREATIVE_OLD_DAILY_IMPRESSIONS_THRESHOLD",
  216. DEFAULT_PERFORMANCE_OLD_DAILY_IMPRESSIONS_THRESHOLD,
  217. float,
  218. ),
  219. (
  220. "DAILY_UNDERPERFORMING_CREATIVE_WINDOW_DAYS",
  221. DEFAULT_PERFORMANCE_WINDOW_DAYS,
  222. int,
  223. ),
  224. )
  225. values: dict[str, int | float] = {}
  226. for name, default, converter in names_and_defaults:
  227. raw = os.getenv(name, str(default))
  228. try:
  229. value = converter(raw)
  230. except (TypeError, ValueError) as exc:
  231. raise ValueError(f"{name} must be numeric") from exc
  232. if isinstance(value, float) and not math.isfinite(value):
  233. raise ValueError(f"{name} must be finite")
  234. if value < 0:
  235. raise ValueError(f"{name} must not be negative")
  236. values[name] = value
  237. min_age = int(values["DAILY_UNDERPERFORMING_CREATIVE_NEW_MIN_AGE_DAYS"])
  238. max_age = int(values["DAILY_UNDERPERFORMING_CREATIVE_NEW_MAX_AGE_DAYS"])
  239. window_days = int(values["DAILY_UNDERPERFORMING_CREATIVE_WINDOW_DAYS"])
  240. if max_age <= min_age:
  241. raise ValueError(
  242. "DAILY_UNDERPERFORMING_CREATIVE_NEW_MAX_AGE_DAYS must be greater "
  243. "than DAILY_UNDERPERFORMING_CREATIVE_NEW_MIN_AGE_DAYS"
  244. )
  245. if window_days <= 0:
  246. raise ValueError(
  247. "DAILY_UNDERPERFORMING_CREATIVE_WINDOW_DAYS must be positive"
  248. )
  249. return {
  250. "new_min_age_days": min_age,
  251. "new_max_age_days": max_age,
  252. "new_impressions_threshold": int(
  253. values[
  254. "DAILY_UNDERPERFORMING_CREATIVE_NEW_IMPRESSIONS_THRESHOLD"
  255. ]
  256. ),
  257. "old_daily_impressions_threshold": float(
  258. values[
  259. "DAILY_UNDERPERFORMING_CREATIVE_OLD_DAILY_IMPRESSIONS_THRESHOLD"
  260. ]
  261. ),
  262. "window_days": window_days,
  263. }
  264. def ad_performance_window_days() -> int:
  265. raw = os.getenv(
  266. "DAILY_UNDERPERFORMING_AD_WINDOW_DAYS",
  267. str(DEFAULT_AD_PERFORMANCE_WINDOW_DAYS),
  268. )
  269. try:
  270. value = int(raw)
  271. except (TypeError, ValueError) as exc:
  272. raise ValueError(
  273. "DAILY_UNDERPERFORMING_AD_WINDOW_DAYS must be an integer"
  274. ) from exc
  275. if value <= 0:
  276. raise ValueError(
  277. "DAILY_UNDERPERFORMING_AD_WINDOW_DAYS must be positive"
  278. )
  279. return value
  280. def ad_performance_min_age_days() -> int:
  281. raw = os.getenv(
  282. "DAILY_UNDERPERFORMING_AD_MIN_AGE_DAYS",
  283. str(DEFAULT_AD_PERFORMANCE_MIN_AGE_DAYS),
  284. )
  285. try:
  286. value = int(raw)
  287. except (TypeError, ValueError) as exc:
  288. raise ValueError(
  289. "DAILY_UNDERPERFORMING_AD_MIN_AGE_DAYS must be an integer"
  290. ) from exc
  291. if value < 0:
  292. raise ValueError(
  293. "DAILY_UNDERPERFORMING_AD_MIN_AGE_DAYS must not be negative"
  294. )
  295. return value
  296. def _is_performance_rule(rule_type: Any) -> bool:
  297. return str(rule_type or "").startswith(PERFORMANCE_RULE_PREFIX)
  298. def _is_ad_performance_rule(rule_type: Any) -> bool:
  299. return str(rule_type or "") == PERFORMANCE_AD_ZERO_SPEND_RULE
  300. def _as_shanghai_datetime(value: Any) -> datetime | None:
  301. if value is None or (isinstance(value, str) and not value.strip()):
  302. return None
  303. if not isinstance(value, (date, datetime)):
  304. try:
  305. if bool(pd.isna(value)):
  306. return None
  307. except (TypeError, ValueError):
  308. return None
  309. try:
  310. raw_text = str(value).strip()
  311. unsigned_text = raw_text.lstrip("+-")
  312. numeric_value: float | None = None
  313. if unsigned_text.isdigit():
  314. # ODPS 导出结果可能把紧凑日期值表示为整数。
  315. if len(unsigned_text) in {8, 14} and not raw_text.startswith("-"):
  316. calendar_format = (
  317. "%Y%m%d" if len(unsigned_text) == 8 else "%Y%m%d%H%M%S"
  318. )
  319. parsed_calendar = datetime.strptime(
  320. unsigned_text,
  321. calendar_format,
  322. )
  323. return parsed_calendar.replace(tzinfo=SHANGHAI)
  324. numeric_value = float(raw_text)
  325. elif not isinstance(value, (str, date, datetime, pd.Timestamp)):
  326. try:
  327. numeric_value = float(value)
  328. except (TypeError, ValueError, OverflowError):
  329. numeric_value = None
  330. if numeric_value is not None:
  331. if not math.isfinite(numeric_value) or numeric_value < 0:
  332. return None
  333. absolute_value = abs(numeric_value)
  334. # 腾讯 adgroups/get 的 created_time 是整数 Unix 时间戳;若不指定
  335. # 单位,pandas 会按纳秒解释,产生 1970-01-01 00:00:02 一类错误时间。
  336. if 100_000_000 <= absolute_value < 100_000_000_000:
  337. unit = "s"
  338. elif 100_000_000_000 <= absolute_value < 100_000_000_000_000:
  339. unit = "ms"
  340. elif (
  341. 100_000_000_000_000
  342. <= absolute_value
  343. < 100_000_000_000_000_000
  344. ):
  345. unit = "us"
  346. elif (
  347. 100_000_000_000_000_000
  348. <= absolute_value
  349. < 100_000_000_000_000_000_000
  350. ):
  351. unit = "ns"
  352. else:
  353. return None
  354. timestamp = pd.to_datetime(numeric_value, unit=unit, utc=True)
  355. shanghai_timestamp = timestamp.tz_convert(SHANGHAI)
  356. try:
  357. return shanghai_timestamp.to_pydatetime(warn=False)
  358. except TypeError:
  359. return shanghai_timestamp.to_pydatetime()
  360. timestamp = pd.Timestamp(value)
  361. if pd.isna(timestamp):
  362. return None
  363. try:
  364. parsed = timestamp.to_pydatetime(warn=False)
  365. except TypeError:
  366. # pandas 2.0 以前的版本不支持 ``warn`` 参数。
  367. parsed = timestamp.to_pydatetime()
  368. except (TypeError, ValueError, OverflowError):
  369. return None
  370. if parsed.tzinfo is None:
  371. return parsed.replace(tzinfo=SHANGHAI)
  372. return parsed.astimezone(SHANGHAI)
  373. def _as_date(value: Any) -> date | None:
  374. if value is None or (isinstance(value, str) and not value.strip()):
  375. return None
  376. try:
  377. timestamp = pd.Timestamp(value)
  378. except (TypeError, ValueError, OverflowError):
  379. return None
  380. if pd.isna(timestamp):
  381. return None
  382. return timestamp.date()
  383. def _is_current_cleanup_day(run_date: date, current_time: Any) -> bool:
  384. current = _as_shanghai_datetime(current_time)
  385. return current is not None and current.date() == run_date
  386. def determine_performance_cleanup_action(
  387. creative: dict[str, Any],
  388. ad: dict[str, Any],
  389. *,
  390. creative_created_at: Any,
  391. as_of_date: date | datetime,
  392. impressions: int,
  393. cost_fen: int,
  394. metric_start_date: date,
  395. metric_end_date: date,
  396. current_day_cost_fen: int = 0,
  397. config: dict[str, int | float] | None = None,
  398. ) -> dict[str, Any] | None:
  399. """严格应用新素材和老素材的未起量规则。
  400. 只有审核通过、创意启用且所属广告启用的对象才允许进入候选,避免把父广告
  401. 主动暂停、创意人工暂停或审核未完成造成的零投放误判为应删除对象。
  402. """
  403. if creative.get("configured_status") != "AD_STATUS_NORMAL":
  404. return None
  405. if ad.get("configured_status") != "AD_STATUS_NORMAL":
  406. return None
  407. if creative.get("creative_set_approval_status") not in {
  408. CREATIVE_NORMAL_STATUS,
  409. CREATIVE_PARTIAL_NORMAL_STATUS,
  410. }:
  411. return None
  412. created_at = _as_shanghai_datetime(creative_created_at)
  413. if created_at is None:
  414. return None
  415. try:
  416. impressions_value = int(impressions)
  417. cost_value = int(cost_fen)
  418. current_day_cost_value = int(current_day_cost_fen)
  419. except (TypeError, ValueError):
  420. return None
  421. if (
  422. impressions_value < 0
  423. or cost_value < 0
  424. or current_day_cost_value < 0
  425. ):
  426. return None
  427. if current_day_cost_value != 0:
  428. return None
  429. settings = config or performance_cleanup_config()
  430. if isinstance(as_of_date, datetime):
  431. as_of_datetime = _as_shanghai_datetime(as_of_date)
  432. if as_of_datetime is None:
  433. return None
  434. age_days = (as_of_datetime - created_at).days
  435. else:
  436. age_days = (as_of_date - created_at.date()).days
  437. evaluation_date = (
  438. as_of_date.astimezone(SHANGHAI).date()
  439. if isinstance(as_of_date, datetime) and as_of_date.tzinfo is not None
  440. else (
  441. as_of_date.date()
  442. if isinstance(as_of_date, datetime)
  443. else as_of_date
  444. )
  445. )
  446. begin_date = _as_date(ad.get("begin_date"))
  447. end_date = _as_date(ad.get("end_date"))
  448. if begin_date and begin_date > evaluation_date:
  449. return None
  450. if end_date and end_date < evaluation_date:
  451. return None
  452. if age_days < 0:
  453. return None
  454. window_days = int(settings["window_days"])
  455. daily_average = impressions_value / window_days
  456. common = {
  457. "cleanup_action": DELETE_CREATIVE,
  458. "component_ids": [],
  459. "element_ids": [],
  460. "recent_cost_fen": cost_value,
  461. "cost_start_date": metric_start_date,
  462. "cost_end_date": metric_end_date,
  463. "creative_created_at": created_at,
  464. "creative_age_days": age_days,
  465. "metric_impressions": impressions_value,
  466. "metric_daily_avg_impressions": daily_average,
  467. "metric_window_days": window_days,
  468. }
  469. min_age = int(settings["new_min_age_days"])
  470. max_age = int(settings["new_max_age_days"])
  471. new_threshold = int(settings["new_impressions_threshold"])
  472. old_threshold = float(settings["old_daily_impressions_threshold"])
  473. if min_age < age_days <= max_age:
  474. if impressions_value < new_threshold and cost_value == 0:
  475. return {
  476. **common,
  477. "cleanup_rule_type": PERFORMANCE_NEW_RULE,
  478. "action_reason": (
  479. f"新素材搭建{age_days}天(>{min_age}且≤{max_age}),"
  480. f"累计曝光{impressions_value}<{new_threshold}、"
  481. "创建日至当日累计消耗为0元"
  482. ),
  483. }
  484. return None
  485. if age_days <= max_age or cost_value != 0:
  486. return None
  487. if daily_average < old_threshold:
  488. return {
  489. **common,
  490. "cleanup_rule_type": PERFORMANCE_OLD_LOW_RULE,
  491. "action_reason": (
  492. f"老素材搭建{age_days}天(>{max_age}),近{window_days}天"
  493. f"日均曝光{daily_average:.2f}<{old_threshold:g},"
  494. "历史窗口及当日消耗均为0元"
  495. ),
  496. }
  497. if daily_average > old_threshold:
  498. return {
  499. **common,
  500. "cleanup_rule_type": PERFORMANCE_OLD_HIGH_RULE,
  501. "action_reason": (
  502. f"老素材搭建{age_days}天(>{max_age}),近{window_days}天"
  503. f"日均曝光{daily_average:.2f}>{old_threshold:g},"
  504. "历史窗口及当日消耗均为0元"
  505. ),
  506. }
  507. # 业务规则使用严格大于/小于,因此日均恰好为 100(或配置边界)不命中任何规则。
  508. return None
  509. def determine_ad_performance_cleanup_action(
  510. ad: dict[str, Any],
  511. *,
  512. as_of_date: date | datetime,
  513. cost_fen: int,
  514. metric_start_date: date,
  515. metric_end_date: date,
  516. current_day_cost_fen: int = 0,
  517. window_days: int = DEFAULT_AD_PERFORMANCE_WINDOW_DAYS,
  518. min_age_days: int = DEFAULT_AD_PERFORMANCE_MIN_AGE_DAYS,
  519. ) -> dict[str, Any] | None:
  520. """仅将当前启用且完整指标窗口内零消耗的广告判定为删除候选。"""
  521. if _is_deleted_ad(ad):
  522. return None
  523. if ad.get("configured_status") != "AD_STATUS_NORMAL":
  524. return None
  525. try:
  526. cost_value = int(cost_fen)
  527. current_day_cost_value = int(current_day_cost_fen)
  528. except (TypeError, ValueError):
  529. return None
  530. if (
  531. cost_value != 0
  532. or current_day_cost_value != 0
  533. or current_day_cost_value < 0
  534. or window_days <= 0
  535. or min_age_days < 0
  536. ):
  537. return None
  538. created_at = _as_shanghai_datetime(ad.get("created_time"))
  539. if created_at is None:
  540. return None
  541. if isinstance(as_of_date, datetime):
  542. as_of_datetime = _as_shanghai_datetime(as_of_date)
  543. if as_of_datetime is None:
  544. return None
  545. age_days = (as_of_datetime - created_at).days
  546. else:
  547. age_days = (as_of_date - created_at.date()).days
  548. if age_days <= min_age_days:
  549. return None
  550. evaluation_date = (
  551. as_of_date.astimezone(SHANGHAI).date()
  552. if isinstance(as_of_date, datetime) and as_of_date.tzinfo is not None
  553. else as_of_date.date()
  554. if isinstance(as_of_date, datetime)
  555. else as_of_date
  556. )
  557. begin_date = _as_date(ad.get("begin_date"))
  558. end_date = _as_date(ad.get("end_date"))
  559. if begin_date and begin_date > evaluation_date:
  560. return None
  561. if end_date and end_date < evaluation_date:
  562. return None
  563. return {
  564. "cleanup_action": DELETE_AD,
  565. "cleanup_rule_type": PERFORMANCE_AD_ZERO_SPEND_RULE,
  566. "component_ids": [],
  567. "element_ids": [],
  568. "recent_cost_fen": 0,
  569. "cost_start_date": metric_start_date,
  570. "cost_end_date": metric_end_date,
  571. "creative_created_at": created_at,
  572. "creative_age_days": age_days,
  573. "metric_window_days": window_days,
  574. "action_reason": (
  575. f"广告创建{age_days}天(>{min_age_days}),"
  576. f"近{window_days}个完整日累计消耗为0元,"
  577. "且当日消耗为0元"
  578. ),
  579. }
  580. def determine_cleanup_action(
  581. creative: dict[str, Any],
  582. raw_result: dict | None,
  583. *,
  584. recent_cost_fen: int | None = None,
  585. cost_threshold_fen: int = 5000,
  586. wechat_cost_threshold_fen: int = 10000,
  587. spend_error: str | None = None,
  588. ) -> dict[str, Any] | None:
  589. """从三种整创意删除规则和人工告警中选择唯一处理动作。"""
  590. approval_status = str(creative.get("creative_set_approval_status") or "")
  591. if approval_status == CREATIVE_DENIED_STATUS:
  592. return {
  593. "cleanup_action": DELETE_CREATIVE,
  594. "cleanup_rule_type": REVIEW_DENIED_RULE,
  595. "component_ids": [],
  596. "element_ids": [],
  597. "action_reason": "创意审核状态为审核拒绝",
  598. "recent_cost_fen": recent_cost_fen,
  599. }
  600. if approval_status == CREATIVE_PARTIAL_NORMAL_STATUS:
  601. if has_rejected_wechat_mini_program_element(raw_result):
  602. if spend_error or recent_cost_fen is None:
  603. reason = (
  604. "部分投放中且微信小程序元素审核拒绝,"
  605. "近3天历史消耗读取失败,需人工判断"
  606. )
  607. if spend_error:
  608. reason = f"{reason}:{spend_error}"
  609. return {
  610. "cleanup_action": ALERT_ONLY,
  611. "cleanup_rule_type": REVIEW_PARTIAL_RULE,
  612. "component_ids": [],
  613. "element_ids": [],
  614. "action_reason": reason,
  615. "recent_cost_fen": None,
  616. }
  617. if recent_cost_fen >= wechat_cost_threshold_fen:
  618. return {
  619. "cleanup_action": ALERT_ONLY,
  620. "cleanup_rule_type": REVIEW_PARTIAL_RULE,
  621. "component_ids": [],
  622. "element_ids": [],
  623. "action_reason": (
  624. "部分投放中且微信小程序元素审核拒绝,近3天历史消耗"
  625. f"{recent_cost_fen / 100:.2f}元不低于"
  626. f"{wechat_cost_threshold_fen / 100:.2f}元,需人工判断是否删除"
  627. ),
  628. "recent_cost_fen": recent_cost_fen,
  629. }
  630. return {
  631. "cleanup_action": DELETE_CREATIVE,
  632. "cleanup_rule_type": REVIEW_PARTIAL_RULE,
  633. "component_ids": [],
  634. "element_ids": [],
  635. "action_reason": (
  636. "部分投放中且微信小程序元素审核拒绝,近3天历史消耗"
  637. f"{recent_cost_fen / 100:.2f}元低于"
  638. f"{wechat_cost_threshold_fen / 100:.2f}元"
  639. ),
  640. "recent_cost_fen": recent_cost_fen,
  641. }
  642. if spend_error or recent_cost_fen is None:
  643. reason = "部分投放中,近3天历史消耗读取失败,需人工判断"
  644. if spend_error:
  645. reason = f"{reason}:{spend_error}"
  646. return {
  647. "cleanup_action": ALERT_ONLY,
  648. "cleanup_rule_type": REVIEW_PARTIAL_RULE,
  649. "component_ids": [],
  650. "element_ids": [],
  651. "action_reason": reason,
  652. "recent_cost_fen": None,
  653. }
  654. if recent_cost_fen < cost_threshold_fen:
  655. return {
  656. "cleanup_action": DELETE_CREATIVE,
  657. "cleanup_rule_type": REVIEW_PARTIAL_RULE,
  658. "component_ids": [],
  659. "element_ids": [],
  660. "action_reason": (
  661. f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元"
  662. f"低于{cost_threshold_fen / 100:.2f}元"
  663. ),
  664. "recent_cost_fen": recent_cost_fen,
  665. }
  666. return {
  667. "cleanup_action": ALERT_ONLY,
  668. "cleanup_rule_type": REVIEW_PARTIAL_RULE,
  669. "component_ids": [],
  670. "element_ids": [],
  671. "action_reason": (
  672. f"部分投放中且近3天历史消耗{recent_cost_fen / 100:.2f}元,"
  673. "需人工判断是否删除"
  674. ),
  675. "recent_cost_fen": recent_cost_fen,
  676. }
  677. return None
  678. def _as_int(value: Any) -> int | None:
  679. try:
  680. number = int(value)
  681. except (TypeError, ValueError):
  682. return None
  683. return number if number > 0 else None
  684. def _agency_name(value: Any) -> str:
  685. if value is None or pd.isna(value):
  686. return ""
  687. return "".join(str(value or "").split())
  688. def _env_flag(name: str, default: bool = False) -> bool:
  689. raw = os.getenv(name)
  690. if raw is None:
  691. return default
  692. return raw.strip().lower() in {"1", "true", "yes", "on"}
  693. def resolve_end_date(client, requested=None, *, now=None):
  694. from roi_control.data_source import resolve_end_date as resolve
  695. return resolve(client, requested, now=now)
  696. def date_window(end_date: str) -> tuple[str, str]:
  697. end = datetime.strptime(end_date, "%Y%m%d")
  698. return (end - timedelta(days=2)).strftime("%Y%m%d"), end_date
  699. def fetch_daily_data(client, start_date: str, end_date: str) -> pd.DataFrame:
  700. from roi_control.data_source import fetch_daily_data as fetch
  701. return fetch(client, start_date, end_date)
  702. def fetch_recent_spend_accounts(client, start_date: str, end_date: str) -> list[dict]:
  703. from roi_control.data_source import fetch_recent_spend_accounts as fetch
  704. return fetch(client, start_date, end_date)
  705. def fetch_account_agency_fallbacks(client, account_ids: list[int]) -> dict[int, str]:
  706. from roi_control.data_source import fetch_account_agency_fallbacks as fetch
  707. return fetch(client, account_ids)
  708. def fetch_active_creative_inventory(client) -> list[dict[str, Any]]:
  709. """从 ODPS 读取每个创意最新一行,并仅保留当前 is_delete=0 的记录。"""
  710. frame = client.execute_sql(
  711. """
  712. SELECT account_id, ad_id, creative_id, creative_name,
  713. creative_status, create_time, update_time
  714. FROM (
  715. SELECT id, account_id, ad_id, creative_id, creative_name,
  716. creative_status, is_delete, create_time, update_time,
  717. ROW_NUMBER() OVER (
  718. PARTITION BY account_id, creative_id
  719. ORDER BY COALESCE(update_time, create_time) DESC, id DESC
  720. ) AS rn
  721. FROM loghubods.ad_put_tencent_creative_day
  722. WHERE account_id IS NOT NULL
  723. AND creative_id IS NOT NULL
  724. ) latest
  725. WHERE rn=1 AND is_delete=0
  726. """
  727. )
  728. inventory: list[dict[str, Any]] = []
  729. for row in frame.to_dict("records"):
  730. account_id = _as_int(row.get("account_id"))
  731. adgroup_id = _as_int(row.get("ad_id"))
  732. creative_id = _as_int(row.get("creative_id"))
  733. created_at = _as_shanghai_datetime(row.get("create_time"))
  734. if account_id is None or creative_id is None:
  735. continue
  736. inventory.append(
  737. {
  738. "account_id": account_id,
  739. "adgroup_id": adgroup_id,
  740. "creative_id": creative_id,
  741. "creative_name": str(row.get("creative_name") or ""),
  742. "creative_status": str(row.get("creative_status") or ""),
  743. "create_time": created_at,
  744. "update_time": _as_shanghai_datetime(row.get("update_time")),
  745. }
  746. )
  747. return inventory
  748. def fetch_tencent_account_metadata(client) -> dict[int, dict[str, str]]:
  749. """从 ODPS 读取最新有效的账户名称和代理商昵称。"""
  750. frame = client.execute_sql(
  751. """
  752. SELECT account_id, account_name, agent_name
  753. FROM (
  754. SELECT id, account_id, account_name, agent_name, is_delete,
  755. create_time, update_time,
  756. ROW_NUMBER() OVER (
  757. PARTITION BY account_id
  758. ORDER BY COALESCE(update_time, create_time) DESC, id DESC
  759. ) AS rn
  760. FROM loghubods.ad_put_tencent_account
  761. WHERE account_id IS NOT NULL
  762. AND TRIM(account_id) <> ''
  763. ) latest
  764. WHERE rn=1 AND is_delete=0
  765. """
  766. )
  767. metadata: dict[int, dict[str, str]] = {}
  768. for row in frame.to_dict("records"):
  769. account_id = _as_int(row.get("account_id"))
  770. if account_id is None:
  771. continue
  772. account_name_value = row.get("account_name")
  773. agent_name_value = row.get("agent_name")
  774. account_name = (
  775. ""
  776. if account_name_value is None or pd.isna(account_name_value)
  777. else str(account_name_value).strip()
  778. )
  779. agent_name = (
  780. ""
  781. if agent_name_value is None or pd.isna(agent_name_value)
  782. else str(agent_name_value).strip()
  783. )
  784. metadata[account_id] = {
  785. "account_name": account_name,
  786. "agent_name": agent_name,
  787. }
  788. return metadata
  789. def prefetch_account_access_tokens(account_ids: list[int]) -> dict[int, str]:
  790. from tools.ad_api import prefetch_access_tokens
  791. return prefetch_access_tokens(account_ids)
  792. def build_agency_context(daily: pd.DataFrame) -> dict[str, dict]:
  793. """构建最新创意映射,以及不存在歧义的账户级兜底映射。"""
  794. creative_agency_sets: dict[tuple[int, int], set[str]] = defaultdict(set)
  795. creative_agency_dates: dict[tuple[int, int], str] = {}
  796. account_agencies: dict[int, set[str]] = defaultdict(set)
  797. account_agency_dates: dict[int, str] = {}
  798. account_names: dict[int, str] = {}
  799. account_name_dates: dict[int, str] = {}
  800. if daily.empty:
  801. return {
  802. "creative_agencies": {},
  803. "account_agencies": {},
  804. "fallback_account_agencies": {},
  805. "account_names": account_names,
  806. }
  807. miniapp = daily[daily["entity_type"].eq("self")]
  808. for _, row in miniapp.iterrows():
  809. account_id = _as_int(row.get("账号id"))
  810. creative_id = _as_int(row.get("创意id"))
  811. agency = _agency_name(row.get("代理名称"))
  812. row_date = str(row.get("dt") or "")
  813. if account_id is None:
  814. continue
  815. account_name = str(row.get("账号名称") or "").strip()
  816. if account_name and row_date >= account_name_dates.get(account_id, ""):
  817. account_names[account_id] = account_name
  818. account_name_dates[account_id] = row_date
  819. if agency:
  820. account_date = account_agency_dates.get(account_id, "")
  821. if row_date > account_date:
  822. account_agencies[account_id] = {agency}
  823. account_agency_dates[account_id] = row_date
  824. elif row_date == account_date:
  825. account_agencies[account_id].add(agency)
  826. if creative_id is not None:
  827. key = (account_id, creative_id)
  828. current_date = creative_agency_dates.get(key, "")
  829. if row_date > current_date:
  830. creative_agency_sets[key] = {agency}
  831. creative_agency_dates[key] = row_date
  832. elif row_date == current_date:
  833. creative_agency_sets[key].add(agency)
  834. return {
  835. "creative_agencies": {
  836. key: next(iter(agencies))
  837. for key, agencies in creative_agency_sets.items()
  838. if len(agencies) == 1
  839. },
  840. "account_agencies": {
  841. account_id: next(iter(agencies))
  842. for account_id, agencies in account_agencies.items()
  843. if len(agencies) == 1
  844. },
  845. "fallback_account_agencies": {},
  846. "account_names": account_names,
  847. }
  848. def _resolve_agency(
  849. context: dict[str, dict],
  850. account_id: int,
  851. creative_id: int,
  852. ) -> str:
  853. return str(
  854. context["creative_agencies"].get((account_id, creative_id))
  855. or context["account_agencies"].get(account_id)
  856. or context.get("fallback_account_agencies", {}).get(account_id)
  857. or ""
  858. )
  859. def upsert_cleanup_candidate(
  860. record: dict[str, Any],
  861. *,
  862. _connection=None,
  863. _fetch_result: bool = True,
  864. ) -> dict[str, Any] | None:
  865. """持久化一个清理候选。
  866. 外部调用默认每条记录使用独立连接;内部批量写入器复用同一事务连接并跳过
  867. 最后的 SELECT,在保持相同幂等 SQL 和终态保护的前提下降低连接与查询开销。
  868. """
  869. component_ids_json = _json(record.get("component_ids") or [])
  870. element_ids_json = _json(record.get("element_ids") or [])
  871. review_result_json = _json(record.get("review_result") or {})
  872. pre_state_json = _json(record.get("pre_state") or {})
  873. cleanup_status = (
  874. "ALERT_PENDING"
  875. if record["cleanup_action"] == ALERT_ONLY
  876. else "DISCOVERED"
  877. )
  878. cleanup_rule_type = (
  879. record.get("cleanup_rule_type") or REVIEW_DENIED_RULE
  880. )
  881. performance_suppression_at = (
  882. datetime.now(SHANGHAI).replace(tzinfo=None)
  883. if _is_performance_rule(cleanup_rule_type)
  884. else None
  885. )
  886. insert_values = (
  887. record["account_id"],
  888. record.get("account_name"),
  889. record.get("agent_name"),
  890. "" if performance_suppression_at else record.get("agency_name"),
  891. record["adgroup_id"],
  892. record.get("adgroup_name"),
  893. record["dynamic_creative_id"],
  894. record.get("dynamic_creative_name"),
  895. record["check_date"],
  896. record["cleanup_action"],
  897. cleanup_rule_type,
  898. component_ids_json,
  899. element_ids_json,
  900. record.get("recent_cost_fen"),
  901. record.get("cost_start_date"),
  902. record.get("cost_end_date"),
  903. record.get("action_reason") or record["reject_reason"],
  904. record["reject_reason"],
  905. review_result_json,
  906. pre_state_json,
  907. cleanup_status,
  908. performance_suppression_at,
  909. )
  910. owns_connection = _connection is None
  911. connection = _connection or get_connection()
  912. try:
  913. with connection.cursor() as cursor:
  914. cursor.execute(
  915. """
  916. INSERT INTO creative_rejection_cleanup_item
  917. (account_id, account_name, agent_name, agency_name, adgroup_id,
  918. adgroup_name, dynamic_creative_id, dynamic_creative_name,
  919. check_date,
  920. cleanup_action, cleanup_rule_type,
  921. target_component_ids_json,
  922. target_element_ids_json, recent_cost_fen,
  923. cost_start_date, cost_end_date, action_reason, reject_reason,
  924. review_result_json, pre_state_json, cleanup_status,
  925. agency_notified_at)
  926. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  927. ON DUPLICATE KEY UPDATE
  928. id=LAST_INSERT_ID(id),
  929. agency_name=CASE
  930. WHEN cleanup_status IN (
  931. 'DELETING','CREATIVE_DELETED','AD_DELETED'
  932. ) THEN agency_name
  933. WHEN LEFT(VALUES(cleanup_rule_type), 12)='PERFORMANCE_'
  934. THEN '' ELSE agency_name
  935. END,
  936. agency_notified_at=CASE
  937. WHEN cleanup_status IN (
  938. 'DELETING','CREATIVE_DELETED','AD_DELETED'
  939. ) THEN agency_notified_at
  940. WHEN LEFT(VALUES(cleanup_rule_type), 12)='PERFORMANCE_'
  941. THEN COALESCE(
  942. agency_notified_at,
  943. VALUES(agency_notified_at),
  944. NOW()
  945. )
  946. ELSE agency_notified_at
  947. END
  948. """,
  949. insert_values,
  950. )
  951. cursor.execute(
  952. """
  953. UPDATE creative_rejection_cleanup_item AS item
  954. JOIN (
  955. SELECT
  956. %s AS account_id, %s AS account_name, %s AS agent_name,
  957. %s AS agency_name, %s AS adgroup_id, %s AS adgroup_name,
  958. %s AS dynamic_creative_id, %s AS dynamic_creative_name,
  959. %s AS check_date, %s AS cleanup_action,
  960. %s AS cleanup_rule_type,
  961. %s AS target_component_ids_json,
  962. %s AS target_element_ids_json, %s AS recent_cost_fen,
  963. %s AS cost_start_date, %s AS cost_end_date,
  964. %s AS action_reason, %s AS reject_reason,
  965. %s AS review_result_json, %s AS pre_state_json,
  966. %s AS cleanup_status,
  967. %s AS performance_suppression_at
  968. ) AS incoming
  969. ON incoming.account_id=item.account_id
  970. AND incoming.dynamic_creative_id=item.dynamic_creative_id
  971. AND incoming.check_date=item.check_date
  972. SET
  973. item.agency_name=CASE
  974. WHEN LEFT(incoming.cleanup_rule_type, 12)='PERFORMANCE_'
  975. THEN ''
  976. ELSE COALESCE(
  977. NULLIF(incoming.agency_name,''), item.agency_name
  978. )
  979. END,
  980. item.adgroup_id=incoming.adgroup_id,
  981. item.adgroup_name=COALESCE(
  982. NULLIF(incoming.adgroup_name,''), item.adgroup_name
  983. ),
  984. item.dynamic_creative_name=COALESCE(
  985. NULLIF(incoming.dynamic_creative_name,''),
  986. item.dynamic_creative_name
  987. ),
  988. item.action_reason=incoming.action_reason,
  989. item.reject_reason=incoming.reject_reason,
  990. item.review_result_json=incoming.review_result_json,
  991. item.pre_state_json=incoming.pre_state_json,
  992. notified_at=CASE
  993. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  994. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  995. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  996. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  997. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  998. THEN NULL ELSE item.notified_at
  999. END,
  1000. item.agency_notified_at=CASE
  1001. WHEN LEFT(incoming.cleanup_rule_type, 12)='PERFORMANCE_'
  1002. THEN COALESCE(
  1003. item.agency_notified_at,
  1004. incoming.performance_suppression_at,
  1005. NOW()
  1006. )
  1007. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  1008. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  1009. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  1010. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  1011. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  1012. THEN NULL ELSE item.agency_notified_at
  1013. END,
  1014. item.operator_notified_at=CASE
  1015. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  1016. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  1017. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  1018. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  1019. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  1020. THEN NULL ELSE item.operator_notified_at
  1021. END,
  1022. item.deleted_at=CASE
  1023. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  1024. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  1025. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  1026. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  1027. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  1028. THEN NULL ELSE item.deleted_at
  1029. END,
  1030. item.readback_json=CASE
  1031. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  1032. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  1033. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  1034. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  1035. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  1036. THEN NULL ELSE item.readback_json
  1037. END,
  1038. item.cleanup_status=CASE
  1039. WHEN NOT (item.cleanup_action <=> incoming.cleanup_action)
  1040. OR NOT (item.target_component_ids_json <=> incoming.target_component_ids_json)
  1041. OR NOT (item.target_element_ids_json <=> incoming.target_element_ids_json)
  1042. OR NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)
  1043. OR NOT (item.cost_end_date <=> incoming.cost_end_date)
  1044. THEN incoming.cleanup_status
  1045. WHEN item.cleanup_status='SKIPPED_REVIEW_NOT_RECONFIRMED'
  1046. THEN 'DISCOVERED'
  1047. ELSE item.cleanup_status
  1048. END,
  1049. item.target_component_ids_json=incoming.target_component_ids_json,
  1050. item.target_element_ids_json=incoming.target_element_ids_json,
  1051. item.recent_cost_fen=incoming.recent_cost_fen,
  1052. item.cost_start_date=incoming.cost_start_date,
  1053. item.cost_end_date=incoming.cost_end_date,
  1054. item.cleanup_action=incoming.cleanup_action
  1055. WHERE item.cleanup_status NOT IN (
  1056. 'DELETING','CREATIVE_DELETED','AD_DELETED'
  1057. )
  1058. """,
  1059. insert_values,
  1060. )
  1061. extended_values = (
  1062. cleanup_rule_type,
  1063. record.get("account_name"),
  1064. record.get("agent_name"),
  1065. record.get("creative_created_at"),
  1066. record.get("creative_age_days"),
  1067. record.get("metric_impressions"),
  1068. record.get("metric_daily_avg_impressions"),
  1069. record.get("metric_window_days"),
  1070. cleanup_status,
  1071. record["account_id"],
  1072. record["dynamic_creative_id"],
  1073. record["check_date"],
  1074. performance_suppression_at,
  1075. )
  1076. cursor.execute(
  1077. """
  1078. UPDATE creative_rejection_cleanup_item AS item
  1079. JOIN (
  1080. SELECT %s AS cleanup_rule_type,
  1081. %s AS account_name,
  1082. %s AS agent_name,
  1083. %s AS creative_created_at,
  1084. %s AS creative_age_days,
  1085. %s AS metric_impressions,
  1086. %s AS metric_daily_avg_impressions,
  1087. %s AS metric_window_days,
  1088. %s AS cleanup_status,
  1089. %s AS account_id,
  1090. %s AS dynamic_creative_id,
  1091. %s AS check_date,
  1092. %s AS performance_suppression_at
  1093. ) AS incoming
  1094. ON incoming.account_id=item.account_id
  1095. AND incoming.dynamic_creative_id=item.dynamic_creative_id
  1096. AND incoming.check_date=item.check_date
  1097. SET item.notified_at=CASE
  1098. WHEN NOT (item.cleanup_rule_type <=> incoming.cleanup_rule_type)
  1099. OR NOT (item.account_name <=> COALESCE(NULLIF(incoming.account_name,''), item.account_name))
  1100. OR NOT (item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name))
  1101. OR NOT (item.creative_created_at <=> incoming.creative_created_at)
  1102. OR NOT (item.creative_age_days <=> incoming.creative_age_days)
  1103. OR NOT (item.metric_impressions <=> incoming.metric_impressions)
  1104. OR NOT (item.metric_daily_avg_impressions <=> incoming.metric_daily_avg_impressions)
  1105. THEN NULL ELSE item.notified_at
  1106. END,
  1107. item.agency_notified_at=CASE
  1108. WHEN LEFT(incoming.cleanup_rule_type, 12)='PERFORMANCE_'
  1109. THEN COALESCE(
  1110. item.agency_notified_at,
  1111. incoming.performance_suppression_at,
  1112. NOW()
  1113. )
  1114. WHEN NOT (item.cleanup_rule_type <=> incoming.cleanup_rule_type)
  1115. OR NOT (item.account_name <=> COALESCE(NULLIF(incoming.account_name,''), item.account_name))
  1116. OR NOT (item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name))
  1117. OR NOT (item.creative_created_at <=> incoming.creative_created_at)
  1118. OR NOT (item.creative_age_days <=> incoming.creative_age_days)
  1119. OR NOT (item.metric_impressions <=> incoming.metric_impressions)
  1120. OR NOT (item.metric_daily_avg_impressions <=> incoming.metric_daily_avg_impressions)
  1121. THEN NULL ELSE item.agency_notified_at
  1122. END,
  1123. item.operator_notified_at=CASE
  1124. WHEN NOT (item.cleanup_rule_type <=> incoming.cleanup_rule_type)
  1125. OR NOT (item.account_name <=> COALESCE(NULLIF(incoming.account_name,''), item.account_name))
  1126. OR NOT (item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name))
  1127. OR NOT (item.creative_created_at <=> incoming.creative_created_at)
  1128. OR NOT (item.creative_age_days <=> incoming.creative_age_days)
  1129. OR NOT (item.metric_impressions <=> incoming.metric_impressions)
  1130. OR NOT (item.metric_daily_avg_impressions <=> incoming.metric_daily_avg_impressions)
  1131. THEN NULL ELSE item.operator_notified_at
  1132. END,
  1133. item.cleanup_status=CASE
  1134. WHEN NOT (item.cleanup_rule_type <=> incoming.cleanup_rule_type)
  1135. OR NOT (item.account_name <=> COALESCE(NULLIF(incoming.account_name,''), item.account_name))
  1136. OR NOT (item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name))
  1137. OR NOT (item.creative_created_at <=> incoming.creative_created_at)
  1138. OR NOT (item.creative_age_days <=> incoming.creative_age_days)
  1139. OR NOT (item.metric_impressions <=> incoming.metric_impressions)
  1140. OR NOT (item.metric_daily_avg_impressions <=> incoming.metric_daily_avg_impressions)
  1141. THEN incoming.cleanup_status
  1142. ELSE item.cleanup_status
  1143. END,
  1144. item.cleanup_rule_type=incoming.cleanup_rule_type,
  1145. item.account_name=COALESCE(
  1146. NULLIF(incoming.account_name,''), item.account_name
  1147. ),
  1148. item.agent_name=COALESCE(
  1149. NULLIF(incoming.agent_name,''), item.agent_name
  1150. ),
  1151. item.creative_created_at=incoming.creative_created_at,
  1152. item.creative_age_days=incoming.creative_age_days,
  1153. item.metric_impressions=incoming.metric_impressions,
  1154. item.metric_daily_avg_impressions=incoming.metric_daily_avg_impressions,
  1155. item.metric_window_days=incoming.metric_window_days
  1156. WHERE item.cleanup_status NOT IN (
  1157. 'DELETING','CREATIVE_DELETED','AD_DELETED'
  1158. )
  1159. """,
  1160. extended_values,
  1161. )
  1162. if _fetch_result:
  1163. cursor.execute(
  1164. """
  1165. SELECT * FROM creative_rejection_cleanup_item
  1166. WHERE account_id=%s AND dynamic_creative_id=%s AND check_date=%s
  1167. """,
  1168. (
  1169. record["account_id"],
  1170. record["dynamic_creative_id"],
  1171. record["check_date"],
  1172. ),
  1173. )
  1174. return cursor.fetchone()
  1175. return None
  1176. finally:
  1177. if owns_connection:
  1178. connection.close()
  1179. def _positive_int_setting(name: str, default: int, *, maximum: int) -> int:
  1180. try:
  1181. value = int(os.getenv(name, str(default)))
  1182. except (TypeError, ValueError) as exc:
  1183. raise ValueError(f"{name} must be an integer") from exc
  1184. if value < 1 or value > maximum:
  1185. raise ValueError(f"{name} must be between 1 and {maximum}")
  1186. return value
  1187. def upsert_cleanup_candidates(
  1188. records: list[dict[str, Any]],
  1189. ) -> tuple[list[dict[str, Any]], list[tuple[dict[str, Any], str]]]:
  1190. """按事务分块并发保存候选。
  1191. 每个分块复用一个 MySQL 连接并省略逐行结果读取。分块失败时先回滚,再通过
  1192. 既有幂等路径逐条重试,避免单条坏数据遮蔽同批次的其他有效候选。
  1193. """
  1194. if not records:
  1195. return [], []
  1196. # 同一幂等键保留最后一个值并维持确定顺序,避免重复键让并发分块互相争锁。
  1197. unique_by_key: dict[tuple[int, int, str], dict[str, Any]] = {}
  1198. for record in records:
  1199. key = (
  1200. int(record["account_id"]),
  1201. int(record["dynamic_creative_id"]),
  1202. str(record["check_date"]),
  1203. )
  1204. unique_by_key[key] = record
  1205. unique_records = list(unique_by_key.values())
  1206. batch_size = _positive_int_setting(
  1207. "DAILY_CLEANUP_CANDIDATE_BATCH_SIZE",
  1208. 100,
  1209. maximum=1000,
  1210. )
  1211. configured_workers = _positive_int_setting(
  1212. "DAILY_CLEANUP_CANDIDATE_STORE_WORKERS",
  1213. 4,
  1214. maximum=16,
  1215. )
  1216. chunks = [
  1217. unique_records[index : index + batch_size]
  1218. for index in range(0, len(unique_records), batch_size)
  1219. ]
  1220. workers = min(configured_workers, len(chunks))
  1221. def store_chunk(
  1222. chunk: list[dict[str, Any]],
  1223. ) -> tuple[list[dict[str, Any]], list[tuple[dict[str, Any], str]]]:
  1224. connection = None
  1225. try:
  1226. connection = get_connection()
  1227. begin = getattr(connection, "begin", None)
  1228. if callable(begin):
  1229. begin()
  1230. for record in chunk:
  1231. upsert_cleanup_candidate(
  1232. record,
  1233. _connection=connection,
  1234. _fetch_result=False,
  1235. )
  1236. commit = getattr(connection, "commit", None)
  1237. if callable(commit):
  1238. commit()
  1239. return chunk, []
  1240. except Exception as batch_exc:
  1241. rollback = getattr(connection, "rollback", None)
  1242. if callable(rollback):
  1243. try:
  1244. rollback()
  1245. except Exception:
  1246. logger.exception("cleanup candidate batch rollback failed")
  1247. logger.warning(
  1248. "cleanup candidate batch failed; retrying individually "
  1249. "size=%d error=%s",
  1250. len(chunk),
  1251. batch_exc,
  1252. )
  1253. finally:
  1254. if connection is not None:
  1255. connection.close()
  1256. successful: list[dict[str, Any]] = []
  1257. errors: list[tuple[dict[str, Any], str]] = []
  1258. for record in chunk:
  1259. try:
  1260. upsert_cleanup_candidate(record)
  1261. except Exception as exc:
  1262. errors.append((record, str(exc)))
  1263. else:
  1264. successful.append(record)
  1265. return successful, errors
  1266. successful_records: list[dict[str, Any]] = []
  1267. failed_records: list[tuple[dict[str, Any], str]] = []
  1268. with ThreadPoolExecutor(
  1269. max_workers=workers,
  1270. thread_name_prefix="cleanup-candidate-store",
  1271. ) as executor:
  1272. futures = [executor.submit(store_chunk, chunk) for chunk in chunks]
  1273. for future in as_completed(futures):
  1274. stored, errors = future.result()
  1275. successful_records.extend(stored)
  1276. failed_records.extend(errors)
  1277. return successful_records, failed_records
  1278. def load_retryable_cleanup_items() -> list[dict[str, Any]]:
  1279. stale_minutes = int(
  1280. os.getenv(
  1281. "DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES",
  1282. str(DEFAULT_DELETE_CLAIM_STALE_MINUTES),
  1283. )
  1284. )
  1285. if stale_minutes <= 0:
  1286. raise ValueError(
  1287. "DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES must be positive"
  1288. )
  1289. connection = get_connection()
  1290. try:
  1291. with connection.cursor() as cursor:
  1292. cursor.execute(
  1293. """
  1294. SELECT item.*
  1295. FROM creative_rejection_cleanup_item item
  1296. JOIN (
  1297. SELECT account_id, dynamic_creative_id, MAX(check_date) AS check_date
  1298. FROM creative_rejection_cleanup_item
  1299. GROUP BY account_id, dynamic_creative_id
  1300. ) latest
  1301. ON latest.account_id=item.account_id
  1302. AND latest.dynamic_creative_id=item.dynamic_creative_id
  1303. AND latest.check_date=item.check_date
  1304. WHERE (
  1305. item.cleanup_status IN
  1306. ('DISCOVERED','DEFERRED','FAILED','WRITE_OUTCOME_UNKNOWN')
  1307. OR (
  1308. item.cleanup_status='DELETING'
  1309. AND item.updated_at < DATE_SUB(NOW(), INTERVAL %s MINUTE)
  1310. )
  1311. )
  1312. AND item.cleanup_action IN ('DELETE_CREATIVE','DELETE_AD')
  1313. ORDER BY item.id
  1314. """,
  1315. (stale_minutes,),
  1316. )
  1317. return list(cursor.fetchall())
  1318. finally:
  1319. connection.close()
  1320. def claim_cleanup_item(item_id: int) -> bool:
  1321. """为本轮执行原子认领一条可重试删除记录。"""
  1322. stale_minutes = int(
  1323. os.getenv(
  1324. "DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES",
  1325. str(DEFAULT_DELETE_CLAIM_STALE_MINUTES),
  1326. )
  1327. )
  1328. if stale_minutes <= 0:
  1329. raise ValueError(
  1330. "DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES must be positive"
  1331. )
  1332. connection = get_connection()
  1333. try:
  1334. with connection.cursor() as cursor:
  1335. cursor.execute(
  1336. """
  1337. UPDATE creative_rejection_cleanup_item
  1338. SET cleanup_status='DELETING', error_message=NULL, updated_at=NOW()
  1339. WHERE id=%s
  1340. AND cleanup_action IN ('DELETE_CREATIVE','DELETE_AD')
  1341. AND (
  1342. cleanup_status IN
  1343. ('DISCOVERED','DEFERRED','FAILED','WRITE_OUTCOME_UNKNOWN')
  1344. OR (
  1345. cleanup_status='DELETING'
  1346. AND updated_at < DATE_SUB(NOW(), INTERVAL %s MINUTE)
  1347. )
  1348. )
  1349. """,
  1350. (item_id, stale_minutes),
  1351. )
  1352. return cursor.rowcount == 1
  1353. finally:
  1354. connection.close()
  1355. def update_cleanup_item(item_id: int, **values: Any) -> bool:
  1356. expected_cleanup_status = values.pop("_expected_cleanup_status", None)
  1357. allowed = {
  1358. "agency_name",
  1359. "agent_name",
  1360. "cleanup_action",
  1361. "cleanup_rule_type",
  1362. "target_component_ids_json",
  1363. "target_element_ids_json",
  1364. "recent_cost_fen",
  1365. "cost_start_date",
  1366. "cost_end_date",
  1367. "action_reason",
  1368. "reject_reason",
  1369. "creative_created_at",
  1370. "creative_age_days",
  1371. "metric_impressions",
  1372. "metric_daily_avg_impressions",
  1373. "metric_window_days",
  1374. "review_result_json",
  1375. "cleanup_status",
  1376. "error_message",
  1377. "pre_state_json",
  1378. "readback_json",
  1379. "deleted_at",
  1380. "agency_notified_at",
  1381. "operator_notified_at",
  1382. "notified_at",
  1383. }
  1384. unknown = set(values) - allowed
  1385. if unknown:
  1386. raise ValueError(f"Unsupported cleanup fields: {sorted(unknown)}")
  1387. if not values:
  1388. return False
  1389. assignments = ", ".join(f"{name}=%s" for name in values)
  1390. connection = get_connection()
  1391. try:
  1392. with connection.cursor() as cursor:
  1393. where = "WHERE id=%s"
  1394. params = [*values.values(), item_id]
  1395. if expected_cleanup_status is not None:
  1396. if isinstance(expected_cleanup_status, (tuple, list, set, frozenset)):
  1397. statuses = list(expected_cleanup_status)
  1398. if not statuses:
  1399. return False
  1400. placeholders = ",".join(["%s"] * len(statuses))
  1401. where += f" AND cleanup_status IN ({placeholders})"
  1402. params.extend(statuses)
  1403. else:
  1404. where += " AND cleanup_status=%s"
  1405. params.append(expected_cleanup_status)
  1406. cursor.execute(
  1407. f"UPDATE creative_rejection_cleanup_item SET {assignments} {where}",
  1408. params,
  1409. )
  1410. return cursor.rowcount > 0
  1411. finally:
  1412. connection.close()
  1413. def load_pending_notification_items(
  1414. *,
  1415. include_discovered: bool = False,
  1416. check_date: date | None = None,
  1417. performance_only: bool = False,
  1418. include_notified: bool = False,
  1419. ) -> list[dict[str, Any]]:
  1420. connection = get_connection()
  1421. try:
  1422. with connection.cursor() as cursor:
  1423. statuses = "'CREATIVE_DELETED','AD_DELETED','ALERT_PENDING'"
  1424. if include_discovered:
  1425. statuses += ",'DISCOVERED'"
  1426. extra_where = ""
  1427. params: list[Any] = []
  1428. if check_date is not None:
  1429. extra_where += " AND check_date=%s"
  1430. params.append(check_date)
  1431. if performance_only:
  1432. extra_where += (
  1433. " AND LEFT(cleanup_rule_type, 12)='PERFORMANCE_'"
  1434. )
  1435. notification_where = ""
  1436. if not include_notified:
  1437. notification_where = """
  1438. AND (
  1439. operator_notified_at IS NULL
  1440. OR (
  1441. LEFT(cleanup_rule_type, 12) <> 'PERFORMANCE_'
  1442. AND agency_notified_at IS NULL
  1443. )
  1444. )
  1445. """
  1446. cursor.execute(
  1447. f"""
  1448. SELECT * FROM creative_rejection_cleanup_item
  1449. WHERE cleanup_status IN ({statuses})
  1450. {notification_where}
  1451. {extra_where}
  1452. ORDER BY check_date, agency_name, account_id, adgroup_id,
  1453. dynamic_creative_id
  1454. """,
  1455. params,
  1456. )
  1457. return list(cursor.fetchall())
  1458. finally:
  1459. connection.close()
  1460. def load_unnotified_deleted_items(
  1461. *,
  1462. include_discovered: bool = False,
  1463. check_date: date | None = None,
  1464. performance_only: bool = False,
  1465. include_notified: bool = False,
  1466. ) -> list[dict[str, Any]]:
  1467. """待发送清理通知的兼容入口。"""
  1468. return load_pending_notification_items(
  1469. include_discovered=include_discovered,
  1470. check_date=check_date,
  1471. performance_only=performance_only,
  1472. include_notified=include_notified,
  1473. )
  1474. def _mark_cleanup_channel_notified(
  1475. item_ids: list[int],
  1476. notified_at: datetime,
  1477. *,
  1478. channel: str,
  1479. ) -> None:
  1480. if not item_ids:
  1481. return
  1482. placeholders = ",".join(["%s"] * len(item_ids))
  1483. if channel not in {"agency", "operator"}:
  1484. raise ValueError(f"Unsupported cleanup notification channel: {channel}")
  1485. channel_column = f"{channel}_notified_at"
  1486. other_column = (
  1487. "operator_notified_at" if channel == "agency" else "agency_notified_at"
  1488. )
  1489. connection = get_connection()
  1490. try:
  1491. with connection.cursor() as cursor:
  1492. cursor.execute(
  1493. f"""
  1494. UPDATE creative_rejection_cleanup_item
  1495. SET {channel_column}=%s,
  1496. notified_at=CASE
  1497. WHEN {other_column} IS NOT NULL THEN %s
  1498. ELSE NULL
  1499. END
  1500. WHERE id IN ({placeholders}) AND {channel_column} IS NULL
  1501. """,
  1502. [notified_at, notified_at, *item_ids],
  1503. )
  1504. finally:
  1505. connection.close()
  1506. def mark_cleanup_items_agency_notified(
  1507. item_ids: list[int], notified_at: datetime
  1508. ) -> None:
  1509. _mark_cleanup_channel_notified(item_ids, notified_at, channel="agency")
  1510. def mark_cleanup_items_operator_notified(
  1511. item_ids: list[int], notified_at: datetime
  1512. ) -> None:
  1513. _mark_cleanup_channel_notified(item_ids, notified_at, channel="operator")
  1514. def mark_cleanup_items_notified(
  1515. item_ids: list[int], notified_at: datetime
  1516. ) -> None:
  1517. """代理商通知渠道的兼容入口。"""
  1518. mark_cleanup_items_agency_notified(item_ids, notified_at)
  1519. def _update_owned_cleanup_item(item_id: int, **values: Any) -> bool:
  1520. """仅当本轮仍持有 DELETING 认领状态时更新记录。"""
  1521. return update_cleanup_item(
  1522. item_id,
  1523. _expected_cleanup_status="DELETING",
  1524. **values,
  1525. )
  1526. def upsert_cleanup_delivery(record: dict[str, Any]) -> dict[str, Any]:
  1527. connection = get_connection()
  1528. try:
  1529. with connection.cursor() as cursor:
  1530. cursor.execute(
  1531. """
  1532. INSERT INTO creative_rejection_delivery
  1533. (run_id, agency_name, agency_report_version, file_path,
  1534. file_sha256, creative_rows, ad_rows,
  1535. route_fingerprint, status)
  1536. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'PENDING')
  1537. ON DUPLICATE KEY UPDATE
  1538. status=CASE
  1539. WHEN status='SENT' THEN status
  1540. WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
  1541. THEN 'PENDING'
  1542. ELSE status
  1543. END,
  1544. error_message=CASE
  1545. WHEN NOT (route_fingerprint <=> VALUES(route_fingerprint))
  1546. THEN NULL ELSE error_message
  1547. END,
  1548. file_path=IF(status='SENT', file_path, VALUES(file_path)),
  1549. file_sha256=IF(status='SENT', file_sha256, VALUES(file_sha256)),
  1550. creative_rows=VALUES(creative_rows),
  1551. ad_rows=VALUES(ad_rows),
  1552. route_fingerprint=VALUES(route_fingerprint)
  1553. """,
  1554. (
  1555. record["run_id"],
  1556. record["agency_name"],
  1557. record["agency_report_version"],
  1558. record["file_path"],
  1559. record["file_sha256"],
  1560. record["creative_rows"],
  1561. record["ad_rows"],
  1562. record.get("route_fingerprint"),
  1563. ),
  1564. )
  1565. cursor.execute(
  1566. """
  1567. SELECT * FROM creative_rejection_delivery
  1568. WHERE run_id=%s AND agency_name=%s AND agency_report_version=%s
  1569. """,
  1570. (
  1571. record["run_id"],
  1572. record["agency_name"],
  1573. record["agency_report_version"],
  1574. ),
  1575. )
  1576. return cursor.fetchone()
  1577. finally:
  1578. connection.close()
  1579. def update_cleanup_delivery(delivery_id: int, **values: Any) -> None:
  1580. allowed = {
  1581. "status",
  1582. "sheet_token",
  1583. "sheet_url",
  1584. "response_code",
  1585. "error_message",
  1586. "sent_at",
  1587. }
  1588. increment_attempt = bool(values.pop("increment_attempt", False))
  1589. unknown = set(values) - allowed
  1590. if unknown:
  1591. raise ValueError(f"Unsupported delivery fields: {sorted(unknown)}")
  1592. assignments = [f"{name}=%s" for name in values]
  1593. params = list(values.values())
  1594. if increment_attempt:
  1595. assignments.append("attempt_count=attempt_count+1")
  1596. if not assignments:
  1597. return
  1598. connection = get_connection()
  1599. try:
  1600. with connection.cursor() as cursor:
  1601. cursor.execute(
  1602. f"UPDATE creative_rejection_delivery SET {', '.join(assignments)} WHERE id=%s",
  1603. [*params, delivery_id],
  1604. )
  1605. finally:
  1606. connection.close()
  1607. def _file_sha256(path: Path) -> str:
  1608. digest = hashlib.sha256()
  1609. with path.open("rb") as handle:
  1610. for chunk in iter(lambda: handle.read(1024 * 1024), b""):
  1611. digest.update(chunk)
  1612. return digest.hexdigest()
  1613. def publish_cleanup_operator_summary(
  1614. *,
  1615. run_id: str,
  1616. report: dict[str, object],
  1617. chat_id: str,
  1618. publisher: RoiFeishuPublisher,
  1619. now: datetime,
  1620. ) -> dict[str, object]:
  1621. """幂等上传全代理汇总报表并发送到内部运营群。"""
  1622. path = Path(str(report["report"]))
  1623. delivery_id: int | None = None
  1624. is_performance_report = (
  1625. report.get("notification_kind") == "performance_internal"
  1626. )
  1627. route_name = (
  1628. PERFORMANCE_SUMMARY_ROUTE
  1629. if is_performance_report
  1630. else OPERATOR_SUMMARY_ROUTE
  1631. )
  1632. try:
  1633. if not path.is_file():
  1634. raise FileNotFoundError(path)
  1635. target_chat_id = str(chat_id or "").strip()
  1636. if not target_chat_id:
  1637. raise RuntimeError("FEISHU_AD_PROJECT_CHAT_ID 未配置")
  1638. delivery = upsert_cleanup_delivery(
  1639. {
  1640. "run_id": run_id,
  1641. "agency_name": route_name,
  1642. "agency_report_version": str(report["report_version"]),
  1643. "file_path": str(path),
  1644. "file_sha256": _file_sha256(path),
  1645. "creative_rows": int(report.get("creative_rows") or 0),
  1646. "ad_rows": int(report.get("ad_rows") or 0),
  1647. "route_fingerprint": hashlib.sha256(
  1648. target_chat_id.encode("utf-8")
  1649. ).hexdigest(),
  1650. }
  1651. )
  1652. delivery_id = int(delivery["id"])
  1653. if delivery.get("status") == "SENT":
  1654. return {
  1655. "route": route_name,
  1656. "status": "SENT",
  1657. "sheet_url": str(delivery.get("sheet_url") or ""),
  1658. "reused": True,
  1659. }
  1660. sheet_url = str(delivery.get("sheet_url") or "")
  1661. sheet_token = str(delivery.get("sheet_token") or "")
  1662. if not sheet_url or not sheet_token:
  1663. imported = publisher.upload_workbook(path)
  1664. sheet_url = imported["url"]
  1665. sheet_token = imported["sheet_token"]
  1666. update_cleanup_delivery(
  1667. delivery_id,
  1668. status="UPLOADED",
  1669. sheet_token=sheet_token,
  1670. sheet_url=sheet_url,
  1671. )
  1672. entity_count_text = (
  1673. f"**{int(report.get('creative_rows') or 0)}** 条创意、"
  1674. f"**{int(report.get('ad_rows') or 0)}** 条广告"
  1675. if is_performance_report
  1676. else f"**{int(report.get('creative_rows') or 0)}** 条创意"
  1677. )
  1678. message_id = publisher.send_report_card(
  1679. title=str(report.get("title") or path.stem),
  1680. content=(
  1681. (
  1682. f"本次预演共 {entity_count_text},"
  1683. + (
  1684. "未执行任何删除;本消息仅发送内部群,不发送代理商群。"
  1685. if is_performance_report
  1686. else "未执行任何删除,包含建议删除项及需人工判断项。"
  1687. )
  1688. )
  1689. if report.get("dry_run")
  1690. else (
  1691. f"本批次共 {entity_count_text},"
  1692. f"其中 **{int(report.get('unexecuted_rows') or 0)}** 条仅建议、未执行;"
  1693. + (
  1694. "本消息仅发送内部群,不发送代理商群。"
  1695. if is_performance_report
  1696. else "其余为已执行或需人工判断项。"
  1697. )
  1698. )
  1699. if report.get("unexecuted_rows")
  1700. else (
  1701. f"本批次共 {entity_count_text},"
  1702. + (
  1703. "为长期未起量清理结果;本消息仅发送内部群,不发送代理商群。"
  1704. if is_performance_report
  1705. else "包含各代理自动删除及需人工判断的完整汇总。"
  1706. )
  1707. )
  1708. ),
  1709. sheet_url=sheet_url,
  1710. chat_id=target_chat_id,
  1711. button_text="查看全部处理明细",
  1712. )
  1713. update_cleanup_delivery(
  1714. delivery_id,
  1715. status="SENT",
  1716. response_code=message_id,
  1717. sent_at=now,
  1718. increment_attempt=True,
  1719. )
  1720. return {
  1721. "route": route_name,
  1722. "status": "SENT",
  1723. "sheet_url": sheet_url,
  1724. "reused": False,
  1725. }
  1726. except Exception as exc:
  1727. if delivery_id is not None:
  1728. try:
  1729. update_cleanup_delivery(
  1730. delivery_id,
  1731. status="FAILED",
  1732. error_message=str(exc),
  1733. increment_attempt=True,
  1734. )
  1735. except Exception as audit_exc:
  1736. logger.error("operator summary audit failed: %s", audit_exc)
  1737. logger.error("operator summary delivery failed: %s", exc)
  1738. return {
  1739. "route": route_name,
  1740. "status": "FAILED",
  1741. "error": str(exc),
  1742. }
  1743. def _reject_reason(raw_result: dict[str, Any] | None, system_status: str) -> str:
  1744. if raw_result:
  1745. parsed = parse_review_result(raw_result)
  1746. reasons = list(parsed.reject_messages)
  1747. reasons.extend(fact.reason for fact in parsed.rejection_facts)
  1748. unique = list(dict.fromkeys(reason.strip() for reason in reasons if reason.strip()))
  1749. if unique:
  1750. return ";".join(unique)
  1751. if system_status == DENIED_SYSTEM_STATUS:
  1752. return "腾讯正式审核未通过(接口未返回具体原因)"
  1753. return "腾讯正式审核未通过"
  1754. def _cleanup_reason(
  1755. action: dict[str, Any],
  1756. raw_result: dict[str, Any] | None,
  1757. system_status: str,
  1758. ) -> str:
  1759. if action["cleanup_action"] == DELETE_CREATIVE:
  1760. return "创意审核状态为 CREATIVE_SET_APPROVAL_STATUS_DENIED"
  1761. return _reject_reason(raw_result, system_status)
  1762. def _json_object(value: Any) -> dict[str, Any]:
  1763. if isinstance(value, dict):
  1764. return value
  1765. if not value:
  1766. return {}
  1767. try:
  1768. parsed = json.loads(str(value))
  1769. except (TypeError, ValueError, json.JSONDecodeError):
  1770. return {}
  1771. return parsed if isinstance(parsed, dict) else {}
  1772. def _display_date(value: Any) -> str:
  1773. if isinstance(value, (date, datetime)):
  1774. return value.strftime("%Y-%m-%d")
  1775. return str(value or "")[:10]
  1776. def _cleanup_rule_label(rule_type: Any) -> str:
  1777. return {
  1778. REVIEW_DENIED_RULE: "创意审核拒绝",
  1779. REVIEW_PARTIAL_RULE: "部分投放审核异常",
  1780. PERFORMANCE_NEW_RULE: "新素材未起量",
  1781. PERFORMANCE_OLD_LOW_RULE: "老素材低曝光零消耗",
  1782. PERFORMANCE_OLD_HIGH_RULE: "老素材高曝光零消耗",
  1783. PERFORMANCE_AD_ZERO_SPEND_RULE: "广告近3日及当日零消耗",
  1784. }.get(str(rule_type or ""), str(rule_type or ""))
  1785. def _write_report(
  1786. path: Path,
  1787. rows: list[dict[str, Any]],
  1788. *,
  1789. columns: tuple[str, ...],
  1790. sheet_title: str = "审核不通过创意清理",
  1791. ) -> None:
  1792. workbook = Workbook()
  1793. sheet = workbook.active
  1794. sheet.title = sheet_title
  1795. sheet.append(list(columns))
  1796. for row in rows:
  1797. raw_result = _json_object(
  1798. row.get("review_result") or row.get("review_result_json")
  1799. )
  1800. pre_state = _json_object(row.get("pre_state") or row.get("pre_state_json"))
  1801. granular = review_granularity_fields(raw_result)
  1802. action = str(row.get("cleanup_action") or "")
  1803. is_ad_cleanup = _is_ad_performance_rule(
  1804. row.get("cleanup_rule_type")
  1805. )
  1806. if (
  1807. action in {DELETE_CREATIVE, DELETE_AD}
  1808. and row.get("cleanup_status") == "DISCOVERED"
  1809. ):
  1810. execution_action = (
  1811. "建议删除广告(未执行)"
  1812. if is_ad_cleanup else "建议删除创意(未执行)"
  1813. )
  1814. elif action == DELETE_AD:
  1815. execution_action = "删除广告"
  1816. elif action == DELETE_CREATIVE:
  1817. execution_action = "删除创意"
  1818. else:
  1819. execution_action = "需人工判断"
  1820. checked_at = (
  1821. row.get("checked_at")
  1822. or row.get("created_at")
  1823. or row.get("updated_at")
  1824. or row.get("deleted_at")
  1825. )
  1826. recent_cost_fen = row.get("recent_cost_fen")
  1827. recent_cost_yuan = (
  1828. ""
  1829. if recent_cost_fen is None
  1830. else f"{int(recent_cost_fen) / 100:.2f}"
  1831. )
  1832. rule_type = str(row.get("cleanup_rule_type") or REVIEW_DENIED_RULE)
  1833. is_performance = _is_performance_rule(rule_type)
  1834. cost_start = _display_date(row.get("cost_start_date"))
  1835. cost_end = _display_date(row.get("cost_end_date"))
  1836. metric_average = row.get("metric_daily_avg_impressions")
  1837. created_at = _as_shanghai_datetime(row.get("creative_created_at"))
  1838. values = {
  1839. "清理对象": "广告" if is_ad_cleanup else "创意",
  1840. "代理名称": row.get("agency_name") or "",
  1841. "账户ID": str(row["account_id"]),
  1842. "账户名称": row.get("account_name") or "",
  1843. "代理商昵称": row.get("agent_name") or "",
  1844. "广告ID": str(row["adgroup_id"]),
  1845. "广告名称": row.get("adgroup_name") or "",
  1846. "创意ID": (
  1847. "" if is_ad_cleanup else str(row["dynamic_creative_id"])
  1848. ),
  1849. "创意名称": (
  1850. "" if is_ad_cleanup
  1851. else row.get("dynamic_creative_name") or ""
  1852. ),
  1853. "清理规则": _cleanup_rule_label(rule_type),
  1854. "创意搭建时间": (
  1855. created_at.strftime("%Y-%m-%d %H:%M:%S") if created_at else ""
  1856. ),
  1857. "创意年龄(天)": (
  1858. "" if row.get("creative_age_days") is None
  1859. else int(row["creative_age_days"])
  1860. ),
  1861. "对象创建时间": (
  1862. created_at.strftime("%Y-%m-%d %H:%M:%S") if created_at else ""
  1863. ),
  1864. "对象年龄(天)": (
  1865. "" if row.get("creative_age_days") is None
  1866. else int(row["creative_age_days"])
  1867. ),
  1868. "规则窗口累计曝光": (
  1869. "" if row.get("metric_impressions") is None
  1870. else int(row["metric_impressions"])
  1871. ),
  1872. "规则窗口日均曝光": (
  1873. "" if metric_average is None else f"{float(metric_average):.2f}"
  1874. ),
  1875. "规则窗口累计消耗(元)": (
  1876. recent_cost_yuan if is_performance else ""
  1877. ),
  1878. "广告近3日日均消耗(元)": (
  1879. f"{int(recent_cost_fen or 0) / 100 / int(row.get('metric_window_days') or DEFAULT_AD_PERFORMANCE_WINDOW_DAYS):.2f}"
  1880. if is_ad_cleanup else ""
  1881. ),
  1882. "规则指标日期范围": (
  1883. f"{cost_start} ~ {cost_end}"
  1884. if is_performance and cost_start and cost_end else ""
  1885. ),
  1886. "近3天累计历史消耗(元)": (
  1887. "" if is_performance else recent_cost_yuan
  1888. ),
  1889. "消耗日期范围": (
  1890. f"{cost_start} ~ {cost_end}"
  1891. if not is_performance and cost_start and cost_end else ""
  1892. ),
  1893. "执行操作": execution_action,
  1894. "操作判断原因": row.get("action_reason") or "",
  1895. "配置状态": status_desc(
  1896. pre_state.get("configured_status")
  1897. or row.get("configured_status")
  1898. ),
  1899. "创意审核状态": status_desc(
  1900. pre_state.get("creative_set_approval_status")
  1901. or row.get("creative_set_approval_status")
  1902. ),
  1903. "元素粒度审核状态": granular["element_review_status"],
  1904. "元素粒度审核不通过原因": granular["element_reject_reason"],
  1905. "版位粒度审核状态": granular["site_review_status"],
  1906. "版位粒度审核不通过原因": granular["site_reject_reason"],
  1907. "审核不通过原因": (
  1908. "" if is_performance else row.get("reject_reason") or ""
  1909. ),
  1910. "检查时间": (
  1911. checked_at.strftime("%Y-%m-%d %H:%M:%S")
  1912. if isinstance(checked_at, (date, datetime))
  1913. else str(checked_at or "")
  1914. ),
  1915. }
  1916. sheet.append([values[column] for column in columns])
  1917. header_fill = PatternFill("solid", fgColor="C65911")
  1918. for cell in sheet[1]:
  1919. cell.fill = header_fill
  1920. cell.font = Font(color="FFFFFF", bold=True)
  1921. cell.alignment = Alignment(horizontal="center", vertical="center")
  1922. widths = {
  1923. "清理对象": 12,
  1924. "代理名称": 22,
  1925. "账户ID": 14,
  1926. "账户名称": 22,
  1927. "代理商昵称": 22,
  1928. "广告ID": 14,
  1929. "广告名称": 30,
  1930. "创意ID": 16,
  1931. "创意名称": 30,
  1932. "清理规则": 24,
  1933. "创意搭建时间": 20,
  1934. "创意年龄(天)": 14,
  1935. "对象创建时间": 20,
  1936. "对象年龄(天)": 14,
  1937. "规则窗口累计曝光": 20,
  1938. "规则窗口日均曝光": 20,
  1939. "规则窗口累计消耗(元)": 22,
  1940. "广告近3日日均消耗(元)": 24,
  1941. "规则指标日期范围": 24,
  1942. "近3天累计历史消耗(元)": 22,
  1943. "消耗日期范围": 24,
  1944. "执行操作": 16,
  1945. "操作判断原因": 60,
  1946. "配置状态": 20,
  1947. "创意审核状态": 24,
  1948. "元素粒度审核状态": 40,
  1949. "元素粒度审核不通过原因": 60,
  1950. "版位粒度审核状态": 40,
  1951. "版位粒度审核不通过原因": 60,
  1952. "审核不通过原因": 60,
  1953. "检查时间": 20,
  1954. }
  1955. for index, column in enumerate(columns, start=1):
  1956. sheet.column_dimensions[get_column_letter(index)].width = widths[column]
  1957. for row in sheet.iter_rows(min_row=2):
  1958. for cell in row:
  1959. cell.alignment = Alignment(vertical="top", wrap_text=True)
  1960. for id_column in ("账户ID", "广告ID", "创意ID"):
  1961. row[columns.index(id_column)].number_format = "@"
  1962. sheet.freeze_panes = "A2"
  1963. sheet.auto_filter.ref = sheet.dimensions
  1964. path.parent.mkdir(parents=True, exist_ok=True)
  1965. workbook.save(path)
  1966. def write_cleanup_reports(
  1967. rows: list[dict[str, Any]],
  1968. output_dir: Path,
  1969. report_date: str,
  1970. ) -> tuple[str, list[dict[str, object]], dict[str, list[int]]]:
  1971. grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
  1972. for row in rows:
  1973. grouped[str(row["agency_name"])].append(row)
  1974. def digest_for(report_rows: list[dict[str, Any]], destination: str) -> str:
  1975. digest_input = "|".join(
  1976. ":".join(
  1977. [
  1978. str(row["id"]),
  1979. str(row.get("cleanup_action") or ""),
  1980. str(row.get("cleanup_rule_type") or ""),
  1981. str(row.get("cleanup_status") or ""),
  1982. str(row.get("recent_cost_fen")),
  1983. str(row.get("metric_impressions")),
  1984. str(row.get("metric_daily_avg_impressions")),
  1985. _display_date(row.get("cost_end_date")),
  1986. str(row.get("reject_reason") or ""),
  1987. ]
  1988. )
  1989. for row in sorted(report_rows, key=lambda value: int(value["id"]))
  1990. )
  1991. return hashlib.sha256(
  1992. f"{report_date}|{destination}|{digest_input}".encode("utf-8")
  1993. ).hexdigest()[:12]
  1994. digest = digest_for(rows, "all")
  1995. run_id = f"reject_{report_date}_{digest}"
  1996. reports: list[dict[str, object]] = []
  1997. item_ids: dict[str, list[int]] = {}
  1998. for agency, agency_rows in sorted(grouped.items()):
  1999. agency_digest = digest_for(agency_rows, agency)
  2000. safe_agency = agency.replace("/", "_").replace("\\", "_")
  2001. path = output_dir / (
  2002. f"{report_date}_{safe_agency}_创意审核异常处理_{agency_digest}.xlsx"
  2003. )
  2004. dry_run = any(
  2005. row.get("cleanup_status") == "DISCOVERED"
  2006. for row in agency_rows
  2007. )
  2008. _write_report(path, agency_rows, columns=AGENCY_REPORT_COLUMNS)
  2009. reports.append(
  2010. {
  2011. "agency_name": agency,
  2012. "report_version": REPORT_VERSION,
  2013. "report": str(path),
  2014. "title": (
  2015. f"{report_date}_{agency}_创意审核异常处理预演通知"
  2016. if dry_run
  2017. else f"{report_date}_{agency}_创意审核异常处理通知"
  2018. ),
  2019. "creative_rows": len(agency_rows),
  2020. "ad_rows": 0,
  2021. "notification_type": (
  2022. "creative_rejection_dry_run"
  2023. if dry_run
  2024. else "creative_rejection_cleanup"
  2025. ),
  2026. "run_id": f"reject_{report_date}_{agency_digest}",
  2027. }
  2028. )
  2029. item_ids[agency] = [int(row["id"]) for row in agency_rows]
  2030. return run_id, reports, item_ids
  2031. def write_cleanup_operator_summary(
  2032. rows: list[dict[str, Any]],
  2033. output_dir: Path,
  2034. report_date: str,
  2035. run_id: str,
  2036. ) -> dict[str, object]:
  2037. digest_input = "|".join(
  2038. ":".join(
  2039. [
  2040. str(row["id"]),
  2041. str(row.get("cleanup_action") or ""),
  2042. str(row.get("cleanup_rule_type") or ""),
  2043. str(row.get("cleanup_status") or ""),
  2044. str(row.get("recent_cost_fen")),
  2045. str(row.get("metric_impressions")),
  2046. str(row.get("metric_daily_avg_impressions")),
  2047. _display_date(row.get("cost_end_date")),
  2048. str(row.get("reject_reason") or ""),
  2049. ]
  2050. )
  2051. for row in sorted(rows, key=lambda value: int(value["id"]))
  2052. )
  2053. digest = hashlib.sha256(
  2054. f"{report_date}|{OPERATOR_SUMMARY_ROUTE}|{digest_input}".encode("utf-8")
  2055. ).hexdigest()[:12]
  2056. path = output_dir / (
  2057. f"{report_date}_投放调控_创意审核异常处理汇总_{digest}.xlsx"
  2058. )
  2059. unexecuted_rows = sum(
  2060. row.get("cleanup_status") == "DISCOVERED" for row in rows
  2061. )
  2062. deleted_rows = sum(
  2063. row.get("cleanup_status") == "CREATIVE_DELETED" for row in rows
  2064. )
  2065. dry_run = unexecuted_rows > 0 and deleted_rows == 0
  2066. _write_report(path, rows, columns=OPERATOR_REPORT_COLUMNS)
  2067. return {
  2068. "report_version": f"{REPORT_VERSION}_operator_summary",
  2069. "report": str(path),
  2070. "title": (
  2071. f"{report_date}_创意审核异常处理预演汇总通知"
  2072. if dry_run
  2073. else f"{report_date}_创意审核异常处理汇总通知"
  2074. ),
  2075. "creative_rows": len(rows),
  2076. "run_id": f"reject_{report_date}_{digest}",
  2077. "dry_run": dry_run,
  2078. "unexecuted_rows": unexecuted_rows,
  2079. "deleted_rows": deleted_rows,
  2080. }
  2081. def write_performance_operator_summary(
  2082. rows: list[dict[str, Any]],
  2083. output_dir: Path,
  2084. report_date: str,
  2085. ) -> dict[str, object]:
  2086. """生成只发内部群且独立发送的长期未起量报表。"""
  2087. digest_input = "|".join(
  2088. ":".join(
  2089. [
  2090. str(row["id"]),
  2091. str(row.get("cleanup_rule_type") or ""),
  2092. str(row.get("cleanup_status") or ""),
  2093. str(row.get("account_name") or ""),
  2094. str(row.get("agent_name") or ""),
  2095. str(row.get("creative_age_days")),
  2096. str(row.get("metric_impressions")),
  2097. str(row.get("metric_daily_avg_impressions")),
  2098. str(row.get("recent_cost_fen")),
  2099. _display_date(row.get("cost_end_date")),
  2100. ]
  2101. )
  2102. for row in sorted(rows, key=lambda value: int(value["id"]))
  2103. )
  2104. digest = hashlib.sha256(
  2105. (
  2106. f"{report_date}|{PERFORMANCE_SUMMARY_ROUTE}|"
  2107. f"{PERFORMANCE_REPORT_VERSION}|{digest_input}"
  2108. ).encode("utf-8")
  2109. ).hexdigest()[:12]
  2110. path = output_dir / (
  2111. f"{report_date}_投放调控_长期未起量创意及广告清理汇总_{digest}.xlsx"
  2112. )
  2113. unexecuted_rows = sum(
  2114. row.get("cleanup_status") == "DISCOVERED" for row in rows
  2115. )
  2116. deleted_rows = sum(
  2117. row.get("cleanup_status") in {"CREATIVE_DELETED", "AD_DELETED"}
  2118. for row in rows
  2119. )
  2120. dry_run = unexecuted_rows > 0 and deleted_rows == 0
  2121. _write_report(
  2122. path,
  2123. rows,
  2124. columns=PERFORMANCE_REPORT_COLUMNS,
  2125. sheet_title="长期未起量清理",
  2126. )
  2127. ad_rows = sum(
  2128. _is_ad_performance_rule(row.get("cleanup_rule_type")) for row in rows
  2129. )
  2130. creative_rows = len(rows) - ad_rows
  2131. return {
  2132. "report_version": PERFORMANCE_REPORT_VERSION,
  2133. "report": str(path),
  2134. "title": "长期未起量创意及广告清理汇总通知",
  2135. "creative_rows": creative_rows,
  2136. "ad_rows": ad_rows,
  2137. "run_id": f"performance_{report_date}_{digest}",
  2138. "dry_run": dry_run,
  2139. "unexecuted_rows": unexecuted_rows,
  2140. "deleted_rows": deleted_rows,
  2141. "notification_kind": "performance_internal",
  2142. }
  2143. def split_notification_rows(
  2144. rows: list[dict[str, Any]],
  2145. ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
  2146. """拆分代理商、审核内部群和未起量内部群三类通知数据。"""
  2147. agency_rows = [
  2148. row
  2149. for row in rows
  2150. if row.get("agency_notified_at") is None
  2151. and not _is_performance_rule(row.get("cleanup_rule_type"))
  2152. and str(row.get("agency_name") or "").strip()
  2153. ]
  2154. review_internal_rows = [
  2155. row
  2156. for row in rows
  2157. if row.get("operator_notified_at") is None
  2158. and not _is_performance_rule(row.get("cleanup_rule_type"))
  2159. ]
  2160. performance_internal_rows = [
  2161. row
  2162. for row in rows
  2163. if row.get("operator_notified_at") is None
  2164. and _is_performance_rule(row.get("cleanup_rule_type"))
  2165. ]
  2166. return agency_rows, review_internal_rows, performance_internal_rows
  2167. def filter_preview_notification_rows(
  2168. rows: list[dict[str, Any]],
  2169. *,
  2170. check_date: date,
  2171. confirmed_actions: dict[tuple[int, int], dict[str, Any]],
  2172. force_notification: bool,
  2173. ) -> list[dict[str, Any]]:
  2174. """预览通知只保留本轮重新确认并成功落库的未起量候选。
  2175. 同一天重复运行时,审计表里可能残留旧版本误判或本轮已不再满足规则的
  2176. ``DISCOVERED`` 记录。仅按日期查询会把这些旧候选再次写进 Excel;这里同时
  2177. 校验本轮确认键和规则类型,避免过期时间、指标或账户信息进入新报表。
  2178. """
  2179. selected: list[dict[str, Any]] = []
  2180. for row in rows:
  2181. if (
  2182. not _is_performance_rule(row.get("cleanup_rule_type"))
  2183. or _as_date(row.get("check_date")) != check_date
  2184. ):
  2185. continue
  2186. account_id = _as_int(row.get("account_id"))
  2187. try:
  2188. # 广告候选使用负广告 ID 作为审计目标键,不能调用只接受正数的
  2189. # ``_as_int``,否则本轮确认的广告会被错误排除在预览通知之外。
  2190. target_id = int(row.get("dynamic_creative_id"))
  2191. except (TypeError, ValueError):
  2192. target_id = 0
  2193. if account_id is None or target_id == 0:
  2194. continue
  2195. confirmed = confirmed_actions.get((account_id, target_id))
  2196. if (
  2197. confirmed is None
  2198. or confirmed.get("cleanup_rule_type")
  2199. != row.get("cleanup_rule_type")
  2200. ):
  2201. continue
  2202. selected.append(
  2203. {**row, "operator_notified_at": None}
  2204. if force_notification
  2205. else row
  2206. )
  2207. return selected
  2208. def _chunks(values: list[int], size: int) -> list[list[int]]:
  2209. return [values[offset : offset + size] for offset in range(0, len(values), size)]
  2210. def _scan_one_account(
  2211. account: dict[str, Any],
  2212. *,
  2213. tencent,
  2214. review_fetcher: Callable[[int, list[int]], list[dict]],
  2215. spend_start_date: date,
  2216. spend_end_date: date,
  2217. review_enabled: bool = True,
  2218. performance_enabled: bool = False,
  2219. performance_source_creatives: dict[int, dict[str, Any]] | None = None,
  2220. performance_start_date: date | None = None,
  2221. performance_end_date: date | None = None,
  2222. current_day_date: date | None = None,
  2223. performance_as_of: datetime | None = None,
  2224. performance_config: dict[str, int | float] | None = None,
  2225. ad_cleanup_enabled: bool = False,
  2226. ad_metric_start_date: date | None = None,
  2227. ad_metric_end_date: date | None = None,
  2228. ) -> tuple[
  2229. list[dict],
  2230. dict[int, dict],
  2231. dict[int, dict],
  2232. dict[int, int],
  2233. str | None,
  2234. int,
  2235. str | None,
  2236. dict[int, dict[str, Any]],
  2237. str | None,
  2238. dict[int, dict[str, int]],
  2239. str | None,
  2240. str | None,
  2241. dict[str, int],
  2242. ]:
  2243. account_id = int(account["account_id"])
  2244. empty_source_diagnostics = {
  2245. "missing_tencent_creatives": 0,
  2246. "missing_tencent_ads": 0,
  2247. "ad_mismatches": 0,
  2248. "missing_source_ad_ids": 0,
  2249. "missing_create_time": 0,
  2250. }
  2251. try:
  2252. creative_list_error = None
  2253. try:
  2254. creatives = [
  2255. creative
  2256. for creative in tencent.get_dynamic_creatives(account_id)
  2257. if not _is_deleted_creative(creative)
  2258. ]
  2259. except Exception as exc:
  2260. creatives = []
  2261. creative_list_error = str(exc)
  2262. logger.exception("creative list scan failed account=%d", account_id)
  2263. ad_list_error = None
  2264. try:
  2265. ads = {
  2266. int(ad["adgroup_id"]): ad
  2267. for ad in tencent.get_ads(account_id)
  2268. if _as_int(ad.get("adgroup_id")) is not None
  2269. }
  2270. except Exception as exc:
  2271. ads = {}
  2272. ad_list_error = str(exc)
  2273. logger.exception("ad list scan failed account=%d", account_id)
  2274. ids = [
  2275. int(row["dynamic_creative_id"])
  2276. for row in creatives
  2277. if _as_int(row.get("dynamic_creative_id")) is not None
  2278. ]
  2279. creative_by_id = {
  2280. int(row["dynamic_creative_id"]): row
  2281. for row in creatives
  2282. if _as_int(row.get("dynamic_creative_id")) is not None
  2283. }
  2284. raw_by_id: dict[int, dict] = {}
  2285. review_error = creative_list_error if review_enabled else None
  2286. if review_enabled and review_error is None:
  2287. try:
  2288. for batch in _chunks(ids, 100):
  2289. for raw in review_fetcher(account_id, batch):
  2290. creative_id = _as_int(raw.get("dynamic_creative_id"))
  2291. if creative_id is not None:
  2292. raw_by_id[creative_id] = raw
  2293. except Exception as exc:
  2294. review_error = str(exc)
  2295. raw_by_id = {}
  2296. logger.exception(
  2297. "creative review scan failed account=%d", account_id
  2298. )
  2299. cost_by_id: dict[int, int] = {}
  2300. spend_error = None
  2301. partial_ids = [
  2302. int(row["dynamic_creative_id"])
  2303. for row in creatives
  2304. if _as_int(row.get("dynamic_creative_id")) is not None
  2305. and row.get("creative_set_approval_status")
  2306. == CREATIVE_PARTIAL_NORMAL_STATUS
  2307. ]
  2308. if review_enabled and review_error is None and partial_ids:
  2309. try:
  2310. cost_by_id = tencent.get_dynamic_creative_costs(
  2311. account_id,
  2312. partial_ids,
  2313. spend_start_date,
  2314. spend_end_date,
  2315. )
  2316. except Exception as exc:
  2317. spend_error = str(exc)
  2318. logger.exception(
  2319. "creative cost scan failed account=%d start=%s end=%s",
  2320. account_id,
  2321. spend_start_date,
  2322. spend_end_date,
  2323. )
  2324. performance_metrics: dict[int, dict[str, Any]] = {}
  2325. performance_error = None
  2326. source_creatives = performance_source_creatives or {}
  2327. missing_tencent_creatives = (
  2328. set(source_creatives) - set(creative_by_id)
  2329. if creative_list_error is None
  2330. else set()
  2331. )
  2332. missing_create_time = {
  2333. creative_id
  2334. for creative_id, source in source_creatives.items()
  2335. if _as_shanghai_datetime(source.get("create_time")) is None
  2336. }
  2337. if performance_enabled:
  2338. if creative_list_error is not None:
  2339. performance_error = f"creative list failed: {creative_list_error}"
  2340. elif ad_list_error is not None:
  2341. performance_error = f"ad list failed: {ad_list_error}"
  2342. elif review_enabled and review_error is not None:
  2343. # 未起量创意必须在本账户审核判断成功后才能执行;广告链路不受此限制。
  2344. performance_error = None
  2345. else:
  2346. try:
  2347. if performance_start_date is None or performance_end_date is None:
  2348. raise ValueError("performance metric date window is missing")
  2349. if current_day_date is None:
  2350. raise ValueError("current-day metric date is missing")
  2351. settings = performance_config or performance_cleanup_config()
  2352. as_of = _as_shanghai_datetime(performance_as_of)
  2353. if as_of is None:
  2354. as_of = datetime(
  2355. current_day_date.year,
  2356. current_day_date.month,
  2357. current_day_date.day,
  2358. 23,
  2359. 59,
  2360. 59,
  2361. 999999,
  2362. tzinfo=SHANGHAI,
  2363. )
  2364. min_age = int(settings["new_min_age_days"])
  2365. max_age = int(settings["new_max_age_days"])
  2366. new_ids_by_start: dict[date, list[int]] = defaultdict(list)
  2367. old_ids: list[int] = []
  2368. for creative_id, row in creative_by_id.items():
  2369. if (
  2370. creative_id not in source_creatives
  2371. or creative_id in missing_create_time
  2372. or row.get("configured_status") != "AD_STATUS_NORMAL"
  2373. or row.get("creative_set_approval_status")
  2374. not in {
  2375. CREATIVE_NORMAL_STATUS,
  2376. CREATIVE_PARTIAL_NORMAL_STATUS,
  2377. }
  2378. ):
  2379. continue
  2380. ad = ads.get(_as_int(row.get("adgroup_id")) or -1) or {}
  2381. if ad.get("configured_status") != "AD_STATUS_NORMAL":
  2382. continue
  2383. created_at = _as_shanghai_datetime(
  2384. source_creatives[creative_id].get("create_time")
  2385. )
  2386. if created_at is None:
  2387. continue
  2388. age_days = (as_of - created_at).days
  2389. if min_age < age_days <= max_age:
  2390. new_ids_by_start[created_at.date()].append(creative_id)
  2391. elif age_days > max_age:
  2392. old_ids.append(creative_id)
  2393. eligible_ids = [
  2394. creative_id
  2395. for ids_for_date in new_ids_by_start.values()
  2396. for creative_id in ids_for_date
  2397. ] + old_ids
  2398. if eligible_ids:
  2399. old_metrics: dict[int, dict[str, int]] = {}
  2400. if old_ids:
  2401. old_metrics = tencent.get_dynamic_creative_metrics(
  2402. account_id,
  2403. old_ids,
  2404. performance_start_date,
  2405. performance_end_date,
  2406. )
  2407. missing_old = set(old_ids) - set(old_metrics)
  2408. if missing_old:
  2409. raise RuntimeError(
  2410. "Tencent historical creative metric response omitted "
  2411. f"requested IDs: {sorted(missing_old)}"
  2412. )
  2413. new_metrics_by_start: dict[
  2414. date, dict[int, dict[str, int]]
  2415. ] = {}
  2416. for metric_start_date, new_ids in new_ids_by_start.items():
  2417. historical_metrics = (
  2418. tencent.get_dynamic_creative_metrics(
  2419. account_id,
  2420. new_ids,
  2421. metric_start_date,
  2422. performance_end_date,
  2423. )
  2424. if metric_start_date <= performance_end_date
  2425. else {
  2426. creative_id: {
  2427. "impressions": 0,
  2428. "cost_fen": 0,
  2429. }
  2430. for creative_id in new_ids
  2431. }
  2432. )
  2433. missing_new = set(new_ids) - set(historical_metrics)
  2434. if missing_new:
  2435. raise RuntimeError(
  2436. "Tencent cumulative creative metric response omitted "
  2437. f"requested IDs: {sorted(missing_new)}"
  2438. )
  2439. new_metrics_by_start[
  2440. metric_start_date
  2441. ] = historical_metrics
  2442. current_day_metrics = tencent.get_dynamic_creative_metrics(
  2443. account_id,
  2444. eligible_ids,
  2445. current_day_date,
  2446. current_day_date,
  2447. )
  2448. missing_today = set(eligible_ids) - set(current_day_metrics)
  2449. if missing_today:
  2450. raise RuntimeError(
  2451. "Tencent current-day creative metric response omitted "
  2452. f"requested IDs: {sorted(missing_today)}"
  2453. )
  2454. for creative_id in old_ids:
  2455. performance_metrics[creative_id] = {
  2456. **old_metrics[creative_id],
  2457. "current_day_cost_fen": int(
  2458. current_day_metrics[creative_id].get(
  2459. "cost_fen", 0
  2460. )
  2461. ),
  2462. "metric_start_date": performance_start_date,
  2463. "metric_end_date": performance_end_date,
  2464. }
  2465. for metric_start_date, new_ids in new_ids_by_start.items():
  2466. historical_metrics = new_metrics_by_start[
  2467. metric_start_date
  2468. ]
  2469. for creative_id in new_ids:
  2470. today = current_day_metrics[creative_id]
  2471. historical = historical_metrics[creative_id]
  2472. performance_metrics[creative_id] = {
  2473. "impressions": int(
  2474. historical.get("impressions", 0)
  2475. )
  2476. + int(today.get("impressions", 0)),
  2477. "cost_fen": int(historical.get("cost_fen", 0))
  2478. + int(today.get("cost_fen", 0)),
  2479. "current_day_cost_fen": int(
  2480. today.get("cost_fen", 0)
  2481. ),
  2482. "metric_start_date": metric_start_date,
  2483. "metric_end_date": current_day_date,
  2484. }
  2485. except Exception as exc:
  2486. performance_metrics = {}
  2487. performance_error = str(exc)
  2488. logger.exception(
  2489. "creative performance scan failed account=%d start=%s end=%s",
  2490. account_id,
  2491. performance_start_date,
  2492. performance_end_date,
  2493. )
  2494. ad_metrics: dict[int, dict[str, int]] = {}
  2495. ad_metric_error = None
  2496. missing_tencent_ads: set[int] = set()
  2497. ad_mismatches: set[int] = set()
  2498. missing_source_ad_ids = {
  2499. creative_id
  2500. for creative_id, source in source_creatives.items()
  2501. if _as_int(source.get("adgroup_id")) is None
  2502. }
  2503. if creative_list_error is None:
  2504. for creative_id, source in source_creatives.items():
  2505. creative = creative_by_id.get(creative_id)
  2506. if creative is None:
  2507. continue
  2508. source_adgroup_id = _as_int(source.get("adgroup_id"))
  2509. tencent_adgroup_id = _as_int(creative.get("adgroup_id"))
  2510. if (
  2511. source_adgroup_id is not None
  2512. and tencent_adgroup_id is not None
  2513. and source_adgroup_id != tencent_adgroup_id
  2514. ):
  2515. # 只记录源映射漂移,不阻断广告按自身条件判断。
  2516. ad_mismatches.add(creative_id)
  2517. if ad_cleanup_enabled:
  2518. eligible_ad_ids: set[int] = set()
  2519. if ad_list_error is not None:
  2520. ad_metric_error = f"ad list failed: {ad_list_error}"
  2521. else:
  2522. source_ad_ids = {
  2523. adgroup_id
  2524. for source in source_creatives.values()
  2525. if (adgroup_id := _as_int(source.get("adgroup_id")))
  2526. is not None
  2527. }
  2528. missing_tencent_ads = source_ad_ids - set(ads)
  2529. eligible_ad_ids = {
  2530. adgroup_id
  2531. for adgroup_id in source_ad_ids
  2532. if (ads.get(adgroup_id) or {}).get("configured_status")
  2533. == "AD_STATUS_NORMAL"
  2534. }
  2535. if ad_metric_error is None and eligible_ad_ids:
  2536. try:
  2537. if ad_metric_start_date is None or ad_metric_end_date is None:
  2538. raise ValueError("ad metric date window is missing")
  2539. if current_day_date is None:
  2540. raise ValueError("current-day metric date is missing")
  2541. historical_ad_metrics = tencent.get_ad_metrics(
  2542. account_id,
  2543. sorted(eligible_ad_ids),
  2544. ad_metric_start_date,
  2545. ad_metric_end_date,
  2546. )
  2547. current_day_ad_metrics = tencent.get_ad_metrics(
  2548. account_id,
  2549. sorted(eligible_ad_ids),
  2550. current_day_date,
  2551. current_day_date,
  2552. )
  2553. missing_historical_ads = set(eligible_ad_ids) - set(
  2554. historical_ad_metrics
  2555. )
  2556. missing_today_ads = set(eligible_ad_ids) - set(
  2557. current_day_ad_metrics
  2558. )
  2559. if missing_historical_ads or missing_today_ads:
  2560. raise RuntimeError(
  2561. "Tencent ad metric response omitted requested IDs: "
  2562. f"historical={sorted(missing_historical_ads)} "
  2563. f"today={sorted(missing_today_ads)}"
  2564. )
  2565. ad_metrics = {
  2566. adgroup_id: {
  2567. **historical_ad_metrics[adgroup_id],
  2568. "current_day_cost_fen": int(
  2569. current_day_ad_metrics[adgroup_id].get(
  2570. "cost_fen", 0
  2571. )
  2572. ),
  2573. }
  2574. for adgroup_id in eligible_ad_ids
  2575. if adgroup_id in historical_ad_metrics
  2576. and adgroup_id in current_day_ad_metrics
  2577. }
  2578. except Exception as exc:
  2579. ad_metric_error = str(exc)
  2580. logger.exception(
  2581. "ad performance scan failed account=%d start=%s end=%s",
  2582. account_id,
  2583. ad_metric_start_date,
  2584. ad_metric_end_date,
  2585. )
  2586. source_diagnostics = {
  2587. "missing_tencent_creatives": len(missing_tencent_creatives),
  2588. "missing_tencent_ads": len(missing_tencent_ads),
  2589. "ad_mismatches": len(ad_mismatches),
  2590. "missing_source_ad_ids": len(missing_source_ad_ids),
  2591. "missing_create_time": len(missing_create_time),
  2592. }
  2593. return (
  2594. creatives,
  2595. ads,
  2596. raw_by_id,
  2597. cost_by_id,
  2598. spend_error,
  2599. len(ids),
  2600. None,
  2601. performance_metrics,
  2602. performance_error,
  2603. ad_metrics,
  2604. ad_metric_error,
  2605. review_error,
  2606. source_diagnostics,
  2607. )
  2608. except Exception as exc:
  2609. return (
  2610. [],
  2611. {},
  2612. {},
  2613. {},
  2614. None,
  2615. 0,
  2616. f"account={account_id} scan failed: {exc}",
  2617. {},
  2618. None,
  2619. {},
  2620. None,
  2621. None,
  2622. empty_source_diagnostics,
  2623. )
  2624. def cleanup_precondition_failure(
  2625. item: dict[str, Any],
  2626. scanned_accounts: set[int],
  2627. confirmed_actions: dict[tuple[int, int], dict[str, Any]],
  2628. performance_scanned_accounts: set[int] | None = None,
  2629. ad_scanned_accounts: set[int] | None = None,
  2630. performance_scope_loaded: bool = True,
  2631. performance_source_keys: set[tuple[int, int]] | None = None,
  2632. performance_source_ad_keys: set[tuple[int, int]] | None = None,
  2633. ) -> tuple[str, str] | None:
  2634. """除非本轮再次确认同一清理动作,否则按保守策略拒绝执行。"""
  2635. account_id = int(item["account_id"])
  2636. creative_id = int(item["dynamic_creative_id"])
  2637. is_performance_item = _is_performance_rule(item.get("cleanup_rule_type"))
  2638. is_ad_item = _is_ad_performance_rule(item.get("cleanup_rule_type"))
  2639. if is_ad_item:
  2640. if not performance_scope_loaded:
  2641. return "DEFERRED", "本轮有效创意范围读取失败,未执行广告删除"
  2642. if (
  2643. account_id,
  2644. int(item["adgroup_id"]),
  2645. ) not in (performance_source_ad_keys or set()):
  2646. return (
  2647. "SKIPPED_REVIEW_NOT_RECONFIRMED",
  2648. "广告已不在当前有效创意派生范围内",
  2649. )
  2650. if account_id not in (ad_scanned_accounts or set()):
  2651. return "DEFERRED", "本轮账户近3天广告消耗读取失败,未执行删除"
  2652. elif is_performance_item:
  2653. if not performance_scope_loaded:
  2654. return "DEFERRED", "本轮有效创意范围读取失败,未执行创意删除"
  2655. if (account_id, creative_id) not in (
  2656. performance_source_keys or set()
  2657. ):
  2658. return (
  2659. "SKIPPED_REVIEW_NOT_RECONFIRMED",
  2660. "创意已不在当前 is_delete=0 有效范围内",
  2661. )
  2662. if account_id not in (performance_scanned_accounts or set()):
  2663. return "DEFERRED", "本轮账户近7天创意指标读取失败,未执行删除"
  2664. elif account_id not in scanned_accounts:
  2665. return "DEFERRED", "本轮账户审核结果扫描失败,未执行删除"
  2666. confirmed = confirmed_actions.get((account_id, creative_id))
  2667. if not confirmed:
  2668. return (
  2669. "SKIPPED_REVIEW_NOT_RECONFIRMED",
  2670. "本轮未按新规则再次确认清理动作",
  2671. )
  2672. item_action = str(item.get("cleanup_action") or "")
  2673. allowed_action = DELETE_AD if is_ad_item else DELETE_CREATIVE
  2674. if item_action != allowed_action:
  2675. return "SKIPPED_REVIEW_NOT_RECONFIRMED", "当前规则不允许该删除动作"
  2676. if item_action != confirmed["cleanup_action"]:
  2677. return "SKIPPED_REVIEW_NOT_RECONFIRMED", "本轮清理动作与候选记录不一致"
  2678. return None
  2679. def _same_cleanup_action(
  2680. expected_action: str,
  2681. actual: dict[str, Any] | None,
  2682. *,
  2683. expected_rule_type: str | None = None,
  2684. ) -> bool:
  2685. matches = bool(
  2686. expected_action in {DELETE_CREATIVE, DELETE_AD}
  2687. and actual
  2688. and actual.get("cleanup_action") == expected_action
  2689. )
  2690. if not matches:
  2691. return False
  2692. if expected_rule_type and _is_performance_rule(expected_rule_type):
  2693. return actual.get("cleanup_rule_type") == expected_rule_type
  2694. return True
  2695. def run_rejected_creative_cleanup(
  2696. *,
  2697. output_dir: Path,
  2698. now: datetime | None = None,
  2699. tencent=None,
  2700. odps=None,
  2701. review_fetcher: Callable[[int, list[int]], list[dict]] | None = None,
  2702. publisher: RoiFeishuPublisher | None = None,
  2703. notifier=None,
  2704. clock: Callable[[], datetime] | None = None,
  2705. underperformance_preview_only: bool = False,
  2706. force_notification: bool = False,
  2707. ) -> dict[str, Any]:
  2708. """先应用审核规则,再应用长期未起量规则。
  2709. ``underperformance_preview_only`` 是人工预览模式:跳过审核规则发现,强制
  2710. 关闭两类腾讯删除开关,只写预览审计记录,并把当日未起量 Excel 发到内部群。
  2711. ``force_notification`` 仅用于该预览模式,同日重发时创建新的通知审计批次。
  2712. """
  2713. if force_notification and not underperformance_preview_only:
  2714. raise ValueError("force_notification 仅允许用于未起量预览模式")
  2715. clock_was_provided = clock is not None
  2716. clock_fn = clock or (lambda: datetime.now(SHANGHAI))
  2717. effective_now = now or clock_fn()
  2718. if effective_now.tzinfo is None:
  2719. effective_now = effective_now.replace(tzinfo=SHANGHAI)
  2720. if underperformance_preview_only:
  2721. apply_enabled = False
  2722. performance_enabled = True
  2723. performance_apply_enabled = False
  2724. ad_cleanup_enabled = True
  2725. ad_apply_enabled = False
  2726. else:
  2727. apply_enabled = _env_flag("DAILY_REJECTED_CREATIVE_APPLY_ENABLED")
  2728. performance_enabled = _env_flag(
  2729. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED"
  2730. )
  2731. performance_apply_enabled = _env_flag(
  2732. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED"
  2733. )
  2734. ad_cleanup_enabled = _env_flag(
  2735. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED"
  2736. )
  2737. ad_apply_enabled = _env_flag(
  2738. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED"
  2739. )
  2740. if performance_apply_enabled and not performance_enabled:
  2741. raise RuntimeError(
  2742. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED=1 requires "
  2743. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=1"
  2744. )
  2745. if ad_apply_enabled and not ad_cleanup_enabled:
  2746. raise RuntimeError(
  2747. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED=1 requires "
  2748. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED=1"
  2749. )
  2750. if ad_cleanup_enabled and not performance_enabled:
  2751. raise RuntimeError(
  2752. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED=1 requires "
  2753. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=1 so creative "
  2754. "judgment always runs first"
  2755. )
  2756. performance_settings = (
  2757. performance_cleanup_config() if performance_enabled else None
  2758. )
  2759. webhook_config = (
  2760. AgencyWebhookConfig()
  2761. if underperformance_preview_only
  2762. else AgencyWebhookConfig.from_env()
  2763. )
  2764. if apply_enabled and not webhook_config.enabled:
  2765. raise RuntimeError(
  2766. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1 requires "
  2767. "ROI_AGENCY_WEBHOOK_ENABLED=1"
  2768. )
  2769. if (
  2770. apply_enabled
  2771. or performance_enabled
  2772. or performance_apply_enabled
  2773. or ad_cleanup_enabled
  2774. or ad_apply_enabled
  2775. ) and not _operator_summary_chat_id():
  2776. raise RuntimeError(
  2777. "creative cleanup internal notification requires "
  2778. "FEISHU_AD_PROJECT_CHAT_ID"
  2779. )
  2780. initialize_schema()
  2781. if odps is None:
  2782. from roi_control.odps_client import ODPSClient
  2783. odps_client = ODPSClient(project=os.getenv("ODPS_PROJECT", "loghubods"))
  2784. else:
  2785. odps_client = odps
  2786. spend_end_date = effective_now.date() - timedelta(days=1)
  2787. spend_start_date = spend_end_date - timedelta(days=2)
  2788. performance_window_days = int(
  2789. (performance_settings or {}).get(
  2790. "window_days", DEFAULT_PERFORMANCE_WINDOW_DAYS
  2791. )
  2792. )
  2793. performance_end_date = spend_end_date
  2794. performance_start_date = performance_end_date - timedelta(
  2795. days=performance_window_days - 1
  2796. )
  2797. current_day_date = effective_now.date()
  2798. ad_window_days = DEFAULT_AD_PERFORMANCE_WINDOW_DAYS
  2799. ad_min_age_days = DEFAULT_AD_PERFORMANCE_MIN_AGE_DAYS
  2800. if ad_cleanup_enabled:
  2801. ad_window_days = ad_performance_window_days()
  2802. ad_min_age_days = ad_performance_min_age_days()
  2803. ad_metric_end_date = spend_end_date
  2804. ad_metric_start_date = ad_metric_end_date - timedelta(
  2805. days=ad_window_days - 1
  2806. )
  2807. cost_threshold_fen = partial_creative_cost_threshold_fen()
  2808. wechat_cost_threshold_fen = wechat_mini_program_cost_threshold_fen()
  2809. # 审核范围/代理上下文与未起量 ODPS 范围相互隔离。审核数据异常时,
  2810. # 审核删除和未起量创意均 fail closed,但广告仍可按自身条件继续判断。
  2811. start_date: str | None = None
  2812. end_date: str | None = None
  2813. review_scope_error: str | None = None
  2814. review_context_error: str | None = None
  2815. review_accounts: list[dict[str, Any]] = []
  2816. daily = pd.DataFrame()
  2817. if not underperformance_preview_only:
  2818. try:
  2819. resolved_review_end_date = resolve_end_date(
  2820. odps_client,
  2821. now=effective_now,
  2822. )
  2823. start_date, end_date = date_window(resolved_review_end_date)
  2824. except Exception as exc:
  2825. review_scope_error = str(exc)
  2826. review_context_error = str(exc)
  2827. logger.exception("creative review date range resolution failed")
  2828. else:
  2829. try:
  2830. daily = fetch_daily_data(odps_client, start_date, end_date)
  2831. except Exception as exc:
  2832. review_context_error = str(exc)
  2833. logger.exception("creative review agency context query failed")
  2834. try:
  2835. review_accounts = fetch_recent_spend_accounts(
  2836. odps_client,
  2837. start_date,
  2838. end_date,
  2839. )
  2840. except Exception as exc:
  2841. review_scope_error = str(exc)
  2842. logger.exception("creative review account scope query failed")
  2843. try:
  2844. context = build_agency_context(daily)
  2845. except Exception as exc:
  2846. context = build_agency_context(pd.DataFrame())
  2847. review_context_error = str(exc)
  2848. logger.exception("creative review agency context build failed")
  2849. review_account_ids = {
  2850. int(account["account_id"]) for account in review_accounts
  2851. }
  2852. accounts_by_id = {
  2853. int(account["account_id"]): account for account in review_accounts
  2854. }
  2855. performance_inventory: list[dict[str, Any]] = []
  2856. performance_sources_by_account: dict[
  2857. int, dict[int, dict[str, Any]]
  2858. ] = defaultdict(dict)
  2859. performance_scope_error: str | None = None
  2860. performance_account_metadata: dict[int, dict[str, str]] = {}
  2861. performance_account_metadata_error: str | None = None
  2862. underperformance_enabled = performance_enabled or ad_cleanup_enabled
  2863. if underperformance_enabled:
  2864. try:
  2865. performance_inventory = fetch_active_creative_inventory(odps_client)
  2866. except Exception as exc:
  2867. performance_scope_error = str(exc)
  2868. logger.exception("active creative inventory query failed")
  2869. else:
  2870. for source in performance_inventory:
  2871. account_id = int(source["account_id"])
  2872. creative_id = int(source["creative_id"])
  2873. performance_sources_by_account[account_id][creative_id] = source
  2874. accounts_by_id.setdefault(
  2875. account_id,
  2876. {"account_id": account_id, "account_name": ""},
  2877. )
  2878. if performance_sources_by_account:
  2879. try:
  2880. performance_account_metadata = fetch_tencent_account_metadata(
  2881. odps_client
  2882. )
  2883. except Exception as exc:
  2884. performance_account_metadata_error = str(exc)
  2885. logger.exception("tencent account metadata query failed")
  2886. else:
  2887. for account_id, metadata in performance_account_metadata.items():
  2888. if account_id not in performance_sources_by_account:
  2889. continue
  2890. account = accounts_by_id.setdefault(
  2891. account_id,
  2892. {"account_id": account_id, "account_name": ""},
  2893. )
  2894. if metadata.get("account_name"):
  2895. account["account_name"] = metadata["account_name"]
  2896. accounts = [accounts_by_id[key] for key in sorted(accounts_by_id)]
  2897. account_ids = [int(account["account_id"]) for account in accounts]
  2898. performance_account_ids = set(performance_sources_by_account)
  2899. performance_creation_times = {
  2900. (account_id, creative_id): created_at
  2901. for account_id, sources in performance_sources_by_account.items()
  2902. for creative_id, source in sources.items()
  2903. if (created_at := _as_shanghai_datetime(source.get("create_time")))
  2904. is not None
  2905. }
  2906. performance_source_keys = {
  2907. (account_id, creative_id)
  2908. for account_id, sources in performance_sources_by_account.items()
  2909. for creative_id in sources
  2910. }
  2911. performance_source_ad_keys = {
  2912. (account_id, adgroup_id)
  2913. for account_id, sources in performance_sources_by_account.items()
  2914. for source in sources.values()
  2915. if (adgroup_id := _as_int(source.get("adgroup_id"))) is not None
  2916. }
  2917. unresolved_account_ids = [
  2918. int(account["account_id"])
  2919. for account in review_accounts
  2920. if int(account["account_id"]) not in context["account_agencies"]
  2921. ]
  2922. if not underperformance_preview_only:
  2923. try:
  2924. context["fallback_account_agencies"] = fetch_account_agency_fallbacks(
  2925. odps_client,
  2926. unresolved_account_ids,
  2927. )
  2928. except Exception as exc:
  2929. if review_context_error is None:
  2930. review_context_error = str(exc)
  2931. logger.exception(
  2932. "account agency fallback query failed accounts=%d",
  2933. len(unresolved_account_ids),
  2934. )
  2935. owned_tencent = tencent is None
  2936. if tencent is None:
  2937. from tencent_client import TencentClient
  2938. client = TencentClient()
  2939. else:
  2940. client = tencent
  2941. prefetched_tokens = prefetch_account_access_tokens(account_ids)
  2942. seed_tokens = getattr(client, "seed_access_tokens", None)
  2943. if callable(seed_tokens):
  2944. seed_tokens(prefetched_tokens)
  2945. fetch_reviews = review_fetcher or fetch_dynamic_creative_review_results
  2946. discovered = 0
  2947. review_discovered = 0
  2948. performance_discovered = 0
  2949. scanned = 0
  2950. scanned_accounts: set[int] = set()
  2951. performance_scanned_accounts: set[int] = set()
  2952. ad_scanned_accounts: set[int] = set()
  2953. confirmed_actions: dict[tuple[int, int], dict[str, Any]] = {}
  2954. scan_errors: list[str] = []
  2955. if review_scope_error:
  2956. scan_errors.append(
  2957. f"creative review account scope query failed: {review_scope_error}"
  2958. )
  2959. if review_context_error:
  2960. scan_errors.append(
  2961. f"creative review agency context query failed: {review_context_error}"
  2962. )
  2963. if performance_scope_error:
  2964. scan_errors.append(
  2965. "active creative inventory query failed: "
  2966. f"{performance_scope_error}"
  2967. )
  2968. if performance_account_metadata_error:
  2969. scan_errors.append(
  2970. "tencent account metadata query failed: "
  2971. f"{performance_account_metadata_error}"
  2972. )
  2973. source_diagnostic_totals = {
  2974. "missing_tencent_creatives": 0,
  2975. "missing_tencent_ads": 0,
  2976. "ad_mismatches": 0,
  2977. "missing_source_ad_ids": 0,
  2978. "missing_create_time": 0,
  2979. }
  2980. try:
  2981. scan_workers = int(os.getenv("TENCENT_AD_ACCOUNT_SCAN_WORKERS", "8"))
  2982. if scan_workers < 1:
  2983. raise ValueError("TENCENT_AD_ACCOUNT_SCAN_WORKERS must be at least 1")
  2984. workers = min(scan_workers, len(accounts), 32) if accounts else 1
  2985. def run_scan(account: dict[str, Any]):
  2986. if owned_tencent:
  2987. from tencent_client import TencentClient
  2988. scan_client = TencentClient()
  2989. scan_client.seed_access_tokens(prefetched_tokens)
  2990. else:
  2991. scan_client = client
  2992. try:
  2993. return _scan_one_account(
  2994. account,
  2995. tencent=scan_client,
  2996. review_fetcher=fetch_reviews,
  2997. spend_start_date=spend_start_date,
  2998. spend_end_date=spend_end_date,
  2999. review_enabled=(
  3000. int(account["account_id"]) in review_account_ids
  3001. ),
  3002. performance_enabled=(
  3003. performance_enabled
  3004. and review_scope_error is None
  3005. and int(account["account_id"])
  3006. in performance_account_ids
  3007. ),
  3008. performance_source_creatives=(
  3009. performance_sources_by_account.get(
  3010. int(account["account_id"]), {}
  3011. )
  3012. ),
  3013. performance_start_date=performance_start_date,
  3014. performance_end_date=performance_end_date,
  3015. current_day_date=current_day_date,
  3016. performance_as_of=effective_now,
  3017. performance_config=performance_settings,
  3018. ad_cleanup_enabled=(
  3019. ad_cleanup_enabled
  3020. and int(account["account_id"])
  3021. in performance_account_ids
  3022. ),
  3023. ad_metric_start_date=ad_metric_start_date,
  3024. ad_metric_end_date=ad_metric_end_date,
  3025. )
  3026. finally:
  3027. if scan_client is not client:
  3028. scan_client.session.close()
  3029. logger.info("account scan started accounts=%d workers=%d", len(accounts), workers)
  3030. scan_results = []
  3031. with ThreadPoolExecutor(
  3032. max_workers=workers,
  3033. thread_name_prefix="creative-scan",
  3034. ) as executor:
  3035. futures = {executor.submit(run_scan, account): account for account in accounts}
  3036. for completed, future in enumerate(as_completed(futures), start=1):
  3037. account = futures[future]
  3038. account_id = int(account["account_id"])
  3039. try:
  3040. (
  3041. creatives,
  3042. ads,
  3043. raw_by_id,
  3044. cost_by_id,
  3045. spend_error,
  3046. account_scanned,
  3047. error,
  3048. performance_metrics,
  3049. performance_error,
  3050. ad_metrics,
  3051. ad_metric_error,
  3052. review_error,
  3053. source_diagnostics,
  3054. ) = future.result()
  3055. except Exception as exc:
  3056. creatives, ads, raw_by_id, cost_by_id = [], {}, {}, {}
  3057. spend_error, account_scanned = None, 0
  3058. error = f"account={account_id} scan failed: {exc}"
  3059. performance_metrics, performance_error = {}, None
  3060. ad_metrics, ad_metric_error = {}, None
  3061. review_error = None
  3062. source_diagnostics = {
  3063. key: 0 for key in source_diagnostic_totals
  3064. }
  3065. scanned += account_scanned
  3066. if error:
  3067. logger.error(error)
  3068. scan_errors.append(error)
  3069. else:
  3070. if account_id in review_account_ids:
  3071. if review_error:
  3072. scan_errors.append(
  3073. f"account={account_id} review scan failed: "
  3074. f"{review_error}"
  3075. )
  3076. else:
  3077. scanned_accounts.add(account_id)
  3078. if (
  3079. performance_enabled
  3080. and review_scope_error is None
  3081. and account_id in performance_account_ids
  3082. ):
  3083. if review_error and account_id in review_account_ids:
  3084. pass
  3085. elif performance_error:
  3086. scan_errors.append(
  3087. "account="
  3088. f"{account_id} performance scan failed: "
  3089. f"{performance_error}"
  3090. )
  3091. else:
  3092. performance_scanned_accounts.add(account_id)
  3093. if (
  3094. ad_cleanup_enabled
  3095. and account_id in performance_account_ids
  3096. ):
  3097. if ad_metric_error:
  3098. scan_errors.append(
  3099. f"account={account_id} ad performance scan failed: "
  3100. f"{ad_metric_error}"
  3101. )
  3102. else:
  3103. ad_scanned_accounts.add(account_id)
  3104. for key in source_diagnostic_totals:
  3105. source_diagnostic_totals[key] += int(
  3106. source_diagnostics.get(key) or 0
  3107. )
  3108. scan_results.append(
  3109. (
  3110. account,
  3111. creatives,
  3112. ads,
  3113. raw_by_id,
  3114. cost_by_id,
  3115. spend_error,
  3116. performance_metrics,
  3117. ad_metrics,
  3118. )
  3119. )
  3120. logger.info(
  3121. "account scan progress=%d/%d account=%d creatives=%d error=%s",
  3122. completed,
  3123. len(accounts),
  3124. account_id,
  3125. account_scanned,
  3126. bool(error),
  3127. )
  3128. if any(source_diagnostic_totals.values()):
  3129. logger.warning(
  3130. "active creative inventory mismatches=%s",
  3131. source_diagnostic_totals,
  3132. )
  3133. review_candidate_keys: set[tuple[int, int]] = set()
  3134. review_processing_failed_keys: set[tuple[int, int]] = set()
  3135. def process_creative(task, *, phase: str):
  3136. (
  3137. account,
  3138. account_id,
  3139. creative,
  3140. ads,
  3141. raw_by_id,
  3142. cost_by_id,
  3143. spend_error,
  3144. performance_metrics,
  3145. _ad_metrics,
  3146. ) = task
  3147. creative_id = _as_int(creative.get("dynamic_creative_id"))
  3148. adgroup_id = _as_int(creative.get("adgroup_id"))
  3149. if creative_id is None or adgroup_id is None:
  3150. return None
  3151. raw_result = raw_by_id.get(creative_id)
  3152. system_status = str(creative.get("system_status") or "")
  3153. is_partial = (
  3154. creative.get("creative_set_approval_status")
  3155. == CREATIVE_PARTIAL_NORMAL_STATUS
  3156. )
  3157. key = (account_id, creative_id)
  3158. action = None
  3159. if phase == "review" and account_id in scanned_accounts:
  3160. action = determine_cleanup_action(
  3161. creative,
  3162. raw_result,
  3163. recent_cost_fen=cost_by_id.get(creative_id),
  3164. cost_threshold_fen=cost_threshold_fen,
  3165. wechat_cost_threshold_fen=wechat_cost_threshold_fen,
  3166. spend_error=(spend_error if is_partial else None),
  3167. )
  3168. if phase == "performance":
  3169. if (
  3170. key in review_candidate_keys
  3171. or key in review_processing_failed_keys
  3172. or _is_deleted_creative(creative)
  3173. ):
  3174. return None
  3175. if not performance_enabled or key not in performance_source_keys:
  3176. return None
  3177. created_at = performance_creation_times.get(
  3178. (account_id, creative_id)
  3179. )
  3180. if created_at is None:
  3181. return None
  3182. elif account_id in performance_scanned_accounts:
  3183. metrics = performance_metrics.get(creative_id)
  3184. if metrics is not None:
  3185. action = determine_performance_cleanup_action(
  3186. creative,
  3187. ads.get(adgroup_id) or {},
  3188. creative_created_at=created_at,
  3189. as_of_date=effective_now,
  3190. impressions=metrics.get("impressions", 0),
  3191. cost_fen=metrics.get("cost_fen", 0),
  3192. current_day_cost_fen=metrics.get(
  3193. "current_day_cost_fen", 0
  3194. ),
  3195. metric_start_date=metrics.get(
  3196. "metric_start_date", performance_start_date
  3197. ),
  3198. metric_end_date=metrics.get(
  3199. "metric_end_date", performance_end_date
  3200. ),
  3201. config=performance_settings,
  3202. )
  3203. if action is None:
  3204. return None
  3205. ad = ads.get(adgroup_id) or {}
  3206. account_metadata = performance_account_metadata.get(
  3207. account_id, {}
  3208. )
  3209. if phase == "performance":
  3210. candidate_account_name = (
  3211. account_metadata.get("account_name") or ""
  3212. )
  3213. else:
  3214. candidate_account_name = (
  3215. context["account_names"].get(account_id)
  3216. or account.get("account_name")
  3217. or ""
  3218. )
  3219. record = {
  3220. "account_id": account_id,
  3221. "account_name": candidate_account_name,
  3222. "agent_name": (
  3223. account_metadata.get("agent_name")
  3224. if phase == "performance"
  3225. else ""
  3226. ),
  3227. "agency_name": (
  3228. ""
  3229. if _is_performance_rule(action.get("cleanup_rule_type"))
  3230. else _resolve_agency(context, account_id, creative_id)
  3231. ),
  3232. "adgroup_id": adgroup_id,
  3233. "adgroup_name": ad.get("adgroup_name") or "",
  3234. "dynamic_creative_id": creative_id,
  3235. "dynamic_creative_name": creative.get(
  3236. "dynamic_creative_name"
  3237. )
  3238. or "",
  3239. "check_date": effective_now.date(),
  3240. **action,
  3241. "action_reason": action.get("action_reason")
  3242. or _cleanup_reason(action, raw_result, system_status),
  3243. "reject_reason": (
  3244. ""
  3245. if _is_performance_rule(action.get("cleanup_rule_type"))
  3246. else _reject_reason(raw_result, system_status)
  3247. ),
  3248. "cost_start_date": action.get("cost_start_date")
  3249. or spend_start_date,
  3250. "cost_end_date": action.get("cost_end_date")
  3251. or spend_end_date,
  3252. "review_result": raw_result or {},
  3253. "pre_state": creative,
  3254. }
  3255. return account_id, creative_id, action, record
  3256. tasks = [
  3257. (
  3258. account,
  3259. int(account["account_id"]),
  3260. creative,
  3261. ads,
  3262. raw_by_id,
  3263. cost_by_id,
  3264. spend_error,
  3265. performance_metrics,
  3266. ad_metrics,
  3267. )
  3268. for (
  3269. account,
  3270. creatives,
  3271. ads,
  3272. raw_by_id,
  3273. cost_by_id,
  3274. spend_error,
  3275. performance_metrics,
  3276. ad_metrics,
  3277. )
  3278. in scan_results
  3279. for creative in creatives
  3280. if not _is_deleted_creative(creative)
  3281. ]
  3282. process_workers = (
  3283. min(
  3284. int(os.getenv("TENCENT_AD_CREATIVE_PROCESS_WORKERS", "8")),
  3285. len(tasks),
  3286. 32,
  3287. )
  3288. if tasks
  3289. else 1
  3290. )
  3291. candidate_batch_min_size = _positive_int_setting(
  3292. "DAILY_CLEANUP_CANDIDATE_BATCH_MIN_SIZE",
  3293. 20,
  3294. maximum=10000,
  3295. )
  3296. def persist_candidate_results(
  3297. candidate_results: list[
  3298. tuple[int, int, dict[str, Any], dict[str, Any]]
  3299. ],
  3300. *,
  3301. phase: str,
  3302. ) -> None:
  3303. nonlocal discovered, review_discovered, performance_discovered
  3304. if not candidate_results:
  3305. return
  3306. started_at = time.monotonic()
  3307. records = [result[3] for result in candidate_results]
  3308. logger.info(
  3309. "cleanup candidate storage phase=%s records=%d mode=%s",
  3310. phase,
  3311. len(records),
  3312. (
  3313. "batch"
  3314. if len(records) >= candidate_batch_min_size
  3315. else "single"
  3316. ),
  3317. )
  3318. if len(records) >= candidate_batch_min_size:
  3319. stored_records, storage_errors = upsert_cleanup_candidates(records)
  3320. else:
  3321. stored_records = []
  3322. storage_errors = []
  3323. for record in records:
  3324. try:
  3325. upsert_cleanup_candidate(record)
  3326. except Exception as exc:
  3327. storage_errors.append((record, str(exc)))
  3328. else:
  3329. stored_records.append(record)
  3330. result_by_key = {
  3331. (account_id, creative_id): (action, record)
  3332. for account_id, creative_id, action, record in candidate_results
  3333. }
  3334. stored_keys = {
  3335. (int(record["account_id"]), int(record["dynamic_creative_id"]))
  3336. for record in stored_records
  3337. }
  3338. for key in stored_keys:
  3339. action, _record = result_by_key[key]
  3340. confirmed_actions[key] = action
  3341. discovered += 1
  3342. if phase == "review":
  3343. review_candidate_keys.add(key)
  3344. review_discovered += 1
  3345. else:
  3346. performance_discovered += 1
  3347. for record, error_text in storage_errors:
  3348. account_id = int(record["account_id"])
  3349. creative_id = int(record["dynamic_creative_id"])
  3350. error = (
  3351. f"cleanup candidate storage failed phase={phase} "
  3352. f"account={account_id} target={creative_id}: {error_text}"
  3353. )
  3354. scan_errors.append(error)
  3355. if phase == "review":
  3356. review_processing_failed_keys.add((account_id, creative_id))
  3357. logger.error(error)
  3358. logger.info(
  3359. "cleanup candidate storage completed phase=%s stored=%d "
  3360. "failed=%d duration_ms=%d",
  3361. phase,
  3362. len(stored_records),
  3363. len(storage_errors),
  3364. int((time.monotonic() - started_at) * 1000),
  3365. )
  3366. for processing_phase in ("review", "performance"):
  3367. if processing_phase == "review" and underperformance_preview_only:
  3368. continue
  3369. if processing_phase == "performance" and not performance_enabled:
  3370. continue
  3371. phase_started_at = time.monotonic()
  3372. phase_candidates: list[
  3373. tuple[int, int, dict[str, Any], dict[str, Any]]
  3374. ] = []
  3375. logger.info(
  3376. "creative processing phase=%s creatives=%d workers=%d",
  3377. processing_phase,
  3378. len(tasks),
  3379. process_workers,
  3380. )
  3381. with ThreadPoolExecutor(
  3382. max_workers=process_workers,
  3383. thread_name_prefix=f"creative-{processing_phase}",
  3384. ) as executor:
  3385. futures = {
  3386. executor.submit(
  3387. process_creative,
  3388. task,
  3389. phase=processing_phase,
  3390. ): task
  3391. for task in tasks
  3392. }
  3393. for completed, future in enumerate(as_completed(futures), start=1):
  3394. task = futures[future]
  3395. account_id = task[1]
  3396. creative_id = _as_int(task[2].get("dynamic_creative_id"))
  3397. try:
  3398. result = future.result()
  3399. except Exception as exc:
  3400. error = (
  3401. f"creative {processing_phase} processing failed "
  3402. f"account={account_id} creative={creative_id}: {exc}"
  3403. )
  3404. scan_errors.append(error)
  3405. if processing_phase == "review" and creative_id is not None:
  3406. review_processing_failed_keys.add(
  3407. (account_id, creative_id)
  3408. )
  3409. logger.exception(error)
  3410. else:
  3411. if result is not None:
  3412. phase_candidates.append(result)
  3413. if completed % 500 == 0 or completed == len(tasks):
  3414. logger.info(
  3415. "creative processing phase=%s progress=%d/%d confirmed=%d",
  3416. processing_phase,
  3417. completed,
  3418. len(tasks),
  3419. len(phase_candidates),
  3420. )
  3421. logger.info(
  3422. "creative processing completed phase=%s creatives=%d "
  3423. "confirmed=%d duration_ms=%d",
  3424. processing_phase,
  3425. len(tasks),
  3426. len(phase_candidates),
  3427. int((time.monotonic() - phase_started_at) * 1000),
  3428. )
  3429. persist_candidate_results(
  3430. phase_candidates,
  3431. phase=processing_phase,
  3432. )
  3433. # 广告判断必须排在全部创意判断之后。审计表沿用同一幂等链路,
  3434. # 用负的广告 ID 作为内部目标键,避免与真实创意 ID 冲突;报表不展示该值。
  3435. ad_discovered = 0
  3436. if ad_cleanup_enabled:
  3437. ad_phase_started_at = time.monotonic()
  3438. ad_tasks = [
  3439. (account, int(account["account_id"]), adgroup_id, ad, ad_metrics)
  3440. for (
  3441. account,
  3442. _creatives,
  3443. ads,
  3444. _raw_by_id,
  3445. _cost_by_id,
  3446. _spend_error,
  3447. _performance_metrics,
  3448. ad_metrics,
  3449. ) in scan_results
  3450. if int(account["account_id"]) in ad_scanned_accounts
  3451. for adgroup_id, ad in ads.items()
  3452. ]
  3453. ad_workers = min(
  3454. _positive_int_setting(
  3455. "TENCENT_AD_AD_PROCESS_WORKERS",
  3456. 8,
  3457. maximum=32,
  3458. ),
  3459. len(ad_tasks),
  3460. ) if ad_tasks else 1
  3461. logger.info(
  3462. "ad processing phase=performance ads=%d workers=%d",
  3463. len(ad_tasks),
  3464. ad_workers,
  3465. )
  3466. def process_ad(task):
  3467. account, account_id, adgroup_id, ad, ad_metrics = task
  3468. metrics = ad_metrics.get(adgroup_id)
  3469. if metrics is None:
  3470. return None
  3471. action = determine_ad_performance_cleanup_action(
  3472. ad,
  3473. as_of_date=effective_now,
  3474. cost_fen=metrics.get("cost_fen", 0),
  3475. current_day_cost_fen=metrics.get("current_day_cost_fen", 0),
  3476. metric_start_date=ad_metric_start_date,
  3477. metric_end_date=ad_metric_end_date,
  3478. window_days=ad_window_days,
  3479. min_age_days=ad_min_age_days,
  3480. )
  3481. if action is None:
  3482. return None
  3483. target_id = -int(adgroup_id)
  3484. account_metadata = performance_account_metadata.get(
  3485. account_id, {}
  3486. )
  3487. record = {
  3488. "account_id": account_id,
  3489. "account_name": account_metadata.get("account_name") or "",
  3490. "agent_name": account_metadata.get("agent_name") or "",
  3491. "agency_name": "",
  3492. "adgroup_id": adgroup_id,
  3493. "adgroup_name": ad.get("adgroup_name") or "",
  3494. "dynamic_creative_id": target_id,
  3495. "dynamic_creative_name": "",
  3496. "check_date": effective_now.date(),
  3497. **action,
  3498. "reject_reason": "",
  3499. "review_result": {},
  3500. "pre_state": ad,
  3501. }
  3502. return account_id, target_id, action, record
  3503. ad_candidates: list[
  3504. tuple[int, int, dict[str, Any], dict[str, Any]]
  3505. ] = []
  3506. with ThreadPoolExecutor(
  3507. max_workers=ad_workers,
  3508. thread_name_prefix="ad-performance",
  3509. ) as executor:
  3510. futures = {
  3511. executor.submit(process_ad, task): task for task in ad_tasks
  3512. }
  3513. for completed, future in enumerate(as_completed(futures), start=1):
  3514. task = futures[future]
  3515. try:
  3516. result = future.result()
  3517. except Exception as exc:
  3518. error = (
  3519. "ad performance processing failed "
  3520. f"account={task[1]} ad={task[2]}: {exc}"
  3521. )
  3522. scan_errors.append(error)
  3523. logger.exception(error)
  3524. else:
  3525. if result is not None:
  3526. ad_candidates.append(result)
  3527. if completed % 500 == 0 or completed == len(ad_tasks):
  3528. logger.info(
  3529. "ad processing phase=performance progress=%d/%d "
  3530. "confirmed=%d",
  3531. completed,
  3532. len(ad_tasks),
  3533. len(ad_candidates),
  3534. )
  3535. logger.info(
  3536. "ad processing completed ads=%d confirmed=%d duration_ms=%d",
  3537. len(ad_tasks),
  3538. len(ad_candidates),
  3539. int((time.monotonic() - ad_phase_started_at) * 1000),
  3540. )
  3541. before_performance_discovered = performance_discovered
  3542. persist_candidate_results(ad_candidates, phase="ad_performance")
  3543. ad_discovered = (
  3544. performance_discovered - before_performance_discovered
  3545. )
  3546. deleted = 0
  3547. deferred = 0
  3548. delete_errors: list[str] = []
  3549. write_lock_name = os.getenv(
  3550. "RTC_DB_LOCK_NAME", "tencent_realtime_control"
  3551. )
  3552. retryable_load_started_at = time.monotonic()
  3553. retryable_items = load_retryable_cleanup_items()
  3554. logger.info(
  3555. "cleanup retryable items loaded rows=%d duration_ms=%d",
  3556. len(retryable_items),
  3557. int((time.monotonic() - retryable_load_started_at) * 1000),
  3558. )
  3559. def item_apply_enabled(item: dict[str, Any]) -> bool:
  3560. if _is_ad_performance_rule(item.get("cleanup_rule_type")):
  3561. return ad_apply_enabled
  3562. if _is_performance_rule(item.get("cleanup_rule_type")):
  3563. return performance_apply_enabled
  3564. return apply_enabled
  3565. # 阶段一(锁外):纯前置判断,无腾讯写。通过者进入 deletable_items。
  3566. deletable_items: list[dict[str, Any]] = []
  3567. for item in retryable_items:
  3568. if not item_apply_enabled(item):
  3569. continue
  3570. item_id = int(item["id"])
  3571. account_id = int(item["account_id"])
  3572. creative_id = int(item["dynamic_creative_id"])
  3573. snapshot_status = str(item.get("cleanup_status") or "")
  3574. if snapshot_status == "DELETING":
  3575. # 只有取得腾讯全局写锁后才恢复超时认领,避免改动仍由其他实例执行的记录。
  3576. deletable_items.append(item)
  3577. continue
  3578. if item.get("cleanup_action") not in {DELETE_CREATIVE, DELETE_AD}:
  3579. update_cleanup_item(
  3580. item_id,
  3581. _expected_cleanup_status=snapshot_status,
  3582. cleanup_status="SKIPPED_REVIEW_NOT_RECONFIRMED",
  3583. error_message="当前规则仅允许删除整条创意",
  3584. )
  3585. continue
  3586. is_performance_item = _is_performance_rule(
  3587. item.get("cleanup_rule_type")
  3588. )
  3589. agency = (
  3590. ""
  3591. if is_performance_item
  3592. else str(item.get("agency_name") or "")
  3593. or _resolve_agency(context, account_id, creative_id)
  3594. )
  3595. if is_performance_item and item.get("agency_name"):
  3596. update_cleanup_item(
  3597. item_id,
  3598. _expected_cleanup_status=snapshot_status,
  3599. agency_name="",
  3600. agency_notified_at=(
  3601. item.get("agency_notified_at") or effective_now
  3602. ),
  3603. )
  3604. item["agency_name"] = ""
  3605. elif agency and agency != item.get("agency_name"):
  3606. update_cleanup_item(
  3607. item_id,
  3608. _expected_cleanup_status=snapshot_status,
  3609. agency_name=agency,
  3610. )
  3611. item["agency_name"] = agency
  3612. webhook_url = resolve_agency_webhook(
  3613. agency, webhook_config.webhooks or {}
  3614. )
  3615. if not is_performance_item and (not agency or not webhook_url):
  3616. reason = (
  3617. "代理商归属为空,禁止自动删除"
  3618. if not agency
  3619. else f"代理商 {agency} 未配置通知群,禁止自动删除"
  3620. )
  3621. updated = update_cleanup_item(
  3622. item_id,
  3623. _expected_cleanup_status=snapshot_status,
  3624. cleanup_status="DEFERRED",
  3625. error_message=reason,
  3626. )
  3627. if updated is not False:
  3628. deferred += 1
  3629. continue
  3630. if item.get("cleanup_status") not in {
  3631. "WRITE_OUTCOME_UNKNOWN",
  3632. "DELETING",
  3633. }:
  3634. precondition_failure = cleanup_precondition_failure(
  3635. item,
  3636. scanned_accounts,
  3637. confirmed_actions,
  3638. performance_scanned_accounts,
  3639. ad_scanned_accounts,
  3640. performance_scope_loaded=(
  3641. performance_scope_error is None
  3642. ),
  3643. performance_source_keys=performance_source_keys,
  3644. performance_source_ad_keys=performance_source_ad_keys,
  3645. )
  3646. if precondition_failure:
  3647. status, reason = precondition_failure
  3648. updated = update_cleanup_item(
  3649. item_id,
  3650. _expected_cleanup_status=snapshot_status,
  3651. cleanup_status=status,
  3652. error_message=reason,
  3653. )
  3654. if status == "DEFERRED" and updated is not False:
  3655. deferred += 1
  3656. continue
  3657. deletable_items.append(item)
  3658. # 阶段二(锁内并发):整个删除批次持锁一次,锁内并发回读/复审/删除。
  3659. # 每 worker 用独立 TencentClient(requests.Session 非线程安全),
  3660. # 避免共享 session 并发导致不可预测行为(与扫描阶段一致)。
  3661. if deletable_items:
  3662. configured_delete_workers = int(
  3663. os.getenv("TENCENT_AD_DELETE_WORKERS", "4")
  3664. )
  3665. if configured_delete_workers < 1:
  3666. raise ValueError("TENCENT_AD_DELETE_WORKERS must be at least 1")
  3667. delete_workers = min(
  3668. configured_delete_workers,
  3669. len(deletable_items),
  3670. 16,
  3671. )
  3672. if not owned_tencent:
  3673. # 调用方传入的客户端可能封装 requests.Session,不能假定它线程安全。
  3674. delete_workers = 1
  3675. delete_worker_local = threading.local()
  3676. delete_worker_clients = []
  3677. delete_worker_clients_lock = threading.Lock()
  3678. def delete_one(item, delete_client):
  3679. """锁内单条创意:回读 → 复审 → 删除;返回 (deleted, deferred, error)。"""
  3680. item_id = int(item["id"])
  3681. account_id = int(item["account_id"])
  3682. creative_id = int(item["dynamic_creative_id"])
  3683. adgroup_id = int(item["adgroup_id"])
  3684. is_ad_item = _is_ad_performance_rule(
  3685. item.get("cleanup_rule_type")
  3686. )
  3687. try:
  3688. if now is None or clock_was_provided:
  3689. if not _is_current_cleanup_day(
  3690. current_day_date,
  3691. clock_fn(),
  3692. ):
  3693. _update_owned_cleanup_item(
  3694. item_id,
  3695. cleanup_status="DEFERRED",
  3696. error_message=(
  3697. "任务已跨上海自然日,旧指标窗口禁止执行删除"
  3698. ),
  3699. )
  3700. return 0, 1, None
  3701. try:
  3702. before = (
  3703. delete_client.get_ad(account_id, adgroup_id)
  3704. if is_ad_item
  3705. else delete_client.get_dynamic_creative(
  3706. account_id, creative_id
  3707. )
  3708. )
  3709. except Exception as read_exc:
  3710. if is_ad_item and str(read_exc).startswith(
  3711. "Ad not found after update:"
  3712. ):
  3713. _update_owned_cleanup_item(
  3714. item_id,
  3715. cleanup_status="DEFERRED",
  3716. error_message=(
  3717. "按广告ID查询(含已删除范围)仍未返回广告,"
  3718. "无法确认 is_deleted/system_status,禁止推断为已删除"
  3719. ),
  3720. )
  3721. return 0, 1, None
  3722. if (
  3723. not is_ad_item
  3724. and item.get("cleanup_action") == DELETE_CREATIVE
  3725. and str(read_exc).startswith(
  3726. "Dynamic creative not found:"
  3727. )
  3728. ):
  3729. updated = _update_owned_cleanup_item(
  3730. item_id,
  3731. cleanup_status="CREATIVE_DELETED",
  3732. error_message=None,
  3733. readback_json=_json({"deleted_from_listing": True}),
  3734. deleted_at=effective_now,
  3735. )
  3736. if updated is not False:
  3737. return 1, 0, None
  3738. return 0, 0, (
  3739. f"account={account_id} creative={creative_id}: "
  3740. "delete result ignored because claim ownership was lost"
  3741. )
  3742. raise
  3743. already_deleted = (
  3744. _is_deleted_ad(before)
  3745. if is_ad_item
  3746. else _is_deleted_creative(before)
  3747. )
  3748. if already_deleted:
  3749. updated = _update_owned_cleanup_item(
  3750. item_id,
  3751. cleanup_status=(
  3752. "AD_DELETED" if is_ad_item
  3753. else "CREATIVE_DELETED"
  3754. ),
  3755. error_message=None,
  3756. pre_state_json=_json(before),
  3757. readback_json=_json(before),
  3758. deleted_at=effective_now,
  3759. )
  3760. if updated is not False:
  3761. return 1, 0, None
  3762. return 0, 0, (
  3763. f"account={account_id} creative={creative_id}: "
  3764. "delete result ignored because claim ownership was lost"
  3765. )
  3766. if item.get("cleanup_status") in {
  3767. "WRITE_OUTCOME_UNKNOWN",
  3768. "DELETING",
  3769. }:
  3770. precondition_failure = cleanup_precondition_failure(
  3771. item,
  3772. scanned_accounts,
  3773. confirmed_actions,
  3774. performance_scanned_accounts,
  3775. ad_scanned_accounts,
  3776. performance_scope_loaded=(
  3777. performance_scope_error is None
  3778. ),
  3779. performance_source_keys=performance_source_keys,
  3780. performance_source_ad_keys=(
  3781. performance_source_ad_keys
  3782. ),
  3783. )
  3784. if precondition_failure:
  3785. status, reason = precondition_failure
  3786. _update_owned_cleanup_item(
  3787. item_id,
  3788. cleanup_status=status,
  3789. error_message=reason,
  3790. )
  3791. if status == "DEFERRED":
  3792. return 0, 1, None
  3793. return 0, 0, None
  3794. action = str(item.get("cleanup_action") or "")
  3795. expected_rule_type = str(
  3796. item.get("cleanup_rule_type") or REVIEW_DENIED_RULE
  3797. )
  3798. fresh_raw = None
  3799. if _is_ad_performance_rule(expected_rule_type):
  3800. fresh_metrics = delete_client.get_ad_metrics(
  3801. account_id,
  3802. [adgroup_id],
  3803. ad_metric_start_date,
  3804. ad_metric_end_date,
  3805. ).get(adgroup_id)
  3806. if fresh_metrics is None:
  3807. raise RuntimeError(
  3808. "Tencent ad metric response omitted requested ID"
  3809. )
  3810. fresh_today_metrics = delete_client.get_ad_metrics(
  3811. account_id,
  3812. [adgroup_id],
  3813. current_day_date,
  3814. current_day_date,
  3815. ).get(adgroup_id)
  3816. if fresh_today_metrics is None:
  3817. raise RuntimeError(
  3818. "Tencent current-day ad metric response omitted "
  3819. "requested ID"
  3820. )
  3821. fresh_action = determine_ad_performance_cleanup_action(
  3822. before,
  3823. as_of_date=effective_now,
  3824. cost_fen=fresh_metrics.get("cost_fen", 0),
  3825. current_day_cost_fen=fresh_today_metrics.get(
  3826. "cost_fen", 0
  3827. ),
  3828. metric_start_date=ad_metric_start_date,
  3829. metric_end_date=ad_metric_end_date,
  3830. window_days=ad_window_days,
  3831. min_age_days=ad_min_age_days,
  3832. )
  3833. elif _is_performance_rule(expected_rule_type):
  3834. before_ad = delete_client.get_ad(
  3835. account_id,
  3836. int(item["adgroup_id"]),
  3837. )
  3838. created_at = performance_creation_times.get(
  3839. (account_id, creative_id)
  3840. )
  3841. if created_at is None:
  3842. raise RuntimeError(
  3843. "active creative inventory omitted creation time"
  3844. )
  3845. fresh_metric_start_date = performance_start_date
  3846. fresh_metric_end_date = performance_end_date
  3847. if expected_rule_type == PERFORMANCE_NEW_RULE:
  3848. fresh_metric_start_date = created_at.date()
  3849. fresh_metrics = delete_client.get_dynamic_creative_metrics(
  3850. account_id,
  3851. [creative_id],
  3852. fresh_metric_start_date,
  3853. fresh_metric_end_date,
  3854. ).get(creative_id)
  3855. if fresh_metrics is None:
  3856. raise RuntimeError(
  3857. "Tencent creative metric response omitted requested ID"
  3858. )
  3859. fresh_today_metrics = (
  3860. delete_client.get_dynamic_creative_metrics(
  3861. account_id,
  3862. [creative_id],
  3863. current_day_date,
  3864. current_day_date,
  3865. ).get(creative_id)
  3866. )
  3867. if fresh_today_metrics is None:
  3868. raise RuntimeError(
  3869. "Tencent current-day creative metric response "
  3870. "omitted requested ID"
  3871. )
  3872. fresh_impressions = int(
  3873. fresh_metrics.get("impressions", 0)
  3874. )
  3875. fresh_cost_fen = int(fresh_metrics.get("cost_fen", 0))
  3876. if expected_rule_type == PERFORMANCE_NEW_RULE:
  3877. fresh_impressions += int(
  3878. fresh_today_metrics.get("impressions", 0)
  3879. )
  3880. fresh_cost_fen += int(
  3881. fresh_today_metrics.get("cost_fen", 0)
  3882. )
  3883. fresh_metric_end_date = current_day_date
  3884. fresh_action = determine_performance_cleanup_action(
  3885. before,
  3886. before_ad,
  3887. creative_created_at=created_at,
  3888. as_of_date=effective_now,
  3889. impressions=fresh_impressions,
  3890. cost_fen=fresh_cost_fen,
  3891. current_day_cost_fen=fresh_today_metrics.get(
  3892. "cost_fen", 0
  3893. ),
  3894. metric_start_date=fresh_metric_start_date,
  3895. metric_end_date=fresh_metric_end_date,
  3896. config=performance_settings,
  3897. )
  3898. else:
  3899. approval_status = str(
  3900. before.get("creative_set_approval_status") or ""
  3901. )
  3902. if approval_status == CREATIVE_DENIED_STATUS:
  3903. fresh_action = determine_cleanup_action(before, None)
  3904. else:
  3905. fresh_results = fetch_reviews(account_id, [creative_id])
  3906. fresh_raw = next(
  3907. (
  3908. result
  3909. for result in fresh_results
  3910. if _as_int(result.get("dynamic_creative_id"))
  3911. == creative_id
  3912. ),
  3913. None,
  3914. )
  3915. fresh_cost_fen = None
  3916. fresh_spend_error = None
  3917. if approval_status == CREATIVE_PARTIAL_NORMAL_STATUS:
  3918. try:
  3919. fresh_cost_fen = (
  3920. delete_client.get_dynamic_creative_costs(
  3921. account_id,
  3922. [creative_id],
  3923. spend_start_date,
  3924. spend_end_date,
  3925. ).get(creative_id, 0)
  3926. )
  3927. except Exception as spend_exc:
  3928. fresh_spend_error = str(spend_exc)
  3929. fresh_action = determine_cleanup_action(
  3930. before,
  3931. fresh_raw,
  3932. recent_cost_fen=fresh_cost_fen,
  3933. cost_threshold_fen=cost_threshold_fen,
  3934. wechat_cost_threshold_fen=wechat_cost_threshold_fen,
  3935. spend_error=fresh_spend_error,
  3936. )
  3937. if not _same_cleanup_action(
  3938. action,
  3939. fresh_action,
  3940. expected_rule_type=expected_rule_type,
  3941. ):
  3942. if (
  3943. fresh_action
  3944. and fresh_action.get("cleanup_action") == ALERT_ONLY
  3945. ):
  3946. _update_owned_cleanup_item(
  3947. item_id,
  3948. cleanup_action=ALERT_ONLY,
  3949. cleanup_rule_type=fresh_action.get(
  3950. "cleanup_rule_type"
  3951. ),
  3952. target_component_ids_json="[]",
  3953. target_element_ids_json="[]",
  3954. recent_cost_fen=fresh_action.get("recent_cost_fen"),
  3955. cost_start_date=spend_start_date,
  3956. cost_end_date=spend_end_date,
  3957. action_reason=fresh_action["action_reason"],
  3958. reject_reason=_reject_reason(
  3959. fresh_raw,
  3960. str(before.get("system_status") or ""),
  3961. ),
  3962. review_result_json=_json(fresh_raw or {}),
  3963. cleanup_status="ALERT_PENDING",
  3964. error_message=None,
  3965. pre_state_json=_json(before),
  3966. readback_json=None,
  3967. deleted_at=None,
  3968. notified_at=None,
  3969. )
  3970. return 0, 0, None
  3971. updated = _update_owned_cleanup_item(
  3972. item_id,
  3973. cleanup_status="SKIPPED_REVIEW_NOT_RECONFIRMED",
  3974. error_message=(
  3975. "写锁内回读发现广告删除条件已变化"
  3976. if is_ad_item
  3977. else "写锁内回读发现整创意删除条件已变化"
  3978. ),
  3979. pre_state_json=_json(before),
  3980. )
  3981. return 0, 0, None
  3982. if (
  3983. (now is None or clock_was_provided)
  3984. and not _is_current_cleanup_day(
  3985. current_day_date,
  3986. clock_fn(),
  3987. )
  3988. ):
  3989. _update_owned_cleanup_item(
  3990. item_id,
  3991. cleanup_status="DEFERRED",
  3992. error_message=(
  3993. "腾讯写前已跨上海自然日,旧指标窗口禁止执行删除"
  3994. ),
  3995. )
  3996. return 0, 1, None
  3997. if action == DELETE_CREATIVE:
  3998. readback = delete_client.delete_dynamic_creative(
  3999. account_id, creative_id
  4000. )
  4001. updated = _update_owned_cleanup_item(
  4002. item_id,
  4003. cleanup_status="CREATIVE_DELETED",
  4004. error_message=None,
  4005. pre_state_json=_json(before),
  4006. readback_json=_json(readback),
  4007. deleted_at=effective_now,
  4008. )
  4009. if updated is not False:
  4010. return 1, 0, None
  4011. return 0, 0, (
  4012. f"account={account_id} creative={creative_id}: "
  4013. "delete result ignored because claim ownership was lost"
  4014. )
  4015. if action == DELETE_AD:
  4016. readback = delete_client.delete_ad(account_id, adgroup_id)
  4017. updated = _update_owned_cleanup_item(
  4018. item_id,
  4019. cleanup_status="AD_DELETED",
  4020. error_message=None,
  4021. pre_state_json=_json(before),
  4022. readback_json=_json(readback),
  4023. deleted_at=effective_now,
  4024. )
  4025. if updated is not False:
  4026. return 1, 0, None
  4027. return 0, 0, (
  4028. f"account={account_id} ad={adgroup_id}: "
  4029. "delete result ignored because claim ownership was lost"
  4030. )
  4031. return 0, 0, None
  4032. except Exception as exc:
  4033. from tencent_client import (
  4034. PostWriteVerificationError,
  4035. TencentWriteOutcomeUnknownError,
  4036. TencentWriteRateLimitedError,
  4037. )
  4038. outcome_unknown = isinstance(
  4039. exc,
  4040. (
  4041. TencentWriteOutcomeUnknownError,
  4042. PostWriteVerificationError,
  4043. ),
  4044. )
  4045. rate_limited = isinstance(exc, TencentWriteRateLimitedError)
  4046. error = f"account={account_id} creative={creative_id}: {exc}"
  4047. try:
  4048. _update_owned_cleanup_item(
  4049. item_id,
  4050. cleanup_status=(
  4051. "DEFERRED"
  4052. if rate_limited
  4053. else (
  4054. "WRITE_OUTCOME_UNKNOWN"
  4055. if outcome_unknown
  4056. else "FAILED"
  4057. )
  4058. ),
  4059. error_message=str(exc)[:4000],
  4060. )
  4061. except Exception as update_exc:
  4062. logger.exception(
  4063. "creative delete failure status update failed "
  4064. "account=%d creative=%d",
  4065. account_id,
  4066. creative_id,
  4067. )
  4068. error += f"; status_update_failed={update_exc}"
  4069. return 0, int(rate_limited), error
  4070. def run_delete(item):
  4071. if owned_tencent:
  4072. delete_client = getattr(delete_worker_local, "client", None)
  4073. if delete_client is None:
  4074. from tencent_client import TencentClient
  4075. delete_client = TencentClient()
  4076. delete_client.seed_access_tokens(prefetched_tokens)
  4077. delete_worker_local.client = delete_client
  4078. with delete_worker_clients_lock:
  4079. delete_worker_clients.append(delete_client)
  4080. else:
  4081. delete_client = client
  4082. return delete_one(item, delete_client)
  4083. with advisory_lock(write_lock_name) as acquired:
  4084. if not acquired:
  4085. for item in deletable_items:
  4086. snapshot_status = str(item.get("cleanup_status") or "")
  4087. if snapshot_status == "DELETING":
  4088. continue
  4089. updated = update_cleanup_item(
  4090. int(item["id"]),
  4091. _expected_cleanup_status=snapshot_status,
  4092. cleanup_status="DEFERRED",
  4093. error_message="腾讯写锁被实时调控占用",
  4094. )
  4095. if updated is not False:
  4096. deferred += 1
  4097. else:
  4098. claimed_items = []
  4099. for item in deletable_items:
  4100. try:
  4101. if claim_cleanup_item(int(item["id"])):
  4102. claimed_items.append(item)
  4103. except Exception as exc:
  4104. error = (
  4105. "creative delete claim failed "
  4106. f"account={item['account_id']} "
  4107. f"creative={item['dynamic_creative_id']}: {exc}"
  4108. )
  4109. delete_errors.append(error)
  4110. logger.exception(error)
  4111. executable_items = []
  4112. for item in claimed_items:
  4113. if _is_performance_rule(item.get("cleanup_rule_type")):
  4114. executable_items.append(item)
  4115. continue
  4116. agency = str(item.get("agency_name") or "") or _resolve_agency(
  4117. context,
  4118. int(item["account_id"]),
  4119. int(item["dynamic_creative_id"]),
  4120. )
  4121. webhook_url = resolve_agency_webhook(
  4122. agency, webhook_config.webhooks or {}
  4123. )
  4124. if agency and webhook_url:
  4125. if agency != item.get("agency_name"):
  4126. _update_owned_cleanup_item(
  4127. int(item["id"]),
  4128. agency_name=agency,
  4129. )
  4130. item["agency_name"] = agency
  4131. executable_items.append(item)
  4132. continue
  4133. reason = (
  4134. "代理商归属为空,禁止自动删除"
  4135. if not agency
  4136. else f"代理商 {agency} 未配置通知群,禁止自动删除"
  4137. )
  4138. updated = _update_owned_cleanup_item(
  4139. int(item["id"]),
  4140. cleanup_status="DEFERRED",
  4141. error_message=reason,
  4142. )
  4143. if updated is not False:
  4144. deferred += 1
  4145. logger.info(
  4146. "cleanup delete started candidates=%d claimed=%d workers=%d",
  4147. len(deletable_items),
  4148. len(executable_items),
  4149. delete_workers,
  4150. )
  4151. if executable_items:
  4152. try:
  4153. delete_phases = (
  4154. (
  4155. "review",
  4156. [
  4157. item for item in executable_items
  4158. if not _is_performance_rule(
  4159. item.get("cleanup_rule_type")
  4160. )
  4161. ],
  4162. ),
  4163. (
  4164. "underperforming-creative",
  4165. [
  4166. item for item in executable_items
  4167. if _is_performance_rule(
  4168. item.get("cleanup_rule_type")
  4169. )
  4170. and not _is_ad_performance_rule(
  4171. item.get("cleanup_rule_type")
  4172. )
  4173. ],
  4174. ),
  4175. (
  4176. "ad",
  4177. [
  4178. item for item in executable_items
  4179. if _is_ad_performance_rule(
  4180. item.get("cleanup_rule_type")
  4181. )
  4182. ],
  4183. ),
  4184. )
  4185. for phase_name, phase_items in delete_phases:
  4186. if not phase_items:
  4187. continue
  4188. logger.info(
  4189. "cleanup delete phase=%s items=%d",
  4190. phase_name,
  4191. len(phase_items),
  4192. )
  4193. with ThreadPoolExecutor(
  4194. max_workers=min(delete_workers, len(phase_items)),
  4195. thread_name_prefix=f"{phase_name}-delete",
  4196. ) as executor:
  4197. futures = {
  4198. executor.submit(run_delete, item): item
  4199. for item in phase_items
  4200. }
  4201. for future in as_completed(futures):
  4202. item = futures[future]
  4203. try:
  4204. d_deleted, d_deferred, d_error = future.result()
  4205. except Exception as exc:
  4206. d_deleted = 0
  4207. d_deferred = 0
  4208. d_error = (
  4209. "cleanup delete worker failed "
  4210. f"account={item['account_id']} "
  4211. f"target={item['dynamic_creative_id']}: {exc}"
  4212. )
  4213. logger.exception(d_error)
  4214. try:
  4215. _update_owned_cleanup_item(
  4216. int(item["id"]),
  4217. cleanup_status="FAILED",
  4218. error_message=str(exc)[:4000],
  4219. )
  4220. except Exception as update_exc:
  4221. logger.exception(
  4222. "cleanup delete worker failure status "
  4223. "update failed account=%s target=%s",
  4224. item["account_id"],
  4225. item["dynamic_creative_id"],
  4226. )
  4227. d_error += (
  4228. f"; status_update_failed={update_exc}"
  4229. )
  4230. deleted += d_deleted
  4231. deferred += d_deferred
  4232. if d_error:
  4233. delete_errors.append(d_error)
  4234. finally:
  4235. for delete_worker_client in delete_worker_clients:
  4236. delete_worker_client.session.close()
  4237. deliveries: list[dict[str, object]] = []
  4238. operator_deliveries: list[dict[str, object]] = []
  4239. notification_errors: list[str] = []
  4240. def publish_pending_notifications(
  4241. pending_notifications: list[dict[str, Any]],
  4242. ) -> None:
  4243. owned_publisher = publisher is None
  4244. sheet_publisher = publisher or RoiFeishuPublisher(require_chat_ids=False)
  4245. try:
  4246. rows_by_date: dict[str, list[dict[str, Any]]] = defaultdict(list)
  4247. for row in pending_notifications:
  4248. rows_by_date[_display_date(row.get("check_date"))].append(row)
  4249. for check_date, daily_rows in sorted(rows_by_date.items()):
  4250. daily_started_at = time.monotonic()
  4251. report_date = check_date.replace("-", "")
  4252. report_dir = output_dir / report_date
  4253. (
  4254. agency_rows,
  4255. operator_rows,
  4256. performance_rows,
  4257. ) = split_notification_rows(daily_rows)
  4258. logger.info(
  4259. "cleanup notification preparing date=%s agency_rows=%d "
  4260. "operator_rows=%d performance_rows=%d",
  4261. check_date,
  4262. len(agency_rows),
  4263. len(operator_rows),
  4264. len(performance_rows),
  4265. )
  4266. run_id = f"reject_{report_date}_{REPORT_VERSION}"
  4267. if agency_rows and webhook_config.enabled:
  4268. run_id, reports, report_item_ids = write_cleanup_reports(
  4269. agency_rows,
  4270. report_dir,
  4271. report_date,
  4272. )
  4273. for report in reports:
  4274. daily_deliveries = publish_agency_reports(
  4275. run_id=str(report.get("run_id") or run_id),
  4276. reports=[report],
  4277. config=webhook_config,
  4278. publisher=sheet_publisher,
  4279. notifier=notifier,
  4280. now=effective_now,
  4281. upsert_delivery=upsert_cleanup_delivery,
  4282. update_delivery=update_cleanup_delivery,
  4283. )
  4284. deliveries.extend(daily_deliveries)
  4285. for outcome in daily_deliveries:
  4286. agency_name = str(outcome["agency_name"])
  4287. if outcome.get("status") == "SENT":
  4288. mark_cleanup_items_notified(
  4289. report_item_ids.get(agency_name, []),
  4290. effective_now,
  4291. )
  4292. else:
  4293. notification_errors.append(
  4294. f"agency={agency_name}: "
  4295. f"{outcome.get('error') or outcome.get('reason') or outcome.get('status')}"
  4296. )
  4297. if operator_rows:
  4298. operator_report = write_cleanup_operator_summary(
  4299. operator_rows,
  4300. report_dir,
  4301. report_date,
  4302. run_id,
  4303. )
  4304. operator_outcome = publish_cleanup_operator_summary(
  4305. run_id=str(operator_report["run_id"]),
  4306. report=operator_report,
  4307. chat_id=_operator_summary_chat_id(),
  4308. publisher=sheet_publisher,
  4309. now=effective_now,
  4310. )
  4311. operator_deliveries.append(operator_outcome)
  4312. if operator_outcome.get("status") == "SENT":
  4313. mark_cleanup_items_operator_notified(
  4314. [int(row["id"]) for row in operator_rows],
  4315. effective_now,
  4316. )
  4317. else:
  4318. notification_errors.append(
  4319. f"operator={check_date}: "
  4320. f"{operator_outcome.get('error') or operator_outcome.get('status')}"
  4321. )
  4322. if performance_rows:
  4323. report_started_at = time.monotonic()
  4324. performance_report = write_performance_operator_summary(
  4325. performance_rows,
  4326. report_dir,
  4327. report_date,
  4328. )
  4329. if force_notification:
  4330. force_suffix = effective_now.strftime(
  4331. "%H%M%S%f"
  4332. )
  4333. performance_report["run_id"] = (
  4334. f"{performance_report['run_id']}_force_"
  4335. f"{force_suffix}"
  4336. )[:64]
  4337. performance_report["title"] = (
  4338. f"{performance_report['title']}(手动重发)"
  4339. )
  4340. logger.info(
  4341. "performance notification excel generated rows=%d "
  4342. "duration_ms=%d path=%s",
  4343. len(performance_rows),
  4344. int((time.monotonic() - report_started_at) * 1000),
  4345. performance_report.get("report"),
  4346. )
  4347. publish_started_at = time.monotonic()
  4348. performance_outcome = publish_cleanup_operator_summary(
  4349. run_id=str(performance_report["run_id"]),
  4350. report=performance_report,
  4351. chat_id=_operator_summary_chat_id(),
  4352. publisher=sheet_publisher,
  4353. now=effective_now,
  4354. )
  4355. logger.info(
  4356. "performance notification published rows=%d status=%s "
  4357. "duration_ms=%d",
  4358. len(performance_rows),
  4359. performance_outcome.get("status"),
  4360. int((time.monotonic() - publish_started_at) * 1000),
  4361. )
  4362. operator_deliveries.append(performance_outcome)
  4363. if performance_outcome.get("status") == "SENT":
  4364. mark_cleanup_items_operator_notified(
  4365. [int(row["id"]) for row in performance_rows],
  4366. effective_now,
  4367. )
  4368. else:
  4369. notification_errors.append(
  4370. f"performance_operator={check_date}: "
  4371. f"{performance_outcome.get('error') or performance_outcome.get('status')}"
  4372. )
  4373. logger.info(
  4374. "cleanup notification completed date=%s rows=%d "
  4375. "duration_ms=%d",
  4376. check_date,
  4377. len(daily_rows),
  4378. int((time.monotonic() - daily_started_at) * 1000),
  4379. )
  4380. finally:
  4381. if owned_publisher:
  4382. sheet_publisher.close()
  4383. include_discovered = (
  4384. not apply_enabled
  4385. or (performance_enabled and not performance_apply_enabled)
  4386. or (ad_cleanup_enabled and not ad_apply_enabled)
  4387. )
  4388. def notification_rows_for_this_run(
  4389. rows: list[dict[str, Any]],
  4390. ) -> list[dict[str, Any]]:
  4391. if not underperformance_preview_only:
  4392. return rows
  4393. selected_rows = filter_preview_notification_rows(
  4394. rows,
  4395. check_date=effective_now.date(),
  4396. confirmed_actions=confirmed_actions,
  4397. force_notification=force_notification,
  4398. )
  4399. logger.info(
  4400. "preview notification selection queried=%d "
  4401. "current_confirmed=%d selected=%d stale_excluded=%d "
  4402. "force_notification=%s",
  4403. len(rows),
  4404. sum(
  4405. _is_performance_rule(action.get("cleanup_rule_type"))
  4406. for action in confirmed_actions.values()
  4407. ),
  4408. len(selected_rows),
  4409. len(rows) - len(selected_rows),
  4410. force_notification,
  4411. )
  4412. return selected_rows
  4413. notification_query_filters = (
  4414. {
  4415. "check_date": effective_now.date(),
  4416. "performance_only": True,
  4417. "include_notified": force_notification,
  4418. }
  4419. if underperformance_preview_only
  4420. else {}
  4421. )
  4422. notification_probe_started_at = time.monotonic()
  4423. pending_notification_probe = notification_rows_for_this_run(
  4424. load_unnotified_deleted_items(
  4425. include_discovered=include_discovered,
  4426. **notification_query_filters,
  4427. )
  4428. )
  4429. logger.info(
  4430. "cleanup notification probe rows=%d duration_ms=%d",
  4431. len(pending_notification_probe),
  4432. int((time.monotonic() - notification_probe_started_at) * 1000),
  4433. )
  4434. if pending_notification_probe and (
  4435. webhook_config.enabled
  4436. or any(
  4437. _is_performance_rule(row.get("cleanup_rule_type"))
  4438. for row in pending_notification_probe
  4439. )
  4440. ):
  4441. notification_lock_name = os.getenv(
  4442. "DAILY_REJECTED_CREATIVE_NOTIFICATION_LOCK_NAME",
  4443. "ad_rejected_creative_notification",
  4444. )
  4445. with advisory_lock(notification_lock_name) as acquired:
  4446. if not acquired:
  4447. logger.info(
  4448. "creative cleanup notification skipped: lock busy name=%s",
  4449. notification_lock_name,
  4450. )
  4451. else:
  4452. notification_reload_started_at = time.monotonic()
  4453. pending_notifications = notification_rows_for_this_run(
  4454. load_unnotified_deleted_items(
  4455. include_discovered=include_discovered,
  4456. **notification_query_filters,
  4457. )
  4458. )
  4459. logger.info(
  4460. "cleanup notification rows reloaded rows=%d duration_ms=%d",
  4461. len(pending_notifications),
  4462. int(
  4463. (time.monotonic() - notification_reload_started_at)
  4464. * 1000
  4465. ),
  4466. )
  4467. if pending_notifications:
  4468. publish_pending_notifications(pending_notifications)
  4469. return {
  4470. "underperformance_preview_only": underperformance_preview_only,
  4471. "force_notification": force_notification,
  4472. "apply_enabled": apply_enabled,
  4473. "performance_cleanup_enabled": performance_enabled,
  4474. "performance_apply_enabled": performance_apply_enabled,
  4475. "ad_cleanup_enabled": ad_cleanup_enabled,
  4476. "ad_apply_enabled": ad_apply_enabled,
  4477. "performance_metric_start_date": performance_start_date,
  4478. "performance_metric_end_date": performance_end_date,
  4479. "current_day_metric_date": current_day_date,
  4480. "ad_metric_start_date": ad_metric_start_date,
  4481. "ad_metric_end_date": ad_metric_end_date,
  4482. "performance_missing_creation_time": source_diagnostic_totals[
  4483. "missing_create_time"
  4484. ],
  4485. "account_scope": (
  4486. "opengid_recent_3d_spend_union_odps_active_creatives"
  4487. if underperformance_enabled
  4488. else "opengid_recent_3d_spend"
  4489. ),
  4490. "account_scope_start_date": start_date,
  4491. "account_scope_end_date": end_date,
  4492. "review_scope_error": review_scope_error,
  4493. "review_context_error": review_context_error,
  4494. "review_account_ids": sorted(review_account_ids),
  4495. "performance_account_ids": sorted(performance_account_ids),
  4496. "performance_active_creatives": len(performance_inventory),
  4497. "performance_account_metadata_count": len(
  4498. performance_account_metadata
  4499. ),
  4500. "performance_account_metadata_error": (
  4501. performance_account_metadata_error
  4502. ),
  4503. "performance_source_missing_tencent_creatives": (
  4504. source_diagnostic_totals["missing_tencent_creatives"]
  4505. ),
  4506. "performance_source_missing_tencent_ads": (
  4507. source_diagnostic_totals["missing_tencent_ads"]
  4508. ),
  4509. "performance_source_ad_mismatches": (
  4510. source_diagnostic_totals["ad_mismatches"]
  4511. ),
  4512. "performance_source_missing_ad_ids": (
  4513. source_diagnostic_totals["missing_source_ad_ids"]
  4514. ),
  4515. "accounts": len(accounts),
  4516. "account_ids": account_ids,
  4517. "tokens_prefetched": len(prefetched_tokens),
  4518. "creatives_scanned": scanned,
  4519. "cleanup_discovered": discovered,
  4520. "rejected_discovered": review_discovered,
  4521. "performance_discovered": performance_discovered,
  4522. "ad_discovered": ad_discovered,
  4523. "pending_cleanup": sum(
  4524. 1 for item in retryable_items if not item_apply_enabled(item)
  4525. ),
  4526. "deleted": deleted,
  4527. "deferred": deferred,
  4528. "scan_errors": scan_errors,
  4529. "delete_errors": delete_errors,
  4530. "notification_errors": notification_errors,
  4531. "deliveries": deliveries,
  4532. "operator_deliveries": operator_deliveries,
  4533. }
  4534. finally:
  4535. if owned_tencent:
  4536. client.session.close()