test_creative_review_scan.py 125 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236
  1. import json
  2. import os
  3. import tempfile
  4. import unittest
  5. from contextlib import contextmanager
  6. from datetime import date, datetime
  7. from pathlib import Path
  8. from unittest.mock import Mock, call, patch
  9. from zoneinfo import ZoneInfo
  10. import pandas as pd
  11. from openpyxl import load_workbook
  12. class CreativeReviewScanTests(unittest.TestCase):
  13. def test_tencent_get_retries_transient_tls_connection_failure(self):
  14. from tools import ad_api
  15. response = Mock()
  16. response.raise_for_status.return_value = None
  17. response.json.return_value = {"code": 0, "data": {"list": []}}
  18. connection_error = ad_api.httpx.ConnectError("temporary TLS EOF")
  19. with patch.dict(
  20. os.environ,
  21. {
  22. "TENCENT_AD_GET_RETRY_ATTEMPTS": "3",
  23. "TENCENT_AD_GET_RETRY_BACKOFF_SECONDS": "0",
  24. },
  25. clear=False,
  26. ), patch.object(
  27. ad_api, "_common_params", side_effect=[{"nonce": "1"}, {"nonce": "2"}]
  28. ) as common_params, patch.object(
  29. ad_api.httpx,
  30. "get",
  31. side_effect=[connection_error, response],
  32. create=True,
  33. ) as get:
  34. payload = ad_api._get("/test/get", {"account_id": 123})
  35. self.assertEqual(payload, {"code": 0, "data": {"list": []}})
  36. self.assertEqual(get.call_count, 2)
  37. self.assertEqual(common_params.call_count, 2)
  38. def test_access_tokens_are_prefetched_concurrently_and_deduplicated(self):
  39. import threading
  40. from tools import ad_api
  41. barrier = threading.Barrier(3, timeout=2)
  42. def fetch(account_id):
  43. barrier.wait()
  44. return f"access-token-{account_id}"
  45. with patch.dict(ad_api._token_cache, {}, clear=True), patch.object(
  46. ad_api,
  47. "_get_access_token",
  48. side_effect=fetch,
  49. ) as get_token:
  50. tokens = ad_api.prefetch_access_tokens([3, 1, 2, 2], max_workers=3)
  51. cached_accounts = set(ad_api._token_cache)
  52. self.assertEqual(tokens, {
  53. 1: "access-token-1",
  54. 2: "access-token-2",
  55. 3: "access-token-3",
  56. })
  57. self.assertEqual(get_token.call_count, 3)
  58. self.assertEqual(cached_accounts, {1, 2, 3})
  59. def test_parse_rejected_result_collects_reasons_and_locations(self):
  60. from tools.creative_review import parse_review_result
  61. parsed = parse_review_result({
  62. "dynamic_creative_id": 123,
  63. "reject_message_list": ["整体拒绝"],
  64. "site_set_result_list": [
  65. {
  66. "site_set": "SITE_SET_MOMENTS",
  67. "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
  68. "reject_message": "朋友圈拒绝",
  69. }
  70. ],
  71. "element_result_list": [
  72. {
  73. "element_name": "image",
  74. "element_type": "ELEMENT_TYPE_IMAGE",
  75. "review_status": "REVIEW_STATUS_REJECTED",
  76. "element_reject_detail_info": [
  77. {
  78. "reason": "图片含违规信息",
  79. "reject_info_location": [
  80. {"x": 1, "y": 2, "width": 3, "height": 4}
  81. ],
  82. }
  83. ],
  84. }
  85. ],
  86. })
  87. self.assertEqual(parsed.review_status, "rejected")
  88. self.assertIn("整体拒绝", parsed.reject_messages)
  89. self.assertIn("朋友圈拒绝", parsed.reject_messages)
  90. self.assertIn("图片含违规信息", parsed.reject_messages)
  91. self.assertEqual(len(parsed.rejection_facts), 2)
  92. def test_parse_pending_result(self):
  93. from tools.creative_review import parse_review_result
  94. parsed = parse_review_result({
  95. "dynamic_creative_id": 456,
  96. "is_all_component_compose_pending": True,
  97. "delay_message_list": ["审核延迟"],
  98. })
  99. self.assertEqual(parsed.review_status, "pending")
  100. self.assertIn("审核延迟", parsed.delay_messages)
  101. def test_review_granularity_fields_lists_element_and_site_results(self):
  102. from tools.creative_review import review_granularity_fields
  103. fields = review_granularity_fields(
  104. {
  105. "element_result_list": [
  106. {
  107. "element_name": "主图",
  108. "image_id": "10001",
  109. "review_status": "REVIEW_STATUS_REJECTED",
  110. "element_reject_detail_info": [
  111. {"reason": "图片违规"},
  112. {"reason": "文字违规"},
  113. ],
  114. },
  115. {
  116. "element_type": "ELEMENT_TYPE_VIDEO",
  117. "video_id": "20002",
  118. "review_status": "REVIEW_STATUS_APPROVED",
  119. },
  120. {
  121. "element_name": "正常元素",
  122. "element_id": "30003",
  123. "system_status": "AD_STATUS_NORMAL",
  124. },
  125. ],
  126. "site_set_result_list": [
  127. {
  128. "site_set": "SITE_SET_MOMENTS",
  129. "site_set_id": "40004",
  130. "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
  131. "reject_message": "朋友圈版位拒绝",
  132. "element_reject_detail_info": [
  133. {"reason": "版位素材不适配"}
  134. ],
  135. },
  136. {
  137. "site_set": "SITE_SET_WECHAT_CHANNELS",
  138. "review_status": "REVIEW_STATUS_APPROVED",
  139. },
  140. {
  141. "site_set": "SITE_SET_NORMAL",
  142. "review_status": "AD_STATUS_NORMAL",
  143. },
  144. ],
  145. }
  146. )
  147. self.assertEqual(
  148. fields["element_review_status"],
  149. "主图(id=10001): 审核拒绝\nELEMENT_TYPE_VIDEO: 审核通过",
  150. )
  151. self.assertEqual(
  152. fields["element_reject_reason"],
  153. "主图(id=10001): 图片违规\n主图(id=10001): 文字违规",
  154. )
  155. self.assertEqual(
  156. fields["site_review_status"],
  157. "SITE_SET_MOMENTS(id=40004): 审核拒绝\n"
  158. "SITE_SET_WECHAT_CHANNELS: 审核通过",
  159. )
  160. self.assertEqual(
  161. fields["site_reject_reason"],
  162. "SITE_SET_MOMENTS(id=40004): 朋友圈版位拒绝\n"
  163. "SITE_SET_MOMENTS(id=40004): 版位素材不适配",
  164. )
  165. def test_status_desc_preserves_unknown_enum(self):
  166. from tools.creative_review import status_desc
  167. self.assertEqual(status_desc("AD_STATUS_NORMAL"), "投放中")
  168. self.assertEqual(
  169. status_desc("CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"),
  170. "部分投放中",
  171. )
  172. self.assertEqual(status_desc("FUTURE_STATUS_NEW"), "FUTURE_STATUS_NEW")
  173. def test_chunk_ids_by_account_limits_to_100(self):
  174. from tools.creative_review import group_review_tasks
  175. tasks = [
  176. {"account_id": 1, "dynamic_creative_id": i}
  177. for i in range(250)
  178. ] + [{"account_id": 2, "dynamic_creative_id": 999}]
  179. groups = list(group_review_tasks(tasks, batch_size=100))
  180. self.assertEqual(len(groups), 4)
  181. self.assertEqual(groups[0][0], 1)
  182. self.assertEqual(len(groups[0][1]), 100)
  183. self.assertEqual(len(groups[1][1]), 100)
  184. self.assertEqual(len(groups[2][1]), 50)
  185. self.assertEqual(groups[3], (2, [999]))
  186. class CreativeRejectionCleanupTests(unittest.TestCase):
  187. def test_dynamic_creative_api_requests_creative_approval_status(self):
  188. from tencent_client import DYNAMIC_CREATIVE_FIELDS, TencentClient
  189. response = Mock()
  190. response.raise_for_status.return_value = None
  191. response.json.return_value = {
  192. "code": 0,
  193. "data": {"list": [], "page_info": {"total_page": 1}},
  194. }
  195. client = TencentClient()
  196. client.session.get = Mock(return_value=response)
  197. client._common_params = Mock(return_value={"access_token": "token"})
  198. client.get_dynamic_creatives(1)
  199. self.assertIn("creative_set_approval_status", DYNAMIC_CREATIVE_FIELDS)
  200. requested_fields = json.loads(client.session.get.call_args.kwargs["params"]["fields"])
  201. self.assertIn("creative_set_approval_status", requested_fields)
  202. def test_dynamic_creative_costs_query_sums_three_day_api_rows(self):
  203. from datetime import date
  204. from tencent_client import TencentClient
  205. response = Mock()
  206. response.raise_for_status.return_value = None
  207. response.json.return_value = {
  208. "code": 0,
  209. "data": {
  210. "list": [
  211. {"dynamic_creative_id": 3, "date": "2026-08-10", "cost": 1200},
  212. {"dynamic_creative_id": 3, "date": "2026-08-11", "cost": 1799},
  213. {"dynamic_creative_id": 4, "date": "2026-08-12", "cost": 3000},
  214. ],
  215. "page_info": {"total_page": 1},
  216. },
  217. }
  218. client = TencentClient()
  219. client.session.get = Mock(return_value=response)
  220. client._common_params = Mock(return_value={"access_token": "token"})
  221. costs = client.get_dynamic_creative_costs(
  222. 1,
  223. [3, 4],
  224. date(2026, 8, 10),
  225. date(2026, 8, 12),
  226. )
  227. self.assertEqual(costs, {3: 2999, 4: 3000})
  228. request = client.session.get.call_args
  229. self.assertTrue(
  230. request.args[0].endswith("/daily_reports/get")
  231. )
  232. self.assertEqual(
  233. request.kwargs["params"]["level"],
  234. "REPORT_LEVEL_DYNAMIC_CREATIVE",
  235. )
  236. self.assertEqual(
  237. json.loads(request.kwargs["params"]["date_range"]),
  238. {"start_date": "2026-08-10", "end_date": "2026-08-12"},
  239. )
  240. self.assertEqual(
  241. json.loads(request.kwargs["params"]["filtering"]),
  242. [
  243. {
  244. "field": "dynamic_creative_id",
  245. "operator": "IN",
  246. "values": ["3", "4"],
  247. }
  248. ],
  249. )
  250. self.assertEqual(request.kwargs["params"]["time_line"], "REQUEST_TIME")
  251. def test_account_scope_uses_recent_three_day_opengid_spend(self):
  252. from roi_control.data_source import (
  253. build_recent_spend_accounts_sql,
  254. fetch_recent_spend_accounts,
  255. )
  256. sql = build_recent_spend_accounts_sql("20260808", "20260810")
  257. self.assertIn("FROM loghubods.opengid_base_data", sql)
  258. self.assertIn("dt BETWEEN '20260808' AND '20260810'", sql)
  259. self.assertNotIn("usersharedepth", sql)
  260. self.assertNotIn("videoid", sql)
  261. self.assertNotIn("hotsencetype", sql)
  262. self.assertIn("GROUP BY 账号id", sql)
  263. self.assertIn("HAVING SUM(NVL(成本, 0)) > 0", sql)
  264. self.assertNotIn("account_whitelist", sql)
  265. client = Mock()
  266. client.execute_sql.return_value = pd.DataFrame(
  267. [
  268. {"account_id": "1", "account_name": "账户一", "cost_yuan": 10},
  269. {"account_id": "", "account_name": "无效", "cost_yuan": 20},
  270. {"account_id": "2", "account_name": "账户二", "cost_yuan": 30},
  271. ]
  272. )
  273. self.assertEqual(
  274. fetch_recent_spend_accounts(client, "20260808", "20260810"),
  275. [
  276. {"account_id": 1, "account_name": "账户一", "cost_yuan": 10.0},
  277. {"account_id": 2, "account_name": "账户二", "cost_yuan": 30.0},
  278. ],
  279. )
  280. def test_account_agency_fallback_uses_latest_active_account_record(self):
  281. from roi_control.data_source import (
  282. build_account_agency_fallback_sql,
  283. fetch_account_agency_fallbacks,
  284. )
  285. sql = build_account_agency_fallback_sql([2, 1, 2])
  286. self.assertIn("FROM loghubods.ad_put_tencent_account", sql)
  287. self.assertIn("account_id IN ('1', '2')", sql)
  288. self.assertIn("NVL(is_delete, 0) = 0", sql)
  289. self.assertIn("status = 1", sql)
  290. self.assertIn("TRIM(agent_name) <> ''", sql)
  291. self.assertIn("ORDER BY id DESC", sql)
  292. self.assertIn("WHERE row_number = 1", sql)
  293. inner_projection = sql.split("FROM (", 1)[1].split(
  294. "FROM loghubods.ad_put_tencent_account", 1
  295. )[0]
  296. self.assertIn("is_delete", inner_projection)
  297. self.assertIn("status", inner_projection)
  298. client = Mock()
  299. client.execute_sql.return_value = pd.DataFrame(
  300. [
  301. {"account_id": "1", "agent_name": " 代理 A "},
  302. {"account_id": "2", "agent_name": float("nan")},
  303. {"account_id": "invalid", "agent_name": "代理 B"},
  304. ]
  305. )
  306. self.assertEqual(fetch_account_agency_fallbacks(client, [1, 2]), {1: "代理A"})
  307. def test_agency_resolution_only_uses_account_table_as_last_fallback(self):
  308. from tools.creative_rejection_cleanup import _resolve_agency
  309. context = {
  310. "creative_agencies": {(1, 101): "创意代理"},
  311. "account_agencies": {1: "账户代理"},
  312. "fallback_account_agencies": {1: "表中代理", 2: "兜底代理"},
  313. "account_names": {},
  314. }
  315. self.assertEqual(_resolve_agency(context, 1, 101), "创意代理")
  316. self.assertEqual(_resolve_agency(context, 1, 102), "账户代理")
  317. self.assertEqual(_resolve_agency(context, 2, 201), "兜底代理")
  318. def test_cleanup_requires_current_rejection_confirmation(self):
  319. from tools.creative_rejection_cleanup import (
  320. DELETE_CREATIVE,
  321. cleanup_precondition_failure,
  322. )
  323. item = {
  324. "account_id": 1,
  325. "dynamic_creative_id": 3,
  326. "cleanup_status": "DISCOVERED",
  327. "cleanup_action": DELETE_CREATIVE,
  328. }
  329. self.assertEqual(
  330. cleanup_precondition_failure(item, set(), set())[0],
  331. "DEFERRED",
  332. )
  333. self.assertEqual(
  334. cleanup_precondition_failure(item, {1}, {})[0],
  335. "SKIPPED_REVIEW_NOT_RECONFIRMED",
  336. )
  337. confirmed = {
  338. (1, 3): {
  339. "cleanup_action": DELETE_CREATIVE,
  340. }
  341. }
  342. self.assertIsNone(cleanup_precondition_failure(item, {1}, confirmed))
  343. item["cleanup_action"] = "DELETE_COMPONENTS"
  344. self.assertEqual(
  345. cleanup_precondition_failure(item, {1}, confirmed)[0],
  346. "SKIPPED_REVIEW_NOT_RECONFIRMED",
  347. )
  348. def test_cleanup_action_follows_creative_approval_and_element_statuses(self):
  349. from tools.creative_rejection_cleanup import (
  350. ALERT_ONLY,
  351. DELETE_CREATIVE,
  352. determine_cleanup_action,
  353. )
  354. denied_element = {
  355. "element_result_list": [
  356. {
  357. "element_id": 101,
  358. "review_status": "AD_STATUS_DENIED",
  359. "component_info": {"component_id": 201},
  360. }
  361. ]
  362. }
  363. denied_action = determine_cleanup_action(
  364. {
  365. "configured_status": "AD_STATUS_NORMAL",
  366. "creative_set_approval_status": (
  367. "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  368. ),
  369. },
  370. denied_element,
  371. )
  372. self.assertEqual(denied_action["cleanup_action"], DELETE_CREATIVE)
  373. self.assertEqual(denied_action["component_ids"], [])
  374. normal_action = determine_cleanup_action(
  375. {
  376. "configured_status": "AD_STATUS_SUSPEND",
  377. "creative_set_approval_status": (
  378. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  379. ),
  380. },
  381. denied_element,
  382. )
  383. self.assertIsNone(normal_action)
  384. partial = {
  385. "creative_set_approval_status": (
  386. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  387. )
  388. }
  389. low_cost = determine_cleanup_action(
  390. partial,
  391. denied_element,
  392. recent_cost_fen=4999,
  393. current_day_cost_fen=0,
  394. creative_created_at="2026-08-10 11:00:00",
  395. as_of_datetime="2026-08-20 11:00:00",
  396. )
  397. self.assertEqual(low_cost["cleanup_action"], DELETE_CREATIVE)
  398. at_threshold = determine_cleanup_action(
  399. partial,
  400. denied_element,
  401. recent_cost_fen=5000,
  402. current_day_cost_fen=0,
  403. creative_created_at="2026-08-10 11:00:00",
  404. as_of_datetime="2026-08-20 11:00:00",
  405. )
  406. self.assertEqual(at_threshold["cleanup_action"], ALERT_ONLY)
  407. def test_partial_creative_with_rejected_wechat_mini_program_is_deleted(self):
  408. from tools.creative_rejection_cleanup import (
  409. DELETE_CREATIVE,
  410. determine_cleanup_action,
  411. )
  412. action = determine_cleanup_action(
  413. {
  414. "creative_set_approval_status": (
  415. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  416. )
  417. },
  418. {
  419. "element_result_list": [
  420. {
  421. "element_name": "微信小程序",
  422. "element_id": -8101,
  423. "review_status": "AD_STATUS_DENIED",
  424. }
  425. ]
  426. },
  427. recent_cost_fen=9999,
  428. current_day_cost_fen=0,
  429. creative_created_at="2026-08-10 11:00:00",
  430. as_of_datetime="2026-08-20 11:00:00",
  431. )
  432. self.assertEqual(action["cleanup_action"], DELETE_CREATIVE)
  433. self.assertIn("微信小程序元素审核拒绝", action["action_reason"])
  434. def test_partial_creative_with_high_cost_rejected_wechat_requires_review(self):
  435. from tools.creative_rejection_cleanup import ALERT_ONLY, determine_cleanup_action
  436. action = determine_cleanup_action(
  437. {
  438. "creative_set_approval_status": (
  439. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  440. )
  441. },
  442. {
  443. "element_result_list": [{
  444. "element_name": "微信小程序",
  445. "review_status": "AD_STATUS_DENIED",
  446. }]
  447. },
  448. recent_cost_fen=10001,
  449. current_day_cost_fen=0,
  450. creative_created_at="2026-08-10 11:00:00",
  451. as_of_datetime="2026-08-20 11:00:00",
  452. )
  453. self.assertEqual(action["cleanup_action"], ALERT_ONLY)
  454. self.assertIn("需人工判断", action["action_reason"])
  455. def test_partial_creative_spend_error_only_alerts(self):
  456. from tools.creative_rejection_cleanup import ALERT_ONLY, determine_cleanup_action
  457. action = determine_cleanup_action(
  458. {
  459. "creative_set_approval_status": (
  460. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  461. )
  462. },
  463. {},
  464. creative_created_at="2026-08-10 11:00:00",
  465. as_of_datetime="2026-08-20 11:00:00",
  466. spend_error="timeout",
  467. )
  468. self.assertEqual(action["cleanup_action"], ALERT_ONLY)
  469. self.assertIn("消耗读取失败", action["action_reason"])
  470. def test_partial_creative_not_older_than_three_days_only_alerts(self):
  471. from tools.creative_rejection_cleanup import ALERT_ONLY, determine_cleanup_action
  472. action = determine_cleanup_action(
  473. {
  474. "creative_set_approval_status": (
  475. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  476. )
  477. },
  478. {},
  479. recent_cost_fen=0,
  480. current_day_cost_fen=0,
  481. creative_created_at="2026-08-17 11:00:00",
  482. as_of_datetime="2026-08-20 11:00:00",
  483. )
  484. self.assertEqual(action["cleanup_action"], ALERT_ONLY)
  485. self.assertEqual(action["creative_age_days"], 3)
  486. self.assertIn("未超过3天", action["action_reason"])
  487. def test_partial_creative_uses_historical_plus_current_day_cost(self):
  488. from tools.creative_rejection_cleanup import (
  489. ALERT_ONLY,
  490. DELETE_CREATIVE,
  491. determine_cleanup_action,
  492. )
  493. common = {
  494. "creative_set_approval_status": (
  495. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  496. )
  497. }
  498. protected_by_total = determine_cleanup_action(
  499. common,
  500. {},
  501. recent_cost_fen=4000,
  502. current_day_cost_fen=1000,
  503. creative_created_at="2026-08-10 11:00:00",
  504. as_of_datetime="2026-08-20 11:00:00",
  505. )
  506. still_below_total = determine_cleanup_action(
  507. common,
  508. {},
  509. recent_cost_fen=4000,
  510. current_day_cost_fen=999,
  511. creative_created_at="2026-08-10 11:00:00",
  512. as_of_datetime="2026-08-20 11:00:00",
  513. )
  514. self.assertEqual(protected_by_total["cleanup_action"], ALERT_ONLY)
  515. self.assertEqual(protected_by_total["total_cost_fen"], 5000)
  516. self.assertEqual(still_below_total["cleanup_action"], DELETE_CREATIVE)
  517. self.assertEqual(still_below_total["total_cost_fen"], 4999)
  518. def test_partial_creative_missing_creation_time_only_alerts(self):
  519. from tools.creative_rejection_cleanup import ALERT_ONLY, determine_cleanup_action
  520. action = determine_cleanup_action(
  521. {
  522. "creative_set_approval_status": (
  523. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  524. )
  525. },
  526. {},
  527. recent_cost_fen=0,
  528. current_day_cost_fen=0,
  529. creative_created_at=None,
  530. as_of_datetime="2026-08-20 11:00:00",
  531. )
  532. self.assertEqual(action["cleanup_action"], ALERT_ONLY)
  533. self.assertIn("搭建时间缺失", action["action_reason"])
  534. def test_partial_creative_protection_days_config_is_validated(self):
  535. from tools.creative_rejection_cleanup import partial_creative_protection_days
  536. with patch.dict(
  537. os.environ,
  538. {"DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS": "5"},
  539. clear=False,
  540. ):
  541. self.assertEqual(partial_creative_protection_days(), 5)
  542. with patch.dict(
  543. os.environ,
  544. {"DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS": "-1"},
  545. clear=False,
  546. ):
  547. with self.assertRaises(ValueError):
  548. partial_creative_protection_days()
  549. def test_cleanup_candidate_insert_has_one_value_per_column(self):
  550. from tools import creative_rejection_cleanup as cleanup
  551. class Cursor:
  552. def __init__(self):
  553. self.calls = []
  554. def __enter__(self):
  555. return self
  556. def __exit__(self, *_args):
  557. return None
  558. def execute(self, sql, params=None):
  559. self.calls.append((sql, params))
  560. def fetchone(self):
  561. return {"id": 7}
  562. cursor = Cursor()
  563. connection = Mock()
  564. connection.cursor.return_value = cursor
  565. with patch.object(cleanup, "get_connection", return_value=connection):
  566. result = cleanup.upsert_cleanup_candidate(
  567. {
  568. "account_id": 1,
  569. "account_name": "账户一",
  570. "agency_name": "代理一",
  571. "adgroup_id": 2,
  572. "adgroup_name": "广告二",
  573. "dynamic_creative_id": 3,
  574. "dynamic_creative_name": "创意三",
  575. "check_date": "2026-08-12",
  576. "cleanup_action": cleanup.ALERT_ONLY,
  577. "component_ids": [],
  578. "element_ids": [],
  579. "recent_cost_fen": 3000,
  580. "cost_start_date": "2026-08-10",
  581. "cost_end_date": "2026-08-12",
  582. "reject_reason": "待人工判断",
  583. "review_result": {},
  584. "pre_state": {},
  585. }
  586. )
  587. insert_sql, insert_params = cursor.calls[0]
  588. values_sql = insert_sql.split("ON DUPLICATE KEY UPDATE", 1)[0]
  589. self.assertEqual(values_sql.count("%s"), 22)
  590. self.assertEqual(len(insert_params), 22)
  591. self.assertEqual(insert_params[-2], "ALERT_PENDING")
  592. self.assertIsNone(insert_params[-1])
  593. update_sql, update_params = cursor.calls[1]
  594. self.assertIn(
  595. "NOT (item.recent_cost_fen <=> incoming.recent_cost_fen)",
  596. update_sql,
  597. )
  598. normalized_update_sql = " ".join(update_sql.split())
  599. self.assertIn(
  600. "'DELETING','CREATIVE_DELETED','AD_DELETED'",
  601. normalized_update_sql,
  602. )
  603. self.assertEqual(update_params, insert_params)
  604. self.assertEqual(result, {"id": 7})
  605. connection.close.assert_called_once()
  606. def test_cleanup_candidates_reuse_one_transaction_connection_per_batch(self):
  607. from tools import creative_rejection_cleanup as cleanup
  608. connections = []
  609. def connection_factory():
  610. cursor = Mock()
  611. cursor.__enter__ = Mock(return_value=cursor)
  612. cursor.__exit__ = Mock(return_value=None)
  613. connection = Mock()
  614. connection.cursor.return_value = cursor
  615. connections.append((connection, cursor))
  616. return connection
  617. records = [
  618. {
  619. "account_id": 1,
  620. "account_name": "账户一",
  621. "agency_name": "",
  622. "adgroup_id": 2,
  623. "adgroup_name": "广告二",
  624. "dynamic_creative_id": creative_id,
  625. "dynamic_creative_name": f"创意{creative_id}",
  626. "check_date": date(2026, 8, 20),
  627. "cleanup_action": cleanup.DELETE_CREATIVE,
  628. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  629. "reject_reason": "",
  630. "review_result": {},
  631. "pre_state": {},
  632. }
  633. for creative_id in range(1, 5)
  634. ]
  635. with patch.dict(
  636. os.environ,
  637. {
  638. "DAILY_CLEANUP_CANDIDATE_BATCH_SIZE": "2",
  639. "DAILY_CLEANUP_CANDIDATE_STORE_WORKERS": "2",
  640. },
  641. clear=False,
  642. ), patch.object(
  643. cleanup,
  644. "get_connection",
  645. side_effect=connection_factory,
  646. ):
  647. stored, errors = cleanup.upsert_cleanup_candidates(records)
  648. self.assertEqual(len(stored), 4)
  649. self.assertEqual(errors, [])
  650. self.assertEqual(len(connections), 2)
  651. self.assertEqual(
  652. sum(cursor.execute.call_count for _, cursor in connections),
  653. 12,
  654. )
  655. for connection, _cursor in connections:
  656. connection.begin.assert_called_once_with()
  657. connection.commit.assert_called_once_with()
  658. connection.close.assert_called_once_with()
  659. def test_cleanup_candidate_batch_failure_falls_back_to_individual_rows(self):
  660. from tools import creative_rejection_cleanup as cleanup
  661. connections = []
  662. def connection_factory():
  663. cursor = Mock()
  664. cursor.__enter__ = Mock(return_value=cursor)
  665. cursor.__exit__ = Mock(return_value=None)
  666. cursor.fetchone.return_value = {"id": 7}
  667. connection = Mock()
  668. connection.cursor.return_value = cursor
  669. if not connections:
  670. cursor.execute.side_effect = RuntimeError("batch write failed")
  671. connections.append((connection, cursor))
  672. return connection
  673. records = [
  674. {
  675. "account_id": 1,
  676. "account_name": "账户一",
  677. "agency_name": "",
  678. "adgroup_id": 2,
  679. "adgroup_name": "广告二",
  680. "dynamic_creative_id": creative_id,
  681. "dynamic_creative_name": f"创意{creative_id}",
  682. "check_date": date(2026, 8, 20),
  683. "cleanup_action": cleanup.DELETE_CREATIVE,
  684. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  685. "reject_reason": "",
  686. "review_result": {},
  687. "pre_state": {},
  688. }
  689. for creative_id in (1, 2)
  690. ]
  691. with patch.dict(
  692. os.environ,
  693. {
  694. "DAILY_CLEANUP_CANDIDATE_BATCH_SIZE": "10",
  695. "DAILY_CLEANUP_CANDIDATE_STORE_WORKERS": "1",
  696. },
  697. clear=False,
  698. ), patch.object(
  699. cleanup,
  700. "get_connection",
  701. side_effect=connection_factory,
  702. ):
  703. stored, errors = cleanup.upsert_cleanup_candidates(records)
  704. self.assertEqual(len(stored), 2)
  705. self.assertEqual(errors, [])
  706. self.assertEqual(len(connections), 3)
  707. connections[0][0].rollback.assert_called_once_with()
  708. for connection, _cursor in connections:
  709. connection.close.assert_called_once_with()
  710. def test_retry_query_only_loads_whole_creative_deletes(self):
  711. from tools import creative_rejection_cleanup as cleanup
  712. cursor = Mock()
  713. cursor.__enter__ = Mock(return_value=cursor)
  714. cursor.__exit__ = Mock(return_value=None)
  715. cursor.fetchall.return_value = []
  716. connection = Mock()
  717. connection.cursor.return_value = cursor
  718. with patch.object(cleanup, "get_connection", return_value=connection):
  719. self.assertEqual(cleanup.load_retryable_cleanup_items(), [])
  720. sql = cursor.execute.call_args.args[0]
  721. self.assertIn(
  722. "cleanup_action IN ('DELETE_CREATIVE','DELETE_AD')",
  723. sql,
  724. )
  725. self.assertIn("item.cleanup_status='DELETING'", sql)
  726. self.assertIn("DATE_SUB(NOW(), INTERVAL %s MINUTE)", sql)
  727. self.assertNotIn("COMPONENTS_PARTIAL", sql)
  728. def test_cleanup_item_claim_is_atomic_and_reports_contention(self):
  729. from tools import creative_rejection_cleanup as cleanup
  730. cursor = Mock()
  731. cursor.__enter__ = Mock(return_value=cursor)
  732. cursor.__exit__ = Mock(return_value=None)
  733. cursor.rowcount = 0
  734. connection = Mock()
  735. connection.cursor.return_value = cursor
  736. with patch.object(cleanup, "get_connection", return_value=connection):
  737. self.assertFalse(cleanup.claim_cleanup_item(7))
  738. sql, params = cursor.execute.call_args.args
  739. self.assertIn("SET cleanup_status='DELETING'", sql)
  740. self.assertIn(
  741. "cleanup_action IN ('DELETE_CREATIVE','DELETE_AD')",
  742. sql,
  743. )
  744. self.assertIn("cleanup_status IN", sql)
  745. self.assertIn("cleanup_status='DELETING'", sql)
  746. self.assertEqual(params, (7, cleanup.DEFAULT_DELETE_CLAIM_STALE_MINUTES))
  747. def test_owned_cleanup_update_requires_deleting_status(self):
  748. from tools import creative_rejection_cleanup as cleanup
  749. cursor = Mock()
  750. cursor.__enter__ = Mock(return_value=cursor)
  751. cursor.__exit__ = Mock(return_value=None)
  752. cursor.rowcount = 1
  753. connection = Mock()
  754. connection.cursor.return_value = cursor
  755. with patch.object(cleanup, "get_connection", return_value=connection):
  756. self.assertTrue(
  757. cleanup._update_owned_cleanup_item(
  758. 7,
  759. cleanup_status="CREATIVE_DELETED",
  760. error_message=None,
  761. )
  762. )
  763. sql, params = cursor.execute.call_args.args
  764. self.assertIn("WHERE id=%s AND cleanup_status=%s", sql)
  765. self.assertEqual(params, ["CREATIVE_DELETED", None, 7, "DELETING"])
  766. def test_dry_run_notification_query_includes_discovered_candidates(self):
  767. from tools import creative_rejection_cleanup as cleanup
  768. cursor = Mock()
  769. cursor.__enter__ = Mock(return_value=cursor)
  770. cursor.__exit__ = Mock(return_value=None)
  771. cursor.fetchall.return_value = []
  772. connection = Mock()
  773. connection.cursor.return_value = cursor
  774. with patch.object(cleanup, "get_connection", return_value=connection):
  775. cleanup.load_pending_notification_items(include_discovered=True)
  776. sql = cursor.execute.call_args.args[0]
  777. self.assertIn("'DISCOVERED'", sql)
  778. def test_preview_notification_query_filters_current_performance_day_in_sql(self):
  779. from tools import creative_rejection_cleanup as cleanup
  780. cursor = Mock()
  781. cursor.__enter__ = Mock(return_value=cursor)
  782. cursor.__exit__ = Mock(return_value=None)
  783. cursor.fetchall.return_value = []
  784. connection = Mock()
  785. connection.cursor.return_value = cursor
  786. target_date = date(2026, 8, 20)
  787. with patch.object(cleanup, "get_connection", return_value=connection):
  788. cleanup.load_pending_notification_items(
  789. include_discovered=True,
  790. check_date=target_date,
  791. performance_only=True,
  792. )
  793. sql, params = cursor.execute.call_args.args
  794. self.assertIn("AND check_date=%s", sql)
  795. self.assertIn(
  796. "LEFT(cleanup_rule_type, 12)='PERFORMANCE_'",
  797. sql,
  798. )
  799. self.assertEqual(params, [target_date])
  800. def test_force_preview_notification_query_includes_notified_rows(self):
  801. from tools import creative_rejection_cleanup as cleanup
  802. cursor = Mock()
  803. cursor.__enter__ = Mock(return_value=cursor)
  804. cursor.__exit__ = Mock(return_value=None)
  805. cursor.fetchall.return_value = []
  806. connection = Mock()
  807. connection.cursor.return_value = cursor
  808. with patch.object(cleanup, "get_connection", return_value=connection):
  809. cleanup.load_pending_notification_items(
  810. include_discovered=True,
  811. check_date=date(2026, 8, 20),
  812. performance_only=True,
  813. include_notified=True,
  814. )
  815. sql = cursor.execute.call_args.args[0]
  816. self.assertNotIn("operator_notified_at IS NULL", sql)
  817. def test_dry_run_reports_mark_delete_as_not_executed(self):
  818. from tools.creative_rejection_cleanup import (
  819. write_cleanup_operator_summary,
  820. write_cleanup_reports,
  821. )
  822. row = {
  823. "id": 7,
  824. "agency_name": "棱镜",
  825. "account_id": 1,
  826. "adgroup_id": 2,
  827. "dynamic_creative_id": 3,
  828. "cleanup_action": "DELETE_CREATIVE",
  829. "cleanup_status": "DISCOVERED",
  830. "reject_reason": "审核拒绝",
  831. "review_result_json": "{}",
  832. "pre_state_json": "{}",
  833. }
  834. with tempfile.TemporaryDirectory() as directory:
  835. output_dir = Path(directory)
  836. _, reports, _ = write_cleanup_reports(
  837. [row], output_dir, "20260813"
  838. )
  839. operator = write_cleanup_operator_summary(
  840. [row], output_dir, "20260813", "unused"
  841. )
  842. agency_sheet = load_workbook(reports[0]["report"])[
  843. "审核不通过创意清理"
  844. ]
  845. operator_sheet = load_workbook(operator["report"])[
  846. "审核不通过创意清理"
  847. ]
  848. for sheet in (agency_sheet, operator_sheet):
  849. columns = {cell.value: cell.column for cell in sheet[1]}
  850. self.assertEqual(
  851. sheet.cell(2, columns["执行操作"]).value,
  852. "建议删除创意(未执行)",
  853. )
  854. self.assertEqual(
  855. reports[0]["notification_type"],
  856. "creative_rejection_dry_run",
  857. )
  858. self.assertTrue(operator["dry_run"])
  859. def test_cleanup_action_does_not_delete_for_overall_or_site_denial(self):
  860. from tools.creative_rejection_cleanup import determine_cleanup_action
  861. creative = {
  862. "configured_status": "AD_STATUS_NORMAL",
  863. "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
  864. }
  865. result = {
  866. "site_set_result_list": [
  867. {"system_status": "DYNAMIC_CREATIVE_STATUS_DENIED"}
  868. ]
  869. }
  870. self.assertIsNone(determine_cleanup_action(creative, result))
  871. def test_denied_element_is_not_a_cleanup_candidate(self):
  872. from tools.creative_rejection_cleanup import determine_cleanup_action
  873. action = determine_cleanup_action(
  874. {"configured_status": "AD_STATUS_NORMAL"},
  875. {
  876. "element_result_list": [
  877. {"element_id": 101, "system_status": "AD_STATUS_DENIED"}
  878. ]
  879. },
  880. )
  881. self.assertIsNone(action)
  882. def test_tencent_client_uses_official_creative_delete_endpoint(self):
  883. from tencent_client import TencentClient
  884. response = Mock(status_code=200, text="ok")
  885. response.raise_for_status.return_value = None
  886. response.json.return_value = {"code": 0, "data": {}}
  887. client = TencentClient()
  888. client.session.post = Mock(return_value=response)
  889. client._common_params = Mock(return_value={"nonce": "1"})
  890. client._user_token = Mock(return_value="user-token")
  891. client.get_dynamic_creative = Mock(
  892. side_effect=RuntimeError("Dynamic creative not found: account=1 creative=3")
  893. )
  894. result = client.delete_dynamic_creative(1, 3)
  895. request = client.session.post.call_args
  896. self.assertTrue(request.args[0].endswith("/dynamic_creatives/delete"))
  897. self.assertEqual(
  898. request.kwargs["json"],
  899. {"account_id": 1, "dynamic_creative_id": 3},
  900. )
  901. self.assertEqual(result["configured_status"], "AD_STATUS_DELETED")
  902. def test_tencent_creative_delete_accepts_deleted_system_status(self):
  903. from tencent_client import TencentClient
  904. response = Mock(status_code=200, text="ok")
  905. response.raise_for_status.return_value = None
  906. response.json.return_value = {"code": 0, "data": {}}
  907. client = TencentClient()
  908. client.session.post = Mock(return_value=response)
  909. client._common_params = Mock(return_value={"nonce": "1"})
  910. client._user_token = Mock(return_value="user-token")
  911. client.get_dynamic_creative = Mock(return_value={
  912. "dynamic_creative_id": 3,
  913. "configured_status": "AD_STATUS_NORMAL",
  914. "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
  915. "creative_set_approval_status": (
  916. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  917. ),
  918. })
  919. result = client.delete_dynamic_creative(1, 3)
  920. self.assertEqual(result["system_status"], "DYNAMIC_CREATIVE_STATUS_DELETED")
  921. self.assertFalse(result["deleted_from_listing"])
  922. self.assertEqual(client.get_dynamic_creative.call_count, 1)
  923. def test_scan_excludes_creative_with_deleted_system_status(self):
  924. from tools import creative_rejection_cleanup as cleanup
  925. tencent = Mock()
  926. tencent.get_dynamic_creatives.return_value = [{
  927. "dynamic_creative_id": 3,
  928. "configured_status": "AD_STATUS_NORMAL",
  929. "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
  930. "creative_set_approval_status": cleanup.CREATIVE_PARTIAL_NORMAL_STATUS,
  931. }]
  932. tencent.get_ads.return_value = []
  933. review_fetcher = Mock(return_value=[])
  934. result = cleanup._scan_one_account(
  935. {"account_id": 1},
  936. tencent=tencent,
  937. review_fetcher=review_fetcher,
  938. spend_start_date=date(2026, 8, 14),
  939. spend_end_date=date(2026, 8, 16),
  940. )
  941. self.assertEqual(result[5], 0)
  942. self.assertEqual(result[0], [])
  943. review_fetcher.assert_not_called()
  944. tencent.get_dynamic_creative_costs.assert_not_called()
  945. def test_invalid_access_token_skips_all_remaining_account_scans(self):
  946. from tools import creative_rejection_cleanup as cleanup
  947. tencent = Mock()
  948. tencent.get_dynamic_creatives.side_effect = RuntimeError(
  949. "get_dynamic_creatives failed: code=11002 "
  950. "message=您的 access_token 无效,请通过 oauth/authorize 接口获取 token。"
  951. )
  952. review_fetcher = Mock()
  953. result = cleanup._scan_one_account(
  954. {"account_id": 1},
  955. tencent=tencent,
  956. review_fetcher=review_fetcher,
  957. spend_start_date=date(2026, 8, 14),
  958. spend_end_date=date(2026, 8, 16),
  959. performance_enabled=True,
  960. ad_cleanup_enabled=True,
  961. )
  962. self.assertTrue(cleanup._is_token_skipped_scan_error(result[6]))
  963. tencent.get_ads.assert_not_called()
  964. review_fetcher.assert_not_called()
  965. tencent.get_dynamic_creative_costs.assert_not_called()
  966. tencent.get_dynamic_creative_metrics.assert_not_called()
  967. tencent.get_ad_metrics.assert_not_called()
  968. def test_tencent_creative_delete_retries_explicit_rate_limit_only(self):
  969. import tencent_client
  970. TencentClient = tencent_client.TencentClient
  971. limited = Mock(status_code=429, text="too many requests", headers={})
  972. limited.raise_for_status.side_effect = AssertionError(
  973. "429 should be handled before raise_for_status"
  974. )
  975. success = Mock(status_code=200, text="ok", headers={})
  976. success.raise_for_status.return_value = None
  977. success.json.return_value = {"code": 0, "data": {}}
  978. client = TencentClient()
  979. client.session.post = Mock(side_effect=[limited, success])
  980. client._common_params = Mock(
  981. side_effect=[{"nonce": "1"}, {"nonce": "2"}]
  982. )
  983. client._user_token = Mock(return_value="user-token")
  984. client.get_dynamic_creative = Mock(
  985. side_effect=RuntimeError("Dynamic creative not found: account=1 creative=3")
  986. )
  987. limiter = Mock()
  988. with patch.dict(
  989. os.environ,
  990. {
  991. "TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS": "0",
  992. "TENCENT_AD_DELETE_RATE_LIMIT_RETRIES": "1",
  993. "TENCENT_AD_DELETE_RATE_LIMIT_BACKOFF_SECONDS": "0",
  994. },
  995. clear=False,
  996. ), patch.object(tencent_client, "_CREATIVE_DELETE_RATE_LIMITER", limiter):
  997. result = client.delete_dynamic_creative(1, 3)
  998. self.assertEqual(result["configured_status"], "AD_STATUS_DELETED")
  999. self.assertEqual(client.session.post.call_count, 2)
  1000. self.assertEqual(client._common_params.call_count, 2)
  1001. self.assertEqual(limiter.wait.call_args_list, [call(0.0)] * 2)
  1002. limiter.defer.assert_called_once_with(0.0)
  1003. def test_tencent_creative_delete_honors_shared_retry_after_cooldown(self):
  1004. import tencent_client
  1005. limited = Mock(
  1006. status_code=429,
  1007. text="too many requests",
  1008. headers={"Retry-After": "2"},
  1009. )
  1010. success = Mock(status_code=200, text="ok", headers={})
  1011. success.raise_for_status.return_value = None
  1012. success.json.return_value = {"code": 0, "data": {}}
  1013. client = tencent_client.TencentClient()
  1014. client.session.post = Mock(side_effect=[limited, success])
  1015. client._common_params = Mock(return_value={"nonce": "1"})
  1016. client._user_token = Mock(return_value="user-token")
  1017. client.get_dynamic_creative = Mock(
  1018. side_effect=RuntimeError("Dynamic creative not found: account=1 creative=3")
  1019. )
  1020. limiter = Mock()
  1021. with patch.dict(
  1022. os.environ,
  1023. {
  1024. "TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS": "0.25",
  1025. "TENCENT_AD_DELETE_RATE_LIMIT_RETRIES": "1",
  1026. "TENCENT_AD_DELETE_RATE_LIMIT_BACKOFF_SECONDS": "1",
  1027. },
  1028. clear=False,
  1029. ), patch.object(tencent_client, "_CREATIVE_DELETE_RATE_LIMITER", limiter):
  1030. client.delete_dynamic_creative(1, 3)
  1031. self.assertEqual(limiter.wait.call_args_list, [call(0.25)] * 2)
  1032. limiter.defer.assert_called_once_with(2.0)
  1033. def test_tencent_creative_delete_exhausted_rate_limit_still_cools_workers(self):
  1034. import tencent_client
  1035. client = tencent_client.TencentClient()
  1036. limited = Mock(
  1037. status_code=429,
  1038. text="too many requests",
  1039. headers={"Retry-After": "3"},
  1040. )
  1041. client.session.post = Mock(return_value=limited)
  1042. client._common_params = Mock(return_value={"nonce": "1"})
  1043. client._user_token = Mock(return_value="user-token")
  1044. limiter = Mock()
  1045. with patch.dict(
  1046. os.environ,
  1047. {
  1048. "TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS": "0.25",
  1049. "TENCENT_AD_DELETE_RATE_LIMIT_RETRIES": "0",
  1050. "TENCENT_AD_DELETE_RATE_LIMIT_BACKOFF_SECONDS": "1",
  1051. },
  1052. clear=False,
  1053. ), patch.object(tencent_client, "_CREATIVE_DELETE_RATE_LIMITER", limiter):
  1054. with self.assertRaises(tencent_client.TencentWriteRateLimitedError):
  1055. client.delete_dynamic_creative(1, 3)
  1056. limiter.wait.assert_called_once_with(0.25)
  1057. limiter.defer.assert_called_once_with(3.0)
  1058. self.assertEqual(client.session.post.call_count, 1)
  1059. def test_tencent_creative_delete_invalid_retry_after_uses_backoff(self):
  1060. import tencent_client
  1061. client = tencent_client.TencentClient()
  1062. limited = Mock(
  1063. status_code=429,
  1064. text="too many requests",
  1065. headers={"Retry-After": "NaN"},
  1066. )
  1067. client.session.post = Mock(return_value=limited)
  1068. client._common_params = Mock(return_value={"nonce": "1"})
  1069. client._user_token = Mock(return_value="user-token")
  1070. limiter = Mock()
  1071. with patch.dict(
  1072. os.environ,
  1073. {
  1074. "TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS": "0",
  1075. "TENCENT_AD_DELETE_RATE_LIMIT_RETRIES": "0",
  1076. "TENCENT_AD_DELETE_RATE_LIMIT_BACKOFF_SECONDS": "1.5",
  1077. },
  1078. clear=False,
  1079. ), patch.object(tencent_client, "_CREATIVE_DELETE_RATE_LIMITER", limiter):
  1080. with self.assertRaises(tencent_client.TencentWriteRateLimitedError):
  1081. client.delete_dynamic_creative(1, 3)
  1082. limiter.defer.assert_called_once_with(1.5)
  1083. def test_tencent_creative_delete_retries_business_rate_limit_payload(self):
  1084. import tencent_client
  1085. client = tencent_client.TencentClient()
  1086. limited = Mock(status_code=200, text="limited", headers={})
  1087. limited.raise_for_status.return_value = None
  1088. limited.json.return_value = {
  1089. "code": 12002,
  1090. "message_cn": "请求频繁,请稍后重试",
  1091. }
  1092. success = Mock(status_code=200, text="ok", headers={})
  1093. success.raise_for_status.return_value = None
  1094. success.json.return_value = {"code": 0, "data": {}}
  1095. client.session.post = Mock(side_effect=[limited, success])
  1096. client._common_params = Mock(
  1097. side_effect=[{"nonce": "1"}, {"nonce": "2"}]
  1098. )
  1099. client._user_token = Mock(return_value="user-token")
  1100. client.get_dynamic_creative = Mock(
  1101. side_effect=RuntimeError("Dynamic creative not found: account=1 creative=3")
  1102. )
  1103. limiter = Mock()
  1104. with patch.dict(
  1105. os.environ,
  1106. {
  1107. "TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS": "0",
  1108. "TENCENT_AD_DELETE_RATE_LIMIT_RETRIES": "1",
  1109. "TENCENT_AD_DELETE_RATE_LIMIT_BACKOFF_SECONDS": "2",
  1110. },
  1111. clear=False,
  1112. ), patch.object(tencent_client, "_CREATIVE_DELETE_RATE_LIMITER", limiter):
  1113. result = client.delete_dynamic_creative(1, 3)
  1114. self.assertEqual(result["configured_status"], "AD_STATUS_DELETED")
  1115. self.assertEqual(client.session.post.call_count, 2)
  1116. self.assertEqual(limiter.defer.call_args_list, [call(2.0)])
  1117. def test_tencent_creative_delete_does_not_retry_success_message_about_qps(self):
  1118. import tencent_client
  1119. client = tencent_client.TencentClient()
  1120. success = Mock(status_code=200, text="ok", headers={})
  1121. success.raise_for_status.return_value = None
  1122. success.json.return_value = {
  1123. "code": 0,
  1124. "message": "success; current QPS quota is available",
  1125. "data": {},
  1126. }
  1127. client.session.post = Mock(return_value=success)
  1128. client._common_params = Mock(return_value={"nonce": "1"})
  1129. client._user_token = Mock(return_value="user-token")
  1130. client.get_dynamic_creative = Mock(
  1131. side_effect=RuntimeError("Dynamic creative not found: account=1 creative=3")
  1132. )
  1133. limiter = Mock()
  1134. with patch.dict(
  1135. os.environ,
  1136. {
  1137. "TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS": "0",
  1138. "TENCENT_AD_DELETE_RATE_LIMIT_RETRIES": "2",
  1139. "TENCENT_AD_DELETE_RATE_LIMIT_BACKOFF_SECONDS": "1",
  1140. },
  1141. clear=False,
  1142. ), patch.object(tencent_client, "_CREATIVE_DELETE_RATE_LIMITER", limiter):
  1143. result = client.delete_dynamic_creative(1, 3)
  1144. self.assertEqual(result["configured_status"], "AD_STATUS_DELETED")
  1145. self.assertEqual(client.session.post.call_count, 1)
  1146. limiter.defer.assert_not_called()
  1147. def test_tencent_creative_delete_does_not_retry_unknown_network_failure(self):
  1148. from tencent_client import TencentClient, TencentWriteOutcomeUnknownError
  1149. client = TencentClient()
  1150. client.session.post = Mock(
  1151. side_effect=__import__("requests").RequestException("connection reset")
  1152. )
  1153. client._common_params = Mock(return_value={"nonce": "1"})
  1154. client._user_token = Mock(return_value="user-token")
  1155. with patch.dict(
  1156. os.environ,
  1157. {
  1158. "TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS": "0",
  1159. "TENCENT_AD_DELETE_RATE_LIMIT_RETRIES": "3",
  1160. "TENCENT_AD_DELETE_RATE_LIMIT_BACKOFF_SECONDS": "0",
  1161. },
  1162. clear=False,
  1163. ):
  1164. with self.assertRaises(TencentWriteOutcomeUnknownError):
  1165. client.delete_dynamic_creative(1, 3)
  1166. self.assertEqual(client.session.post.call_count, 1)
  1167. def test_tencent_client_uses_component_id_for_component_delete(self):
  1168. from tencent_client import TencentClient
  1169. response = Mock(status_code=200, text="ok")
  1170. response.raise_for_status.return_value = None
  1171. response.json.return_value = {"code": 0, "data": {}}
  1172. client = TencentClient()
  1173. client.session.post = Mock(return_value=response)
  1174. client._common_params = Mock(return_value={"nonce": "1"})
  1175. client.get_creative_component = Mock(
  1176. return_value={"component_id": 201, "is_deleted": True}
  1177. )
  1178. result = client.delete_creative_component(1, 201)
  1179. request = client.session.post.call_args
  1180. self.assertTrue(request.args[0].endswith("/components/delete"))
  1181. self.assertEqual(request.kwargs["json"]["component_id"], 201)
  1182. self.assertEqual(
  1183. request.kwargs["json"]["delete_strategy"],
  1184. "DELETE_STRATEGY_FORCE",
  1185. )
  1186. self.assertTrue(result["is_deleted"])
  1187. def test_agency_context_prefers_creative_and_rejects_ambiguous_fallback(self):
  1188. from tools.creative_rejection_cleanup import build_agency_context
  1189. daily = pd.DataFrame(
  1190. [
  1191. {
  1192. "entity_type": "self",
  1193. "账号id": "1",
  1194. "账号名称": "账户一",
  1195. "创意id": "101",
  1196. "代理名称": "小程序-代投-棱镜",
  1197. },
  1198. {
  1199. "entity_type": "self",
  1200. "账号id": "2",
  1201. "账号名称": "账户二",
  1202. "创意id": "201",
  1203. "代理名称": "代理A",
  1204. },
  1205. {
  1206. "entity_type": "self",
  1207. "账号id": "2",
  1208. "账号名称": "账户二",
  1209. "创意id": "202",
  1210. "代理名称": "代理B",
  1211. },
  1212. {
  1213. "entity_type": "self",
  1214. "账号id": "3",
  1215. "账号名称": "账户三",
  1216. "创意id": "301",
  1217. "代理名称": float("nan"),
  1218. },
  1219. ]
  1220. )
  1221. context = build_agency_context(daily)
  1222. self.assertEqual(
  1223. context["creative_agencies"][(1, 101)],
  1224. "小程序-代投-棱镜",
  1225. )
  1226. self.assertEqual(context["account_agencies"][1], "小程序-代投-棱镜")
  1227. self.assertNotIn(2, context["account_agencies"])
  1228. self.assertNotIn(3, context["account_agencies"])
  1229. def test_agency_context_uses_latest_date_regardless_of_row_order(self):
  1230. from tools.creative_rejection_cleanup import build_agency_context
  1231. rows = [
  1232. {
  1233. "entity_type": "self",
  1234. "dt": "20260810",
  1235. "账号id": "1",
  1236. "创意id": "101",
  1237. "代理名称": "旧代理",
  1238. },
  1239. {
  1240. "entity_type": "self",
  1241. "dt": "20260812",
  1242. "账号id": "1",
  1243. "创意id": "101",
  1244. "代理名称": "新代理",
  1245. },
  1246. ]
  1247. for ordered_rows in (rows, list(reversed(rows))):
  1248. context = build_agency_context(pd.DataFrame(ordered_rows))
  1249. self.assertEqual(context["creative_agencies"][(1, 101)], "新代理")
  1250. self.assertEqual(context["account_agencies"][1], "新代理")
  1251. def test_agency_context_rejects_conflicting_agencies_on_latest_date(self):
  1252. from tools.creative_rejection_cleanup import build_agency_context
  1253. context = build_agency_context(pd.DataFrame([
  1254. {
  1255. "entity_type": "self",
  1256. "dt": "20260812",
  1257. "账号id": "1",
  1258. "创意id": "101",
  1259. "代理名称": "代理A",
  1260. },
  1261. {
  1262. "entity_type": "self",
  1263. "dt": "20260812",
  1264. "账号id": "1",
  1265. "创意id": "101",
  1266. "代理名称": "代理B",
  1267. },
  1268. ]))
  1269. self.assertNotIn((1, 101), context["creative_agencies"])
  1270. self.assertNotIn(1, context["account_agencies"])
  1271. def test_agency_report_run_id_is_independent_per_destination(self):
  1272. from tools.creative_rejection_cleanup import write_cleanup_reports
  1273. def row(item_id, agency):
  1274. return {
  1275. "id": item_id,
  1276. "agency_name": agency,
  1277. "account_id": item_id,
  1278. "adgroup_id": item_id + 10,
  1279. "dynamic_creative_id": item_id + 20,
  1280. "cleanup_action": "ALERT_ONLY",
  1281. "cleanup_status": "ALERT_PENDING",
  1282. "reject_reason": "需人工判断",
  1283. }
  1284. with tempfile.TemporaryDirectory() as directory:
  1285. _, one_report, _ = write_cleanup_reports(
  1286. [row(1, "代理A")], Path(directory), "20260812"
  1287. )
  1288. _, two_reports, _ = write_cleanup_reports(
  1289. [row(1, "代理A"), row(2, "代理B")],
  1290. Path(directory),
  1291. "20260812",
  1292. )
  1293. run_id_a = next(
  1294. report["run_id"]
  1295. for report in two_reports
  1296. if report["agency_name"] == "代理A"
  1297. )
  1298. self.assertEqual(one_report[0]["run_id"], run_id_a)
  1299. def test_daily_cleanup_schema_uses_check_date_in_unique_key(self):
  1300. schema = (
  1301. Path(__file__).parents[1]
  1302. / "tencent_realtime_control"
  1303. / "schema.sql"
  1304. ).read_text(encoding="utf-8")
  1305. self.assertIn(
  1306. "(account_id, dynamic_creative_id, check_date)",
  1307. schema,
  1308. )
  1309. def test_cleanup_report_has_required_business_columns(self):
  1310. from tools.creative_rejection_cleanup import (
  1311. AGENCY_REPORT_COLUMNS,
  1312. write_cleanup_reports,
  1313. )
  1314. now = datetime(2026, 8, 11, 11, 0)
  1315. with tempfile.TemporaryDirectory() as directory:
  1316. _, reports, item_ids = write_cleanup_reports(
  1317. [
  1318. {
  1319. "id": 7,
  1320. "agency_name": "棱镜",
  1321. "account_id": 1000000000001,
  1322. "account_name": "账户一",
  1323. "adgroup_id": 2000000000002,
  1324. "adgroup_name": "广告二",
  1325. "dynamic_creative_id": 3000000000003,
  1326. "dynamic_creative_name": "创意三",
  1327. "cleanup_action": "DELETE_CREATIVE",
  1328. "target_element_ids_json": "[]",
  1329. "target_component_ids_json": "[]",
  1330. "cleanup_status": "CREATIVE_DELETED",
  1331. "recent_cost_fen": 54261,
  1332. "cost_start_date": "2026-08-08",
  1333. "cost_end_date": "2026-08-10",
  1334. "action_reason": "创意审核状态为审核拒绝",
  1335. "reject_reason": "图片违规",
  1336. "review_result_json": json.dumps(
  1337. {
  1338. "element_result_list": [
  1339. {
  1340. "element_name": "主图",
  1341. "image_id": "10001",
  1342. "review_status": "REVIEW_STATUS_REJECTED",
  1343. "element_reject_detail_info": [
  1344. {"reason": "图片违规"}
  1345. ],
  1346. },
  1347. {
  1348. "element_name": "正常元素",
  1349. "element_id": "30003",
  1350. "system_status": "AD_STATUS_NORMAL",
  1351. },
  1352. ],
  1353. "site_set_result_list": [
  1354. {
  1355. "site_set": "SITE_SET_MOMENTS",
  1356. "site_set_id": "40004",
  1357. "system_status": (
  1358. "DYNAMIC_CREATIVE_STATUS_DENIED"
  1359. ),
  1360. "reject_message": "朋友圈版位拒绝",
  1361. },
  1362. {
  1363. "site_set": "SITE_SET_NORMAL",
  1364. "review_status": "AD_STATUS_NORMAL",
  1365. },
  1366. ],
  1367. },
  1368. ensure_ascii=False,
  1369. ),
  1370. "pre_state_json": json.dumps(
  1371. {
  1372. "configured_status": "AD_STATUS_NORMAL",
  1373. "creative_set_approval_status": (
  1374. "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  1375. ),
  1376. }
  1377. ),
  1378. "deleted_at": now,
  1379. }
  1380. ],
  1381. Path(directory),
  1382. "20260811",
  1383. )
  1384. workbook = load_workbook(reports[0]["report"])
  1385. sheet = workbook["审核不通过创意清理"]
  1386. headers = [cell.value for cell in sheet[1]]
  1387. self.assertEqual(
  1388. headers,
  1389. [
  1390. "代理名称",
  1391. "账户ID",
  1392. "账户名称",
  1393. "广告ID",
  1394. "广告名称",
  1395. "创意ID",
  1396. "创意名称",
  1397. "近3天累计历史消耗(元)",
  1398. "当日消耗(元)",
  1399. "近3天及当日累计消耗(元)",
  1400. "配置状态",
  1401. "创意审核状态",
  1402. "审核不通过原因",
  1403. "执行操作",
  1404. ],
  1405. )
  1406. self.assertEqual(headers, list(AGENCY_REPORT_COLUMNS))
  1407. for column_index, expected in (
  1408. (2, "1000000000001"),
  1409. (4, "2000000000002"),
  1410. (6, "3000000000003"),
  1411. ):
  1412. cell = sheet.cell(2, column_index)
  1413. self.assertEqual(cell.value, expected)
  1414. self.assertEqual(cell.data_type, "s")
  1415. self.assertEqual(cell.number_format, "@")
  1416. by_header = {cell.value: cell.column for cell in sheet[1]}
  1417. self.assertEqual(sheet.cell(2, by_header["配置状态"]).value, "投放中")
  1418. self.assertEqual(
  1419. sheet.cell(2, by_header["创意审核状态"]).value,
  1420. "审核拒绝",
  1421. )
  1422. self.assertEqual(
  1423. sheet.cell(2, by_header["近3天累计历史消耗(元)"]).value,
  1424. "542.61",
  1425. )
  1426. self.assertEqual(
  1427. sheet.cell(2, by_header["执行操作"]).value,
  1428. "删除创意",
  1429. )
  1430. self.assertEqual(sheet.cell(2, by_header["代理名称"]).value, "棱镜")
  1431. self.assertEqual(
  1432. sheet.cell(2, by_header["审核不通过原因"]).value,
  1433. "图片违规",
  1434. )
  1435. for removed_header in (
  1436. "消耗日期范围",
  1437. "操作判断原因",
  1438. "元素粒度审核状态",
  1439. "元素粒度审核不通过原因",
  1440. "版位粒度审核状态",
  1441. "版位粒度审核不通过原因",
  1442. "检查时间",
  1443. "元素ID",
  1444. "组件ID",
  1445. "处理结果",
  1446. "处理时间",
  1447. ):
  1448. self.assertNotIn(removed_header, by_header)
  1449. self.assertEqual(item_ids, {"棱镜": [7]})
  1450. self.assertEqual(reports[0]["notification_type"], "creative_rejection_cleanup")
  1451. def test_cleanup_report_displays_partial_creative_manual_alert(self):
  1452. from tools.creative_rejection_cleanup import write_cleanup_reports
  1453. with tempfile.TemporaryDirectory() as directory:
  1454. _, reports, _ = write_cleanup_reports(
  1455. [
  1456. {
  1457. "id": 8,
  1458. "agency_name": "棱镜",
  1459. "account_id": 1,
  1460. "account_name": "账户一",
  1461. "adgroup_id": 2,
  1462. "adgroup_name": "广告二",
  1463. "dynamic_creative_id": 3,
  1464. "dynamic_creative_name": "创意三",
  1465. "cleanup_action": "ALERT_ONLY",
  1466. "target_element_ids_json": "[]",
  1467. "target_component_ids_json": "[]",
  1468. "cleanup_status": "ALERT_PENDING",
  1469. "recent_cost_fen": 3000,
  1470. "cost_start_date": "2026-08-10",
  1471. "cost_end_date": "2026-08-12",
  1472. "reject_reason": "部分投放中,需人工判断是否删除",
  1473. "review_result_json": "{}",
  1474. "pre_state_json": json.dumps(
  1475. {
  1476. "creative_set_approval_status": (
  1477. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  1478. )
  1479. }
  1480. ),
  1481. "updated_at": datetime(2026, 8, 12, 11, 0),
  1482. }
  1483. ],
  1484. Path(directory),
  1485. "20260812",
  1486. )
  1487. sheet = load_workbook(reports[0]["report"])["审核不通过创意清理"]
  1488. columns = {cell.value: cell.column for cell in sheet[1]}
  1489. self.assertEqual(sheet.cell(2, columns["执行操作"]).value, "需人工判断")
  1490. self.assertEqual(sheet.cell(2, columns["创意审核状态"]).value, "部分投放中")
  1491. self.assertEqual(
  1492. sheet.cell(2, columns["近3天累计历史消耗(元)"]).value,
  1493. "30.00",
  1494. )
  1495. self.assertNotIn("操作判断原因", columns)
  1496. def test_operator_summary_report_contains_all_agencies(self):
  1497. from tools.creative_rejection_cleanup import (
  1498. OPERATOR_REPORT_COLUMNS,
  1499. write_cleanup_operator_summary,
  1500. )
  1501. rows = [
  1502. {
  1503. "id": index,
  1504. "agency_name": agency,
  1505. "account_id": index,
  1506. "account_name": f"账户{index}",
  1507. "adgroup_id": index + 10,
  1508. "adgroup_name": f"广告{index}",
  1509. "dynamic_creative_id": index + 20,
  1510. "dynamic_creative_name": f"创意{index}",
  1511. "cleanup_action": "DELETE_CREATIVE",
  1512. "recent_cost_fen": index * 100,
  1513. "cost_start_date": "2026-08-09",
  1514. "cost_end_date": "2026-08-11",
  1515. "action_reason": f"判断原因{index}",
  1516. "reject_reason": "图片违规",
  1517. "review_result_json": "{}",
  1518. "pre_state_json": "{}",
  1519. "updated_at": datetime(2026, 8, 12, 11, 0),
  1520. }
  1521. for index, agency in ((1, "代理A"), (2, "代理B"), (3, ""))
  1522. ]
  1523. with tempfile.TemporaryDirectory() as directory:
  1524. report = write_cleanup_operator_summary(
  1525. rows,
  1526. Path(directory),
  1527. "20260812",
  1528. "reject_20260812_abc123",
  1529. )
  1530. sheet = load_workbook(report["report"])["审核不通过创意清理"]
  1531. agencies = [sheet.cell(row, 1).value or "" for row in range(2, 5)]
  1532. headers = [cell.value for cell in sheet[1]]
  1533. columns = {cell.value: cell.column for cell in sheet[1]}
  1534. self.assertEqual(agencies, ["代理A", "代理B", ""])
  1535. self.assertEqual(headers, list(OPERATOR_REPORT_COLUMNS))
  1536. self.assertEqual(
  1537. sheet.cell(2, columns["近3天累计历史消耗(元)"]).value,
  1538. "1.00",
  1539. )
  1540. self.assertEqual(
  1541. sheet.cell(2, columns["消耗日期范围"]).value,
  1542. "2026-08-09 ~ 2026-08-11",
  1543. )
  1544. self.assertEqual(sheet.cell(2, columns["操作判断原因"]).value, "判断原因1")
  1545. self.assertEqual(headers[-2:], ["执行操作", "操作判断原因"])
  1546. self.assertEqual(report["creative_rows"], 3)
  1547. self.assertIn("投放调控", Path(report["report"]).name)
  1548. def test_operator_summary_reuses_uploaded_sheet_when_notification_retries(self):
  1549. from tools import creative_rejection_cleanup as cleanup
  1550. publisher = Mock()
  1551. publisher.send_report_card.return_value = "message-1"
  1552. with tempfile.TemporaryDirectory() as directory:
  1553. path = Path(directory) / "summary.xlsx"
  1554. path.write_bytes(b"xlsx")
  1555. report = {
  1556. "report_version": "summary-v1",
  1557. "report": str(path),
  1558. "title": "创意审核异常处理汇总",
  1559. "creative_rows": 3,
  1560. }
  1561. with patch.object(
  1562. cleanup,
  1563. "upsert_cleanup_delivery",
  1564. return_value={
  1565. "id": 9,
  1566. "status": "FAILED",
  1567. "sheet_url": "https://example.test/existing-summary",
  1568. "sheet_token": "existing-token",
  1569. },
  1570. ), patch.object(cleanup, "update_cleanup_delivery") as update:
  1571. outcome = cleanup.publish_cleanup_operator_summary(
  1572. run_id="reject_20260812_abc123",
  1573. report=report,
  1574. chat_id="chat-operator",
  1575. publisher=publisher,
  1576. now=datetime(2026, 8, 12, 11, 0),
  1577. )
  1578. publisher.upload_workbook.assert_not_called()
  1579. publisher.send_report_card.assert_called_once_with(
  1580. title="创意审核异常处理汇总",
  1581. content="本批次共 **3** 条创意,包含各代理自动删除及需人工判断的完整汇总。",
  1582. sheet_url="https://example.test/existing-summary",
  1583. chat_id="chat-operator",
  1584. button_text="查看全部处理明细",
  1585. )
  1586. self.assertEqual(outcome["status"], "SENT")
  1587. self.assertEqual(update.call_args.kwargs["status"], "SENT")
  1588. def test_operator_summary_chat_uses_ad_project_group(self):
  1589. from tools import creative_rejection_cleanup as cleanup
  1590. with patch.dict(
  1591. os.environ,
  1592. {
  1593. "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
  1594. "FEISHU_AD_PROJECT_CHAT_ID": "chat-project",
  1595. },
  1596. clear=False,
  1597. ):
  1598. self.assertEqual(cleanup._operator_summary_chat_id(), "chat-project")
  1599. with patch.dict(
  1600. os.environ,
  1601. {
  1602. "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
  1603. "FEISHU_AD_PROJECT_CHAT_ID": "",
  1604. },
  1605. clear=False,
  1606. ):
  1607. self.assertEqual(cleanup._operator_summary_chat_id(), "")
  1608. def test_apply_deletes_denied_creative_and_marks_sent_notification(self):
  1609. from tools import creative_rejection_cleanup as cleanup
  1610. @contextmanager
  1611. def acquired_lock(_name):
  1612. yield True
  1613. class FakeTencent:
  1614. def __init__(self):
  1615. self.deleted = []
  1616. self.seeded_tokens = {}
  1617. def seed_access_tokens(self, tokens):
  1618. self.seeded_tokens.update(tokens)
  1619. def get_dynamic_creatives(self, account_id):
  1620. return [
  1621. {
  1622. "dynamic_creative_id": 3,
  1623. "dynamic_creative_name": "创意三",
  1624. "adgroup_id": 2,
  1625. "configured_status": "AD_STATUS_NORMAL",
  1626. "creative_set_approval_status": (
  1627. "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  1628. ),
  1629. "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
  1630. }
  1631. ]
  1632. def get_ads(self, account_id):
  1633. return [{"adgroup_id": 2, "adgroup_name": "广告二"}]
  1634. def get_dynamic_creative(self, account_id, creative_id):
  1635. return {
  1636. "dynamic_creative_id": creative_id,
  1637. "adgroup_id": 2,
  1638. "configured_status": "AD_STATUS_NORMAL",
  1639. "creative_set_approval_status": (
  1640. "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  1641. ),
  1642. "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
  1643. }
  1644. def delete_dynamic_creative(self, account_id, creative_id):
  1645. self.deleted.append((account_id, creative_id))
  1646. return {
  1647. "dynamic_creative_id": creative_id,
  1648. "configured_status": "AD_STATUS_DELETED",
  1649. }
  1650. tencent = FakeTencent()
  1651. current = datetime(2026, 8, 11, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
  1652. retry_item = {
  1653. "id": 7,
  1654. "account_id": 1,
  1655. "account_name": "账户一",
  1656. "agency_name": "棱镜",
  1657. "adgroup_id": 2,
  1658. "adgroup_name": "广告二",
  1659. "dynamic_creative_id": 3,
  1660. "dynamic_creative_name": "创意三",
  1661. "cleanup_action": "DELETE_CREATIVE",
  1662. "target_component_ids_json": "[]",
  1663. "target_element_ids_json": "[]",
  1664. "reject_reason": "图片违规",
  1665. "cleanup_status": "DISCOVERED",
  1666. }
  1667. deleted_item = {
  1668. **retry_item,
  1669. "cleanup_status": "CREATIVE_DELETED",
  1670. "deleted_at": current,
  1671. }
  1672. updates = []
  1673. with tempfile.TemporaryDirectory() as directory, patch.dict(
  1674. os.environ,
  1675. {
  1676. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  1677. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  1678. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  1679. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
  1680. {
  1681. "棱镜": (
  1682. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  1683. "test-cleanup-route"
  1684. )
  1685. }
  1686. ),
  1687. },
  1688. clear=False,
  1689. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1690. cleanup, "resolve_end_date", return_value="20260810"
  1691. ), patch.object(
  1692. cleanup, "fetch_daily_data", return_value=pd.DataFrame(
  1693. [
  1694. {
  1695. "entity_type": "self",
  1696. "账号id": "1",
  1697. "账号名称": "账户一",
  1698. "创意id": "3",
  1699. "代理名称": "棱镜",
  1700. }
  1701. ]
  1702. )
  1703. ), patch.object(
  1704. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  1705. ), patch.object(
  1706. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1707. ), patch.object(
  1708. cleanup,
  1709. "prefetch_account_access_tokens",
  1710. return_value={1: "access-token-account-1"},
  1711. ), patch.object(
  1712. cleanup, "upsert_cleanup_candidate"
  1713. ), patch.object(
  1714. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  1715. ), patch.object(
  1716. cleanup, "claim_cleanup_item", return_value=True
  1717. ) as claim_item, patch.object(
  1718. cleanup,
  1719. "update_cleanup_item",
  1720. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  1721. ), patch.object(
  1722. cleanup, "load_unnotified_deleted_items", return_value=[deleted_item]
  1723. ), patch.object(
  1724. cleanup, "advisory_lock", side_effect=acquired_lock
  1725. ), patch.object(
  1726. cleanup,
  1727. "publish_agency_reports",
  1728. return_value=[{"agency_name": "棱镜", "status": "SENT"}],
  1729. ), patch.object(
  1730. cleanup,
  1731. "publish_cleanup_operator_summary",
  1732. return_value={"route": "投放调控汇总", "status": "SENT"},
  1733. ), patch.object(
  1734. cleanup, "mark_cleanup_items_notified"
  1735. ) as mark_notified, patch.object(
  1736. cleanup, "mark_cleanup_items_operator_notified"
  1737. ) as mark_operator_notified:
  1738. summary = cleanup.run_rejected_creative_cleanup(
  1739. output_dir=Path(directory),
  1740. now=current,
  1741. tencent=tencent,
  1742. odps=Mock(),
  1743. review_fetcher=lambda _account, _ids: [
  1744. {
  1745. "dynamic_creative_id": 3,
  1746. "reject_message_list": ["图片违规"],
  1747. }
  1748. ],
  1749. publisher=Mock(),
  1750. )
  1751. self.assertEqual(tencent.deleted, [(1, 3)])
  1752. self.assertEqual(tencent.seeded_tokens, {1: "access-token-account-1"})
  1753. self.assertEqual(summary["account_scope"], "opengid_recent_3d_spend")
  1754. self.assertEqual(summary["account_scope_start_date"], "20260808")
  1755. self.assertEqual(summary["account_scope_end_date"], "20260810")
  1756. self.assertEqual(summary["account_ids"], [1])
  1757. self.assertEqual(summary["tokens_prefetched"], 1)
  1758. self.assertEqual(summary["deleted"], 1)
  1759. claim_item.assert_called_once_with(7)
  1760. self.assertTrue(
  1761. any(
  1762. values.get("cleanup_status") == "CREATIVE_DELETED"
  1763. for _, values in updates
  1764. )
  1765. )
  1766. mark_notified.assert_called_once_with([7], current)
  1767. mark_operator_notified.assert_called_once_with([7], current)
  1768. def test_cleanup_defers_item_when_delete_rate_limit_is_exhausted(self):
  1769. from tencent_client import TencentWriteRateLimitedError
  1770. from tools import creative_rejection_cleanup as cleanup
  1771. @contextmanager
  1772. def acquired_lock(_name):
  1773. yield True
  1774. tencent = Mock()
  1775. tencent.get_dynamic_creative.return_value = {
  1776. "dynamic_creative_id": 3,
  1777. "adgroup_id": 2,
  1778. "creative_set_approval_status": cleanup.CREATIVE_DENIED_STATUS,
  1779. }
  1780. tencent.delete_dynamic_creative.side_effect = TencentWriteRateLimitedError(
  1781. "Tencent HTTP 429"
  1782. )
  1783. retry_item = {
  1784. "id": 7,
  1785. "account_id": 1,
  1786. "agency_name": "代理A",
  1787. "adgroup_id": 2,
  1788. "dynamic_creative_id": 3,
  1789. "cleanup_action": cleanup.DELETE_CREATIVE,
  1790. "cleanup_status": "WRITE_OUTCOME_UNKNOWN",
  1791. }
  1792. updates = []
  1793. with tempfile.TemporaryDirectory() as directory, patch.dict(
  1794. os.environ,
  1795. {
  1796. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  1797. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  1798. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
  1799. "代理A": (
  1800. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  1801. "test-cleanup-route"
  1802. )
  1803. }),
  1804. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  1805. },
  1806. clear=False,
  1807. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1808. cleanup, "resolve_end_date", return_value="20260812"
  1809. ), patch.object(
  1810. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1811. ), patch.object(
  1812. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1813. ), patch.object(
  1814. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1815. ), patch.object(
  1816. cleanup, "prefetch_account_access_tokens", return_value={}
  1817. ), patch.object(
  1818. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  1819. ), patch.object(
  1820. cleanup, "claim_cleanup_item", return_value=True
  1821. ), patch.object(
  1822. cleanup, "cleanup_precondition_failure", return_value=None
  1823. ), patch.object(
  1824. cleanup,
  1825. "update_cleanup_item",
  1826. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  1827. ), patch.object(
  1828. cleanup, "load_unnotified_deleted_items", return_value=[]
  1829. ), patch.object(
  1830. cleanup, "advisory_lock", side_effect=acquired_lock
  1831. ):
  1832. summary = cleanup.run_rejected_creative_cleanup(
  1833. output_dir=Path(directory),
  1834. now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
  1835. tencent=tencent,
  1836. odps=Mock(),
  1837. )
  1838. self.assertEqual(summary["deleted"], 0)
  1839. self.assertEqual(summary["deferred"], 1)
  1840. self.assertTrue(any(
  1841. values.get("cleanup_status") == "DEFERRED"
  1842. and "429" in values.get("error_message", "")
  1843. for _, values in updates
  1844. ))
  1845. self.assertFalse(any(
  1846. values.get("cleanup_status") == "WRITE_OUTCOME_UNKNOWN"
  1847. for _, values in updates
  1848. ))
  1849. def test_apply_deletes_partial_below_threshold_and_alerts_at_threshold(self):
  1850. from tools import creative_rejection_cleanup as cleanup
  1851. @contextmanager
  1852. def acquired_lock(_name):
  1853. yield True
  1854. class FakeTencent:
  1855. def __init__(self):
  1856. self.deleted = []
  1857. self.cost_requests = []
  1858. def seed_access_tokens(self, _tokens):
  1859. return None
  1860. def get_dynamic_creatives(self, _account_id):
  1861. return [
  1862. {
  1863. "dynamic_creative_id": creative_id,
  1864. "dynamic_creative_name": f"创意{creative_id}",
  1865. "adgroup_id": creative_id + 10,
  1866. "configured_status": "AD_STATUS_NORMAL",
  1867. "creative_set_approval_status": (
  1868. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  1869. ),
  1870. }
  1871. for creative_id in (3, 4)
  1872. ]
  1873. def get_ads(self, _account_id):
  1874. return [
  1875. {"adgroup_id": creative_id + 10, "adgroup_name": f"广告{creative_id}"}
  1876. for creative_id in (3, 4)
  1877. ]
  1878. def get_dynamic_creative(self, _account_id, creative_id):
  1879. return {
  1880. "dynamic_creative_id": creative_id,
  1881. "adgroup_id": creative_id + 10,
  1882. "configured_status": "AD_STATUS_NORMAL",
  1883. "creative_set_approval_status": (
  1884. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  1885. ),
  1886. }
  1887. def get_dynamic_creative_costs(
  1888. self, account_id, creative_ids, start_date, end_date
  1889. ):
  1890. self.cost_requests.append(
  1891. (account_id, list(creative_ids), start_date, end_date)
  1892. )
  1893. costs = (
  1894. {3: 2999, 4: 3000}
  1895. if start_date == date(2026, 8, 9)
  1896. else {3: 0, 4: 0}
  1897. )
  1898. return {creative_id: costs[creative_id] for creative_id in creative_ids}
  1899. def delete_dynamic_creative(self, account_id, creative_id):
  1900. self.deleted.append((account_id, creative_id))
  1901. return {"dynamic_creative_id": creative_id, "deleted_from_listing": True}
  1902. current = datetime(2026, 8, 12, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
  1903. retry_item = {
  1904. "id": 7,
  1905. "account_id": 1,
  1906. "agency_name": "棱镜",
  1907. "adgroup_id": 13,
  1908. "dynamic_creative_id": 3,
  1909. "cleanup_action": "DELETE_CREATIVE",
  1910. "target_component_ids_json": "[]",
  1911. "target_element_ids_json": "[]",
  1912. "cleanup_status": "DISCOVERED",
  1913. }
  1914. candidates = []
  1915. updates = []
  1916. tencent = FakeTencent()
  1917. with tempfile.TemporaryDirectory() as directory, patch.dict(
  1918. os.environ,
  1919. {
  1920. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  1921. "DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN": "30",
  1922. "DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS": "3",
  1923. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  1924. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  1925. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
  1926. {
  1927. "棱镜": (
  1928. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  1929. "test-cleanup-route"
  1930. )
  1931. }
  1932. ),
  1933. },
  1934. clear=False,
  1935. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1936. cleanup, "resolve_end_date", return_value="20260811"
  1937. ), patch.object(
  1938. cleanup,
  1939. "fetch_daily_data",
  1940. return_value=pd.DataFrame(
  1941. [
  1942. {
  1943. "entity_type": "self",
  1944. "账号id": "1",
  1945. "账号名称": "账户一",
  1946. "创意id": str(creative_id),
  1947. "代理名称": "棱镜",
  1948. }
  1949. for creative_id in (3, 4)
  1950. ]
  1951. ),
  1952. ), patch.object(
  1953. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  1954. ), patch.object(
  1955. cleanup,
  1956. "fetch_active_creative_inventory",
  1957. return_value=[
  1958. {
  1959. "account_id": 1,
  1960. "adgroup_id": creative_id + 10,
  1961. "creative_id": creative_id,
  1962. "create_time": datetime(
  1963. 2026, 8, 1, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")
  1964. ),
  1965. }
  1966. for creative_id in (3, 4)
  1967. ],
  1968. ), patch.object(
  1969. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1970. ), patch.object(
  1971. cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
  1972. ), patch.object(
  1973. cleanup,
  1974. "upsert_cleanup_candidate",
  1975. side_effect=lambda record: candidates.append(record) or {"id": len(candidates)},
  1976. ), patch.object(
  1977. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  1978. ), patch.object(
  1979. cleanup, "claim_cleanup_item", return_value=True
  1980. ), patch.object(
  1981. cleanup,
  1982. "update_cleanup_item",
  1983. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  1984. ), patch.object(
  1985. cleanup, "load_unnotified_deleted_items", return_value=[]
  1986. ), patch.object(
  1987. cleanup, "advisory_lock", side_effect=acquired_lock
  1988. ):
  1989. summary = cleanup.run_rejected_creative_cleanup(
  1990. output_dir=Path(directory),
  1991. now=current,
  1992. tencent=tencent,
  1993. odps=Mock(),
  1994. review_fetcher=lambda _account, _ids: [],
  1995. )
  1996. actions = {
  1997. row["dynamic_creative_id"]: row["cleanup_action"] for row in candidates
  1998. }
  1999. self.assertEqual(actions, {3: "DELETE_CREATIVE", 4: "ALERT_ONLY"})
  2000. self.assertEqual(tencent.deleted, [(1, 3)])
  2001. self.assertEqual(summary["deleted"], 1)
  2002. self.assertEqual(len(tencent.cost_requests), 2)
  2003. self.assertEqual(
  2004. [(start.isoformat(), end.isoformat()) for _, _, start, end in tencent.cost_requests],
  2005. [
  2006. ("2026-08-09", "2026-08-11"),
  2007. ("2026-08-12", "2026-08-12"),
  2008. ],
  2009. )
  2010. self.assertTrue(
  2011. any(
  2012. values.get("cleanup_status") == "CREATIVE_DELETED"
  2013. for _, values in updates
  2014. )
  2015. )
  2016. def test_review_delete_does_not_query_spend_again_inside_write_lock(self):
  2017. from tools import creative_rejection_cleanup as cleanup
  2018. @contextmanager
  2019. def acquired_lock(_name):
  2020. yield True
  2021. class FakeTencent:
  2022. def __init__(self):
  2023. self.cost_call_count = 0
  2024. self.deleted = []
  2025. def seed_access_tokens(self, _tokens):
  2026. return None
  2027. def get_dynamic_creatives(self, _account_id):
  2028. return [{
  2029. "dynamic_creative_id": 3,
  2030. "adgroup_id": 13,
  2031. "configured_status": "AD_STATUS_NORMAL",
  2032. "creative_set_approval_status": (
  2033. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
  2034. ),
  2035. }]
  2036. def get_ads(self, _account_id):
  2037. return [{"adgroup_id": 13}]
  2038. def get_dynamic_creative(self, _account_id, _creative_id):
  2039. return self.get_dynamic_creatives(1)[0]
  2040. def get_dynamic_creative_costs(self, *_args):
  2041. self.cost_call_count += 1
  2042. if self.cost_call_count <= 2:
  2043. return {3: 2999 if self.cost_call_count == 1 else 0}
  2044. raise AssertionError("review delete must not query spend inside write lock")
  2045. def delete_dynamic_creative(self, account_id, creative_id):
  2046. self.deleted.append((account_id, creative_id))
  2047. retry_item = {
  2048. "id": 7,
  2049. "account_id": 1,
  2050. "agency_name": "棱镜",
  2051. "adgroup_id": 13,
  2052. "dynamic_creative_id": 3,
  2053. "cleanup_action": "DELETE_CREATIVE",
  2054. "target_component_ids_json": "[]",
  2055. "target_element_ids_json": "[]",
  2056. "cleanup_status": "DISCOVERED",
  2057. }
  2058. updates = []
  2059. tencent = FakeTencent()
  2060. current = datetime(2026, 8, 12, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
  2061. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2062. os.environ,
  2063. {
  2064. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  2065. "DAILY_PARTIAL_CREATIVE_PROTECTION_DAYS": "3",
  2066. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  2067. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  2068. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
  2069. {
  2070. "棱镜": (
  2071. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  2072. "test-cleanup-route"
  2073. )
  2074. }
  2075. ),
  2076. },
  2077. clear=False,
  2078. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2079. cleanup, "resolve_end_date", return_value="20260811"
  2080. ), patch.object(
  2081. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  2082. ), patch.object(
  2083. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  2084. ), patch.object(
  2085. cleanup,
  2086. "fetch_active_creative_inventory",
  2087. return_value=[
  2088. {
  2089. "account_id": 1,
  2090. "adgroup_id": 13,
  2091. "creative_id": 3,
  2092. "create_time": datetime(
  2093. 2026, 8, 1, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")
  2094. ),
  2095. }
  2096. ],
  2097. ), patch.object(
  2098. cleanup, "fetch_account_agency_fallbacks", return_value={}
  2099. ), patch.object(
  2100. cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
  2101. ), patch.object(
  2102. cleanup, "upsert_cleanup_candidate"
  2103. ), patch.object(
  2104. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  2105. ), patch.object(
  2106. cleanup, "claim_cleanup_item", return_value=True
  2107. ), patch.object(
  2108. cleanup,
  2109. "update_cleanup_item",
  2110. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  2111. ), patch.object(
  2112. cleanup, "load_unnotified_deleted_items", return_value=[]
  2113. ), patch.object(
  2114. cleanup, "advisory_lock", side_effect=acquired_lock
  2115. ):
  2116. summary = cleanup.run_rejected_creative_cleanup(
  2117. output_dir=Path(directory),
  2118. now=current,
  2119. tencent=tencent,
  2120. odps=Mock(),
  2121. review_fetcher=lambda _account, _ids: [],
  2122. )
  2123. self.assertEqual(tencent.cost_call_count, 2)
  2124. self.assertEqual(tencent.deleted, [(1, 3)])
  2125. self.assertEqual(summary["deleted"], 1)
  2126. self.assertTrue(
  2127. any(
  2128. values.get("cleanup_status") == "CREATIVE_DELETED"
  2129. for _, values in updates
  2130. )
  2131. )
  2132. def test_historical_component_cleanup_candidate_is_never_executed(self):
  2133. from tools import creative_rejection_cleanup as cleanup
  2134. @contextmanager
  2135. def acquired_lock(_name):
  2136. yield True
  2137. class FakeTencent:
  2138. def __init__(self):
  2139. self.deleted_creatives = []
  2140. self.deleted_components = []
  2141. def seed_access_tokens(self, _tokens):
  2142. return None
  2143. def get_dynamic_creatives(self, _account_id):
  2144. return [{
  2145. "dynamic_creative_id": 3,
  2146. "adgroup_id": 2,
  2147. "configured_status": "AD_STATUS_NORMAL",
  2148. }]
  2149. def get_ads(self, _account_id):
  2150. return [{"adgroup_id": 2}]
  2151. def get_dynamic_creative(self, _account_id, _creative_id):
  2152. return {
  2153. "dynamic_creative_id": 3,
  2154. "adgroup_id": 2,
  2155. "configured_status": "AD_STATUS_NORMAL",
  2156. }
  2157. def get_creative_component(
  2158. self, _account_id, component_id, *, include_deleted=True
  2159. ):
  2160. return {"component_id": component_id, "is_deleted": False}
  2161. def delete_creative_component(self, account_id, component_id):
  2162. self.deleted_components.append((account_id, component_id))
  2163. return {"component_id": component_id, "is_deleted": True}
  2164. def delete_dynamic_creative(self, account_id, creative_id):
  2165. self.deleted_creatives.append((account_id, creative_id))
  2166. review = {
  2167. "dynamic_creative_id": 3,
  2168. "element_result_list": [
  2169. {
  2170. "element_id": 102,
  2171. "review_status": "AD_STATUS_DENIED",
  2172. "component_info": {"component_id": 12},
  2173. },
  2174. ],
  2175. }
  2176. retry_item = {
  2177. "id": 7,
  2178. "account_id": 1,
  2179. "agency_name": "",
  2180. "adgroup_id": 2,
  2181. "dynamic_creative_id": 3,
  2182. "cleanup_action": "DELETE_COMPONENTS",
  2183. "target_component_ids_json": "[11, 12]",
  2184. "target_element_ids_json": "[101, 102]",
  2185. "readback_json": json.dumps(
  2186. {"component_results": {"11": {"status": "DELETED"}}}
  2187. ),
  2188. "cleanup_status": "COMPONENTS_PARTIAL",
  2189. }
  2190. tencent = FakeTencent()
  2191. updates = []
  2192. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2193. os.environ,
  2194. {
  2195. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  2196. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  2197. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  2198. "ROI_AGENCY_WEBHOOKS_JSON": "{}",
  2199. },
  2200. clear=False,
  2201. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2202. cleanup, "resolve_end_date", return_value="20260810"
  2203. ), patch.object(
  2204. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  2205. ), patch.object(
  2206. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  2207. ), patch.object(
  2208. cleanup, "fetch_account_agency_fallbacks", return_value={}
  2209. ), patch.object(
  2210. cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
  2211. ), patch.object(
  2212. cleanup, "upsert_cleanup_candidate"
  2213. ), patch.object(
  2214. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  2215. ), patch.object(
  2216. cleanup,
  2217. "update_cleanup_item",
  2218. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  2219. ), patch.object(
  2220. cleanup, "load_unnotified_deleted_items", return_value=[]
  2221. ), patch.object(
  2222. cleanup, "advisory_lock", side_effect=acquired_lock
  2223. ):
  2224. summary = cleanup.run_rejected_creative_cleanup(
  2225. output_dir=Path(directory),
  2226. tencent=tencent,
  2227. odps=Mock(),
  2228. review_fetcher=lambda _account, _ids: [review],
  2229. )
  2230. self.assertEqual(tencent.deleted_creatives, [])
  2231. self.assertEqual(tencent.deleted_components, [])
  2232. self.assertEqual(summary["deleted"], 0)
  2233. self.assertTrue(
  2234. any(
  2235. values.get("cleanup_status") == "SKIPPED_REVIEW_NOT_RECONFIRMED"
  2236. for _, values in updates
  2237. )
  2238. )
  2239. def test_historical_component_candidate_without_id_is_never_executed(self):
  2240. from tools import creative_rejection_cleanup as cleanup
  2241. @contextmanager
  2242. def acquired_lock(_name):
  2243. yield True
  2244. tencent = Mock()
  2245. tencent.get_dynamic_creatives.return_value = [{
  2246. "dynamic_creative_id": 3,
  2247. "adgroup_id": 2,
  2248. "configured_status": "AD_STATUS_NORMAL",
  2249. }]
  2250. tencent.get_ads.return_value = [{"adgroup_id": 2}]
  2251. tencent.get_dynamic_creative.return_value = {
  2252. "dynamic_creative_id": 3,
  2253. "adgroup_id": 2,
  2254. "configured_status": "AD_STATUS_NORMAL",
  2255. }
  2256. review = {
  2257. "dynamic_creative_id": 3,
  2258. "element_result_list": [
  2259. {"element_id": 101, "review_status": "AD_STATUS_DENIED"}
  2260. ],
  2261. }
  2262. retry_item = {
  2263. "id": 7,
  2264. "account_id": 1,
  2265. "agency_name": "",
  2266. "adgroup_id": 2,
  2267. "dynamic_creative_id": 3,
  2268. "cleanup_action": "DELETE_COMPONENTS",
  2269. "target_component_ids_json": "[]",
  2270. "target_element_ids_json": "[101]",
  2271. "cleanup_status": "DISCOVERED",
  2272. }
  2273. updates = []
  2274. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2275. os.environ,
  2276. {
  2277. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  2278. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  2279. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  2280. "ROI_AGENCY_WEBHOOKS_JSON": "{}",
  2281. },
  2282. clear=False,
  2283. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2284. cleanup, "resolve_end_date", return_value="20260810"
  2285. ), patch.object(
  2286. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  2287. ), patch.object(
  2288. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  2289. ), patch.object(
  2290. cleanup, "fetch_account_agency_fallbacks", return_value={}
  2291. ), patch.object(
  2292. cleanup, "prefetch_account_access_tokens", return_value={1: "token-account-1"}
  2293. ), patch.object(
  2294. cleanup, "upsert_cleanup_candidate"
  2295. ), patch.object(
  2296. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  2297. ), patch.object(
  2298. cleanup,
  2299. "update_cleanup_item",
  2300. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  2301. ), patch.object(
  2302. cleanup, "load_unnotified_deleted_items", return_value=[]
  2303. ), patch.object(
  2304. cleanup, "advisory_lock", side_effect=acquired_lock
  2305. ):
  2306. cleanup.run_rejected_creative_cleanup(
  2307. output_dir=Path(directory),
  2308. tencent=tencent,
  2309. odps=Mock(),
  2310. review_fetcher=lambda _account, _ids: [review],
  2311. )
  2312. tencent.delete_dynamic_creative.assert_not_called()
  2313. tencent.delete_creative_component.assert_not_called()
  2314. self.assertTrue(
  2315. any(
  2316. values.get("cleanup_status") == "SKIPPED_REVIEW_NOT_RECONFIRMED"
  2317. for _, values in updates
  2318. )
  2319. )
  2320. def test_dry_run_discovers_but_never_deletes(self):
  2321. from tools import creative_rejection_cleanup as cleanup
  2322. tencent = Mock()
  2323. tencent.get_dynamic_creatives.return_value = [
  2324. {
  2325. "dynamic_creative_id": 3,
  2326. "adgroup_id": 2,
  2327. "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
  2328. }
  2329. ]
  2330. tencent.get_ads.return_value = [{"adgroup_id": 2}]
  2331. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2332. os.environ,
  2333. {
  2334. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  2335. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  2336. },
  2337. clear=False,
  2338. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2339. cleanup, "resolve_end_date", return_value="20260810"
  2340. ), patch.object(
  2341. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  2342. ), patch.object(
  2343. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  2344. ), patch.object(
  2345. cleanup, "fetch_account_agency_fallbacks", return_value={}
  2346. ), patch.object(
  2347. cleanup,
  2348. "prefetch_account_access_tokens",
  2349. return_value={1: "access-token-account-1"},
  2350. ), patch.object(
  2351. cleanup, "upsert_cleanup_candidate"
  2352. ), patch.object(
  2353. cleanup, "load_retryable_cleanup_items", return_value=[{"id": 7}]
  2354. ), patch.object(
  2355. cleanup, "load_unnotified_deleted_items", return_value=[]
  2356. ):
  2357. summary = cleanup.run_rejected_creative_cleanup(
  2358. output_dir=Path(directory),
  2359. tencent=tencent,
  2360. odps=Mock(),
  2361. review_fetcher=lambda _account, _ids: [],
  2362. )
  2363. tencent.delete_dynamic_creative.assert_not_called()
  2364. self.assertFalse(summary["apply_enabled"])
  2365. self.assertEqual(summary["pending_cleanup"], 1)
  2366. def test_disabled_webhook_keeps_manual_alert_pending_without_feishu_client(self):
  2367. from tools import creative_rejection_cleanup as cleanup
  2368. tencent = Mock()
  2369. tencent.get_dynamic_creatives.return_value = []
  2370. tencent.get_ads.return_value = []
  2371. pending_alert = {
  2372. "id": 7,
  2373. "agency_name": "棱镜",
  2374. "cleanup_status": "ALERT_PENDING",
  2375. }
  2376. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2377. os.environ,
  2378. {
  2379. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  2380. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  2381. "FEISHU_APP_ID": "",
  2382. "FEISHU_APP_SECRET": "",
  2383. },
  2384. clear=False,
  2385. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2386. cleanup, "resolve_end_date", return_value="20260810"
  2387. ), patch.object(
  2388. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  2389. ), patch.object(
  2390. cleanup, "fetch_recent_spend_accounts", return_value=[]
  2391. ), patch.object(
  2392. cleanup, "prefetch_account_access_tokens", return_value={}
  2393. ), patch.object(
  2394. cleanup, "load_retryable_cleanup_items", return_value=[]
  2395. ), patch.object(
  2396. cleanup, "load_unnotified_deleted_items", return_value=[pending_alert]
  2397. ), patch.object(
  2398. cleanup, "RoiFeishuPublisher"
  2399. ) as publisher, patch.object(
  2400. cleanup, "publish_agency_reports"
  2401. ) as publish:
  2402. summary = cleanup.run_rejected_creative_cleanup(
  2403. output_dir=Path(directory),
  2404. tencent=tencent,
  2405. odps=Mock(),
  2406. )
  2407. publisher.assert_not_called()
  2408. publish.assert_not_called()
  2409. self.assertEqual(summary["deliveries"], [])
  2410. def test_apply_requires_ad_project_chat_before_any_external_work(self):
  2411. from tools import creative_rejection_cleanup as cleanup
  2412. with patch.dict(
  2413. os.environ,
  2414. {
  2415. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  2416. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  2417. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps(
  2418. {
  2419. "棱镜": (
  2420. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  2421. "test-cleanup-route"
  2422. )
  2423. }
  2424. ),
  2425. "FEISHU_OPERATOR_CHAT_ID": "",
  2426. "FEISHU_AD_PROJECT_CHAT_ID": "",
  2427. },
  2428. clear=False,
  2429. ), patch.object(cleanup, "initialize_schema") as initialize:
  2430. with self.assertRaisesRegex(RuntimeError, "FEISHU_AD_PROJECT_CHAT_ID"):
  2431. cleanup.run_rejected_creative_cleanup(output_dir=Path("unused"))
  2432. initialize.assert_not_called()
  2433. def test_missing_agency_defers_before_tencent_delete(self):
  2434. from tools import creative_rejection_cleanup as cleanup
  2435. tencent = Mock()
  2436. tencent.get_dynamic_creatives.return_value = [{
  2437. "dynamic_creative_id": 3,
  2438. "adgroup_id": 2,
  2439. "creative_set_approval_status": cleanup.CREATIVE_DENIED_STATUS,
  2440. }]
  2441. tencent.get_ads.return_value = [{"adgroup_id": 2}]
  2442. retry_item = {
  2443. "id": 7,
  2444. "account_id": 1,
  2445. "agency_name": "",
  2446. "adgroup_id": 2,
  2447. "dynamic_creative_id": 3,
  2448. "cleanup_action": cleanup.DELETE_CREATIVE,
  2449. "cleanup_status": "DISCOVERED",
  2450. }
  2451. updates = []
  2452. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2453. os.environ,
  2454. {
  2455. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  2456. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  2457. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
  2458. "代理A": (
  2459. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  2460. "test-cleanup-route"
  2461. )
  2462. }),
  2463. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  2464. },
  2465. clear=False,
  2466. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2467. cleanup, "resolve_end_date", return_value="20260812"
  2468. ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
  2469. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  2470. ), patch.object(cleanup, "fetch_account_agency_fallbacks", return_value={}), patch.object(
  2471. cleanup, "prefetch_account_access_tokens", return_value={}
  2472. ), patch.object(cleanup, "upsert_cleanup_candidate"), patch.object(
  2473. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  2474. ), patch.object(
  2475. cleanup, "claim_cleanup_item", return_value=True
  2476. ), patch.object(
  2477. cleanup,
  2478. "update_cleanup_item",
  2479. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  2480. ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[]), patch.object(
  2481. cleanup, "advisory_lock"
  2482. ) as lock:
  2483. summary = cleanup.run_rejected_creative_cleanup(
  2484. output_dir=Path(directory),
  2485. now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
  2486. tencent=tencent,
  2487. odps=Mock(),
  2488. review_fetcher=lambda _account, _ids: [],
  2489. )
  2490. tencent.delete_dynamic_creative.assert_not_called()
  2491. lock.assert_not_called()
  2492. self.assertEqual(summary["deferred"], 1)
  2493. self.assertTrue(any(
  2494. values.get("cleanup_status") == "DEFERRED"
  2495. and "代理商归属为空" in values.get("error_message", "")
  2496. for _, values in updates
  2497. ))
  2498. def test_unknown_write_result_recovers_from_missing_creative_without_redelete(self):
  2499. from tools import creative_rejection_cleanup as cleanup
  2500. @contextmanager
  2501. def acquired_lock(_name):
  2502. yield True
  2503. tencent = Mock()
  2504. tencent.get_dynamic_creatives.return_value = []
  2505. tencent.get_ads.return_value = []
  2506. tencent.get_dynamic_creative.side_effect = RuntimeError(
  2507. "Dynamic creative not found: account=1 creative=3"
  2508. )
  2509. retry_item = {
  2510. "id": 7,
  2511. "account_id": 1,
  2512. "agency_name": "代理A",
  2513. "adgroup_id": 2,
  2514. "dynamic_creative_id": 3,
  2515. "cleanup_action": cleanup.DELETE_CREATIVE,
  2516. "cleanup_status": "WRITE_OUTCOME_UNKNOWN",
  2517. }
  2518. updates = []
  2519. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2520. os.environ,
  2521. {
  2522. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  2523. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  2524. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
  2525. "代理A": (
  2526. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  2527. "test-cleanup-route"
  2528. )
  2529. }),
  2530. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  2531. },
  2532. clear=False,
  2533. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2534. cleanup, "resolve_end_date", return_value="20260812"
  2535. ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
  2536. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  2537. ), patch.object(cleanup, "fetch_account_agency_fallbacks", return_value={}), patch.object(
  2538. cleanup, "prefetch_account_access_tokens", return_value={}
  2539. ), patch.object(cleanup, "upsert_cleanup_candidate"), patch.object(
  2540. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  2541. ), patch.object(
  2542. cleanup, "claim_cleanup_item", return_value=True
  2543. ), patch.object(
  2544. cleanup,
  2545. "update_cleanup_item",
  2546. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  2547. ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[]), patch.object(
  2548. cleanup, "advisory_lock", side_effect=acquired_lock
  2549. ):
  2550. summary = cleanup.run_rejected_creative_cleanup(
  2551. output_dir=Path(directory),
  2552. now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
  2553. tencent=tencent,
  2554. odps=Mock(),
  2555. )
  2556. tencent.delete_dynamic_creative.assert_not_called()
  2557. self.assertEqual(summary["deleted"], 1)
  2558. self.assertTrue(any(
  2559. values.get("cleanup_status") == "CREATIVE_DELETED"
  2560. for _, values in updates
  2561. ))
  2562. def test_unknown_write_result_recovers_from_deleted_status_without_redelete(self):
  2563. from tools import creative_rejection_cleanup as cleanup
  2564. @contextmanager
  2565. def acquired_lock(_name):
  2566. yield True
  2567. tencent = Mock()
  2568. tencent.get_dynamic_creatives.return_value = []
  2569. tencent.get_ads.return_value = []
  2570. tencent.get_dynamic_creative.return_value = {
  2571. "dynamic_creative_id": 3,
  2572. "adgroup_id": 2,
  2573. "configured_status": "AD_STATUS_NORMAL",
  2574. "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
  2575. "creative_set_approval_status": cleanup.CREATIVE_PARTIAL_NORMAL_STATUS,
  2576. }
  2577. retry_item = {
  2578. "id": 7,
  2579. "account_id": 1,
  2580. "agency_name": "代理A",
  2581. "adgroup_id": 2,
  2582. "dynamic_creative_id": 3,
  2583. "cleanup_action": cleanup.DELETE_CREATIVE,
  2584. "cleanup_status": "WRITE_OUTCOME_UNKNOWN",
  2585. }
  2586. updates = []
  2587. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2588. os.environ,
  2589. {
  2590. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  2591. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  2592. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
  2593. "代理A": (
  2594. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  2595. "test-cleanup-route"
  2596. )
  2597. }),
  2598. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  2599. },
  2600. clear=False,
  2601. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2602. cleanup, "resolve_end_date", return_value="20260812"
  2603. ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
  2604. cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
  2605. ), patch.object(cleanup, "fetch_account_agency_fallbacks", return_value={}), patch.object(
  2606. cleanup, "prefetch_account_access_tokens", return_value={}
  2607. ), patch.object(cleanup, "upsert_cleanup_candidate"), patch.object(
  2608. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  2609. ), patch.object(
  2610. cleanup, "claim_cleanup_item", return_value=True
  2611. ), patch.object(
  2612. cleanup,
  2613. "update_cleanup_item",
  2614. side_effect=lambda item_id, **values: updates.append((item_id, values)),
  2615. ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[]), patch.object(
  2616. cleanup, "advisory_lock", side_effect=acquired_lock
  2617. ):
  2618. summary = cleanup.run_rejected_creative_cleanup(
  2619. output_dir=Path(directory),
  2620. now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
  2621. tencent=tencent,
  2622. odps=Mock(),
  2623. )
  2624. tencent.delete_dynamic_creative.assert_not_called()
  2625. self.assertEqual(summary["deleted"], 1)
  2626. self.assertTrue(any(
  2627. values.get("cleanup_status") == "CREATIVE_DELETED"
  2628. and "DYNAMIC_CREATIVE_STATUS_DELETED" in values.get("readback_json", "")
  2629. for _, values in updates
  2630. ))
  2631. def test_notification_channels_retry_independently(self):
  2632. from tools import creative_rejection_cleanup as cleanup
  2633. base_row = {
  2634. "id": 7,
  2635. "check_date": date(2026, 8, 13),
  2636. "agency_name": "代理A",
  2637. "account_id": 1,
  2638. "adgroup_id": 2,
  2639. "dynamic_creative_id": 3,
  2640. "cleanup_action": cleanup.ALERT_ONLY,
  2641. "cleanup_status": "ALERT_PENDING",
  2642. "reject_reason": "需人工判断",
  2643. }
  2644. def run_with(row, *, lock_acquired=True):
  2645. @contextmanager
  2646. def notification_lock(_name):
  2647. yield lock_acquired
  2648. with tempfile.TemporaryDirectory() as directory, patch.dict(
  2649. os.environ,
  2650. {
  2651. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  2652. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  2653. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
  2654. "代理A": (
  2655. "https://open.feishu.cn/open-apis/bot/v2/hook/"
  2656. "test-cleanup-route"
  2657. )
  2658. }),
  2659. "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
  2660. },
  2661. clear=False,
  2662. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2663. cleanup, "resolve_end_date", return_value="20260812"
  2664. ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
  2665. cleanup, "fetch_recent_spend_accounts", return_value=[]
  2666. ), patch.object(cleanup, "prefetch_account_access_tokens", return_value={}), patch.object(
  2667. cleanup, "load_retryable_cleanup_items", return_value=[]
  2668. ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[row]), patch.object(
  2669. cleanup, "advisory_lock", side_effect=notification_lock
  2670. ), patch.object(
  2671. cleanup, "publish_agency_reports", return_value=[{
  2672. "agency_name": "代理A", "status": "SENT"
  2673. }]
  2674. ) as agency_publish, patch.object(
  2675. cleanup, "publish_cleanup_operator_summary", return_value={
  2676. "route": cleanup.OPERATOR_SUMMARY_ROUTE, "status": "SENT"
  2677. }
  2678. ) as operator_publish, patch.object(
  2679. cleanup, "mark_cleanup_items_notified"
  2680. ) as mark_agency, patch.object(
  2681. cleanup, "mark_cleanup_items_operator_notified"
  2682. ) as mark_operator:
  2683. cleanup.run_rejected_creative_cleanup(
  2684. output_dir=Path(directory),
  2685. now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
  2686. tencent=Mock(),
  2687. odps=Mock(),
  2688. publisher=Mock(),
  2689. )
  2690. return agency_publish, operator_publish, mark_agency, mark_operator
  2691. agency, operator, mark_agency, mark_operator = run_with({
  2692. **base_row,
  2693. "agency_notified_at": datetime(2026, 8, 13, 11, 0),
  2694. "operator_notified_at": None,
  2695. })
  2696. agency.assert_not_called()
  2697. operator.assert_called_once()
  2698. mark_agency.assert_not_called()
  2699. mark_operator.assert_called_once()
  2700. agency, operator, mark_agency, mark_operator = run_with({
  2701. **base_row,
  2702. "agency_notified_at": None,
  2703. "operator_notified_at": datetime(2026, 8, 13, 11, 0),
  2704. })
  2705. agency.assert_called_once()
  2706. operator.assert_not_called()
  2707. mark_agency.assert_called_once()
  2708. mark_operator.assert_not_called()
  2709. agency, operator, mark_agency, mark_operator = run_with(
  2710. {
  2711. **base_row,
  2712. "agency_notified_at": None,
  2713. "operator_notified_at": None,
  2714. },
  2715. lock_acquired=False,
  2716. )
  2717. agency.assert_not_called()
  2718. operator.assert_not_called()
  2719. mark_agency.assert_not_called()
  2720. mark_operator.assert_not_called()
  2721. def test_cleanup_entry_returns_nonzero_for_notification_failure(self):
  2722. from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
  2723. summary = {
  2724. "deleted": 3,
  2725. "deferred": 1,
  2726. "scan_errors": [],
  2727. "delete_errors": [],
  2728. "notification_errors": ["operator failed"],
  2729. }
  2730. with patch("logging_setup.setup_logging"), patch.object(
  2731. entry, "run_rejected_creative_cleanup", return_value=summary
  2732. ), patch.object(entry.logger, "error") as error_log:
  2733. self.assertEqual(entry.main(), 1)
  2734. error_log.assert_called_once()
  2735. message, *values = error_log.call_args.args
  2736. self.assertIn("result=partial_failure", message)
  2737. self.assertEqual(values[:5], [3, 1, 0, 0, 1])
  2738. self.assertIn("operator failed", values[5])
  2739. def test_cleanup_entry_delete_failure_notifies_but_returns_zero(self):
  2740. from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
  2741. delete_errors = [
  2742. "account=1 creative=2: delete_dynamic_creative failed: code=30000"
  2743. ]
  2744. summary = {
  2745. "deleted": 3,
  2746. "deferred": 1,
  2747. "scan_errors": [],
  2748. "delete_errors": delete_errors,
  2749. "notification_errors": [],
  2750. }
  2751. with patch("logging_setup.setup_logging"), patch.object(
  2752. entry, "run_rejected_creative_cleanup", return_value=summary
  2753. ), patch.object(
  2754. entry, "_notify_delete_failures"
  2755. ) as notify_delete_failures:
  2756. self.assertEqual(entry.main(), 0)
  2757. notify_delete_failures.assert_called_once_with(delete_errors)
  2758. def test_delete_failure_notification_reuses_cleanup_failure_destination(self):
  2759. from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
  2760. publisher = Mock()
  2761. with patch.dict(
  2762. os.environ,
  2763. {"CREATIVE_CLEANUP_FAILURE_FEISHU_CHAT_ID": "chat-cleanup-failure"},
  2764. clear=False,
  2765. ), patch.object(
  2766. entry, "RoiFeishuPublisher", return_value=publisher
  2767. ):
  2768. entry._notify_delete_failures(["account=1 creative=2: 腾讯系统繁忙"])
  2769. publisher.send_service_alert.assert_called_once()
  2770. alert = publisher.send_service_alert.call_args.kwargs
  2771. self.assertEqual(alert["chat_id"], "chat-cleanup-failure")
  2772. self.assertIn("删除失败", alert["title"])
  2773. self.assertIn("不影响任务成功状态", alert["content"])
  2774. publisher.close.assert_called_once_with()
  2775. def test_cleanup_entry_exposes_underperformance_preview_only_mode(self):
  2776. from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
  2777. summary = {
  2778. "scan_errors": [],
  2779. "delete_errors": [],
  2780. "notification_errors": [],
  2781. }
  2782. output_dir = Path("/tmp/underperformance-preview-test")
  2783. with patch("logging_setup.setup_logging"), patch.object(
  2784. entry, "run_rejected_creative_cleanup", return_value=summary
  2785. ) as run:
  2786. self.assertEqual(
  2787. entry.main([
  2788. "--underperformance-preview-only",
  2789. "--output-dir",
  2790. str(output_dir),
  2791. ]),
  2792. 0,
  2793. )
  2794. run.assert_called_once_with(
  2795. output_dir=output_dir,
  2796. underperformance_preview_only=True,
  2797. force_notification=False,
  2798. )
  2799. def test_force_notification_requires_underperformance_preview(self):
  2800. from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
  2801. with self.assertRaises(SystemExit):
  2802. entry.parse_args(["--force-notification"])
  2803. def test_cleanup_entry_exposes_force_notification_mode(self):
  2804. from examples.auto_put_ad_mini import cleanup_rejected_creatives as entry
  2805. summary = {
  2806. "scan_errors": [],
  2807. "delete_errors": [],
  2808. "notification_errors": [],
  2809. }
  2810. with patch("logging_setup.setup_logging"), patch.object(
  2811. entry, "run_rejected_creative_cleanup", return_value=summary
  2812. ) as run:
  2813. self.assertEqual(
  2814. entry.main([
  2815. "--underperformance-preview-only",
  2816. "--force-notification",
  2817. ]),
  2818. 0,
  2819. )
  2820. run.assert_called_once_with(
  2821. output_dir=entry.HERE / "outputs" / "creative_rejection_cleanup",
  2822. underperformance_preview_only=True,
  2823. force_notification=True,
  2824. )
  2825. def test_underperformance_preview_forces_no_delete_and_skips_review(self):
  2826. from tools import creative_rejection_cleanup as cleanup
  2827. review_pending_row = {
  2828. "id": 99,
  2829. "check_date": date(2026, 8, 20),
  2830. "cleanup_rule_type": cleanup.REVIEW_DENIED_RULE,
  2831. }
  2832. tencent = Mock()
  2833. with patch.dict(
  2834. os.environ,
  2835. {
  2836. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  2837. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
  2838. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "1",
  2839. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "1",
  2840. },
  2841. clear=False,
  2842. ), patch.object(cleanup, "initialize_schema"), patch.object(
  2843. cleanup, "resolve_end_date"
  2844. ) as resolve_review_date, patch.object(
  2845. cleanup, "fetch_daily_data"
  2846. ) as fetch_review_context, patch.object(
  2847. cleanup, "fetch_recent_spend_accounts"
  2848. ) as fetch_review_accounts, patch.object(
  2849. cleanup, "fetch_account_agency_fallbacks"
  2850. ) as fetch_agency_fallbacks, patch.object(
  2851. cleanup, "fetch_active_creative_inventory", return_value=[]
  2852. ), patch.object(
  2853. cleanup, "prefetch_account_access_tokens", return_value={}
  2854. ), patch.object(
  2855. cleanup, "load_retryable_cleanup_items", return_value=[]
  2856. ), patch.object(
  2857. cleanup,
  2858. "load_unnotified_deleted_items",
  2859. return_value=[review_pending_row],
  2860. ):
  2861. summary = cleanup.run_rejected_creative_cleanup(
  2862. output_dir=Path("/tmp/underperformance-preview-test"),
  2863. now=datetime(
  2864. 2026,
  2865. 8,
  2866. 20,
  2867. 11,
  2868. tzinfo=ZoneInfo("Asia/Shanghai"),
  2869. ),
  2870. tencent=tencent,
  2871. odps=Mock(),
  2872. publisher=Mock(),
  2873. underperformance_preview_only=True,
  2874. )
  2875. self.assertTrue(summary["underperformance_preview_only"])
  2876. self.assertTrue(summary["performance_cleanup_enabled"])
  2877. self.assertTrue(summary["ad_cleanup_enabled"])
  2878. self.assertFalse(summary["apply_enabled"])
  2879. self.assertFalse(summary["performance_apply_enabled"])
  2880. self.assertFalse(summary["ad_apply_enabled"])
  2881. resolve_review_date.assert_not_called()
  2882. fetch_review_context.assert_not_called()
  2883. fetch_review_accounts.assert_not_called()
  2884. fetch_agency_fallbacks.assert_not_called()
  2885. tencent.delete_dynamic_creative.assert_not_called()
  2886. tencent.delete_ad.assert_not_called()
  2887. def test_logging_accepts_only_safe_inherited_child_trace_id(self):
  2888. from examples.auto_put_ad_mini import logging_setup
  2889. with patch.dict(
  2890. os.environ,
  2891. {"AUTO_PUT_TRACE_ID": "parent.cleanup.1234.abcd1234"},
  2892. clear=False,
  2893. ):
  2894. self.assertEqual(
  2895. logging_setup._inherited_trace_id(),
  2896. "parent.cleanup.1234.abcd1234",
  2897. )
  2898. with patch.dict(
  2899. os.environ,
  2900. {"AUTO_PUT_TRACE_ID": "unsafe\nforged-log"},
  2901. clear=False,
  2902. ):
  2903. self.assertIsNone(logging_setup._inherited_trace_id())
  2904. def test_scheduled_subprocess_receives_searchable_child_trace_id(self):
  2905. import run_daily_service as daily_service
  2906. @contextmanager
  2907. def acquired_lock():
  2908. yield True
  2909. with patch.object(
  2910. daily_service, "advisory_lock", return_value=acquired_lock()
  2911. ), patch.object(
  2912. daily_service, "get_trace_id", return_value="parent-trace"
  2913. ), patch.object(
  2914. daily_service.time, "time_ns", return_value=123456789
  2915. ), patch.object(
  2916. daily_service.secrets, "token_hex", return_value="abcd1234"
  2917. ), patch.object(
  2918. daily_service.subprocess,
  2919. "run",
  2920. return_value=Mock(returncode=7),
  2921. ) as run:
  2922. with self.assertRaises(RuntimeError) as raised:
  2923. daily_service._run_script("failing_task.py", "test-lock")
  2924. expected_trace = "parent-trace.failing_task.123456789.abcd1234"
  2925. self.assertIn(expected_trace, str(raised.exception))
  2926. self.assertEqual(
  2927. run.call_args.kwargs["env"]["AUTO_PUT_TRACE_ID"],
  2928. expected_trace,
  2929. )
  2930. if __name__ == "__main__":
  2931. unittest.main()