creative_rejection_cleanup.py 208 KB

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