creative_rejection_cleanup.py 202 KB

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