operator_control.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. """Preview and execute confirmed Feishu operator commands."""
  2. from __future__ import annotations
  3. import logging
  4. import uuid
  5. from datetime import datetime, timedelta
  6. from typing import Any
  7. from operator_commands import (
  8. ACTION_DAY_PAUSE,
  9. ACTION_RESUME,
  10. ACTION_STOP,
  11. ParsedCommand,
  12. )
  13. from storage import (
  14. advisory_lock,
  15. clear_operator_pause,
  16. create_operator_command_with_items,
  17. find_pending_command_conflict,
  18. insert_operator_command_item,
  19. load_ad_states,
  20. load_operator_command_items,
  21. load_operator_command,
  22. load_operator_pauses,
  23. load_realtime_accounts,
  24. set_operator_pause,
  25. transition_operator_command,
  26. update_operator_command,
  27. update_operator_command_item,
  28. upsert_ad_state,
  29. )
  30. from tencent_client import (
  31. ACTIVE_STATUS,
  32. SUSPEND_STATUS,
  33. PostWriteVerificationError,
  34. TencentWriteNotSentError,
  35. TencentWriteOutcomeUnknownError,
  36. TencentWriteRejectedError,
  37. TencentClient,
  38. current_bid_fen,
  39. resolve_bid_field,
  40. )
  41. COMMAND_PENDING = "PENDING_CONFIRMATION"
  42. COMMAND_EXECUTING = "EXECUTING"
  43. COMMAND_SUCCEEDED = "SUCCEEDED"
  44. COMMAND_PARTIAL = "PARTIAL"
  45. COMMAND_FAILED = "FAILED"
  46. COMMAND_CANCELLED = "CANCELLED"
  47. COMMAND_EXPIRED = "EXPIRED"
  48. FINAL_COMMAND_STATUSES = {
  49. COMMAND_SUCCEEDED,
  50. COMMAND_PARTIAL,
  51. COMMAND_FAILED,
  52. COMMAND_CANCELLED,
  53. COMMAND_EXPIRED,
  54. }
  55. PAUSE_UNTIL_NEXT_DELIVERY = "UNTIL_NEXT_DELIVERY"
  56. PAUSE_UNTIL_MANUAL = "UNTIL_MANUAL"
  57. logger = logging.getLogger("tencent_realtime_control.operator_control")
  58. def _managed_account_map() -> dict[int, dict[str, Any]]:
  59. return {
  60. int(row["account_id"]): row
  61. for row in load_realtime_accounts()
  62. }
  63. def resolve_target_accounts(parsed: ParsedCommand) -> list[dict[str, Any]]:
  64. managed = _managed_account_map()
  65. if parsed.scope_type == "ALL":
  66. accounts = list(managed.values())
  67. if not accounts:
  68. raise ValueError("当前没有纳入实时控制的账户")
  69. return accounts
  70. missing = [account_id for account_id in parsed.account_ids if account_id not in managed]
  71. if missing:
  72. raise ValueError(f"账户不在实时控制范围: {missing}")
  73. return [managed[account_id] for account_id in parsed.account_ids]
  74. def _next_delivery_start(now: datetime, start_hour: int) -> datetime:
  75. return datetime.combine(
  76. now.date() + timedelta(days=1),
  77. datetime.min.time().replace(hour=start_hour),
  78. now.tzinfo,
  79. )
  80. def _delivery_end_allows_day(raw_end_date: Any, target_date: str) -> bool:
  81. end_date = str(raw_end_date or "").strip()
  82. return end_date in {"", "0"} or end_date >= target_date
  83. def _load_today_metrics(
  84. client: TencentClient,
  85. account_id: int,
  86. adgroup_ids: list[int],
  87. now: datetime,
  88. ) -> dict[int, dict[str, int]]:
  89. metrics: dict[int, dict[str, int]] = {}
  90. for start in range(0, len(adgroup_ids), 100):
  91. metrics.update(
  92. client.get_today_ad_metrics(
  93. account_id,
  94. adgroup_ids[start:start + 100],
  95. now.date(),
  96. )
  97. )
  98. return metrics
  99. def _operator_pause_is_active(state: dict[str, Any], now: datetime) -> bool:
  100. mode = str(state.get("operator_pause_mode") or "")
  101. if mode == PAUSE_UNTIL_MANUAL:
  102. return True
  103. if mode != PAUSE_UNTIL_NEXT_DELIVERY:
  104. return False
  105. resume_at = state.get("operator_resume_at")
  106. if not resume_at:
  107. return False
  108. if resume_at.tzinfo is None:
  109. resume_at = resume_at.replace(tzinfo=now.tzinfo)
  110. return resume_at > now
  111. def preview_write_command(
  112. parsed: ParsedCommand,
  113. *,
  114. now: datetime,
  115. source_message_id: str,
  116. chat_id: str,
  117. sender_open_id: str,
  118. sender_name: str | None,
  119. confirmation_ttl_minutes: int,
  120. start_hour: int,
  121. raw_text: str = "",
  122. parse_source: str = "deterministic",
  123. intent: dict[str, Any] | None = None,
  124. preview_lock_name: str = "tencent_operator_command_preview",
  125. tencent: TencentClient | None = None,
  126. ) -> dict[str, Any]:
  127. accounts = resolve_target_accounts(parsed)
  128. client = tencent or TencentClient()
  129. next_day = (now.date() + timedelta(days=1)).isoformat()
  130. preview_items: list[dict[str, Any]] = []
  131. account_summaries: list[dict[str, Any]] = []
  132. if parsed.action in {ACTION_DAY_PAUSE, ACTION_STOP}:
  133. for account in accounts:
  134. account_id = int(account["account_id"])
  135. operator_pauses = {
  136. int(row["adgroup_id"]): row
  137. for row in load_operator_pauses([account_id])
  138. if _operator_pause_is_active(row, now)
  139. }
  140. ads = [
  141. ad for ad in client.get_ads(account_id)
  142. if (
  143. (
  144. str(ad.get("configured_status") or "") == ACTIVE_STATUS
  145. and (
  146. parsed.action == ACTION_STOP
  147. or not str(ad.get("begin_date") or "")
  148. or str(ad.get("begin_date")) <= now.date().isoformat()
  149. )
  150. and (
  151. parsed.action == ACTION_STOP
  152. or _delivery_end_allows_day(
  153. ad.get("end_date"), next_day
  154. )
  155. )
  156. and (
  157. parsed.action == ACTION_STOP
  158. or int(ad.get("adgroup_id") or 0)
  159. not in operator_pauses
  160. )
  161. )
  162. or (
  163. parsed.action == ACTION_STOP
  164. and str(ad.get("configured_status") or "")
  165. == SUSPEND_STATUS
  166. and (
  167. operator_pauses.get(
  168. int(ad.get("adgroup_id") or 0),
  169. {},
  170. ).get("operator_pause_mode")
  171. == PAUSE_UNTIL_NEXT_DELIVERY
  172. )
  173. )
  174. )
  175. ]
  176. metrics = _load_today_metrics(
  177. client, account_id, [int(ad["adgroup_id"]) for ad in ads], now
  178. )
  179. account_cost = 0
  180. for ad in ads:
  181. adgroup_id = int(ad["adgroup_id"])
  182. values = metrics.get(adgroup_id, {})
  183. account_cost += int(values.get("cost_fen") or 0)
  184. preview_items.append({
  185. "account_id": account_id,
  186. "audience_name": account.get("audience_name"),
  187. "adgroup_id": adgroup_id,
  188. "adgroup_name": ad.get("adgroup_name"),
  189. "before_status": ad.get("configured_status"),
  190. "target_status": (
  191. SUSPEND_STATUS
  192. if parsed.action == ACTION_STOP
  193. else ad.get("configured_status")
  194. ),
  195. "preview_cost_fen": int(values.get("cost_fen") or 0),
  196. "preview_impressions": int(values.get("impressions") or 0),
  197. "preview_clicks": int(values.get("clicks") or 0),
  198. "preview_conversions": int(values.get("conversions") or 0),
  199. "previewed_at": now,
  200. "execution_status": "PREVIEWED",
  201. })
  202. account_summaries.append({
  203. "account_id": account_id,
  204. "ad_count": len(ads),
  205. "cost_fen": account_cost,
  206. })
  207. elif parsed.action == ACTION_RESUME:
  208. pauses_by_account: dict[int, list[dict[str, Any]]] = {}
  209. for state in load_operator_pauses([int(row["account_id"]) for row in accounts]):
  210. if not _operator_pause_is_active(state, now):
  211. continue
  212. pauses_by_account.setdefault(int(state["account_id"]), []).append(state)
  213. for account in accounts:
  214. account_id = int(account["account_id"])
  215. states = pauses_by_account.get(account_id, [])
  216. ads = {
  217. int(ad.get("adgroup_id") or 0): ad
  218. for ad in client.get_ads(account_id)
  219. }
  220. ids = [int(state["adgroup_id"]) for state in states]
  221. metrics = _load_today_metrics(client, account_id, ids, now)
  222. account_cost = 0
  223. for state in states:
  224. adgroup_id = int(state["adgroup_id"])
  225. ad = ads.get(adgroup_id, {})
  226. values = metrics.get(adgroup_id, {})
  227. account_cost += int(values.get("cost_fen") or 0)
  228. preview_items.append({
  229. "account_id": account_id,
  230. "audience_name": account.get("audience_name"),
  231. "adgroup_id": adgroup_id,
  232. "adgroup_name": ad.get("adgroup_name") or state.get("adgroup_name"),
  233. "before_status": ad.get("configured_status"),
  234. "target_status": (
  235. ACTIVE_STATUS
  236. if state.get("operator_pause_mode") == PAUSE_UNTIL_MANUAL
  237. else ad.get("configured_status")
  238. ),
  239. "preview_cost_fen": int(values.get("cost_fen") or 0),
  240. "preview_impressions": int(values.get("impressions") or 0),
  241. "preview_clicks": int(values.get("clicks") or 0),
  242. "preview_conversions": int(values.get("conversions") or 0),
  243. "previewed_at": now,
  244. "execution_status": "PREVIEWED",
  245. })
  246. account_summaries.append({
  247. "account_id": account_id,
  248. "ad_count": len(states),
  249. "cost_fen": account_cost,
  250. })
  251. else:
  252. raise ValueError(f"Unsupported write action: {parsed.action}")
  253. if not preview_items:
  254. raise ValueError("当前范围内没有符合操作条件的广告")
  255. impacted_account_ids = sorted({int(row["account_id"]) for row in preview_items})
  256. impacted_accounts = [
  257. account for account in accounts
  258. if int(account["account_id"]) in impacted_account_ids
  259. ]
  260. account_summaries = [
  261. row for row in account_summaries if int(row["account_id"]) in impacted_account_ids
  262. ]
  263. command_id = f"cmd_{now.strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
  264. expires_at = now + timedelta(minutes=confirmation_ttl_minutes)
  265. resume_at = _next_delivery_start(now, start_hour) if parsed.action == ACTION_DAY_PAUSE else None
  266. totals = {
  267. "preview_cost_fen": sum(int(row["preview_cost_fen"]) for row in preview_items),
  268. "preview_impressions": sum(int(row["preview_impressions"]) for row in preview_items),
  269. "preview_clicks": sum(int(row["preview_clicks"]) for row in preview_items),
  270. "preview_conversions": sum(int(row["preview_conversions"]) for row in preview_items),
  271. }
  272. for row in preview_items:
  273. row["command_id"] = command_id
  274. command_record = {
  275. "command_id": command_id,
  276. "source_message_id": source_message_id,
  277. "chat_id": chat_id,
  278. "sender_open_id": sender_open_id,
  279. "sender_name": sender_name,
  280. "raw_text": raw_text,
  281. "parse_source": parse_source,
  282. "intent": intent or {},
  283. "action": parsed.action,
  284. "scope_type": parsed.scope_type,
  285. "target_account_ids": impacted_account_ids,
  286. "status": COMMAND_PENDING,
  287. "preview_account_count": len(impacted_accounts),
  288. "preview_ad_count": len(preview_items),
  289. **totals,
  290. "previewed_at": now,
  291. "resume_at": resume_at,
  292. "expires_at": expires_at,
  293. }
  294. with advisory_lock(preview_lock_name) as acquired:
  295. if not acquired:
  296. raise RuntimeError("其他运营命令正在生成预览,请稍后重试")
  297. conflict = find_pending_command_conflict(
  298. [(int(row["account_id"]), int(row["adgroup_id"])) for row in preview_items],
  299. now=now,
  300. )
  301. if conflict:
  302. raise ValueError(
  303. "操作范围与待确认命令冲突: "
  304. f"{conflict['command_id']},账户 {conflict['account_id']},"
  305. f"广告 {conflict['adgroup_id']}"
  306. )
  307. command = create_operator_command_with_items(command_record, preview_items)
  308. command["account_summaries"] = account_summaries
  309. return command
  310. def cancel_command(command_id: str, sender_open_id: str, now: datetime) -> dict[str, Any]:
  311. command = load_operator_command(command_id)
  312. if not command:
  313. raise ValueError(f"命令不存在: {command_id}")
  314. if command["sender_open_id"] != sender_open_id:
  315. raise PermissionError("只能取消自己发起的命令")
  316. if command["status"] != COMMAND_PENDING:
  317. return command
  318. transition_operator_command(
  319. command_id,
  320. expected_statuses={COMMAND_PENDING},
  321. target_status=COMMAND_CANCELLED,
  322. executed_at=now,
  323. )
  324. return load_operator_command(command_id) or command
  325. def _ensure_state(
  326. *,
  327. account: dict[str, Any],
  328. ad: dict[str, Any],
  329. states: dict[int, dict[str, Any]],
  330. now: datetime,
  331. ) -> dict[str, Any]:
  332. adgroup_id = int(ad["adgroup_id"])
  333. state = states.get(adgroup_id)
  334. if state:
  335. return state
  336. bid_field = resolve_bid_field(ad, account.get("bid_scene"))
  337. base_bid = current_bid_fen(ad, bid_field)
  338. if base_bid is None or base_bid <= 0:
  339. raise ValueError(
  340. f"广告缺少有效基础出价: account={account['account_id']} ad={adgroup_id}"
  341. )
  342. upsert_ad_state(
  343. account_id=int(account["account_id"]),
  344. adgroup_id=adgroup_id,
  345. adgroup_name=str(ad.get("adgroup_name") or ""),
  346. bid_field=bid_field,
  347. base_bid_fen=base_bid,
  348. boosted_date=None,
  349. paused_by_strategy=False,
  350. pause_reason=None,
  351. last_action="REGISTER_BASE",
  352. action_at=now,
  353. )
  354. state = {
  355. "account_id": int(account["account_id"]),
  356. "adgroup_id": adgroup_id,
  357. "bid_field": bid_field,
  358. "base_bid_fen": base_bid,
  359. "paused_by_strategy": False,
  360. }
  361. states[adgroup_id] = state
  362. return state
  363. def _record_item(
  364. command: dict[str, Any],
  365. account: dict[str, Any],
  366. *,
  367. item: dict[str, Any] | None = None,
  368. **values: Any,
  369. ) -> None:
  370. if item and item.get("id"):
  371. update_operator_command_item(int(item["id"]), **values)
  372. return
  373. insert_operator_command_item(
  374. {
  375. "command_id": command["command_id"],
  376. "account_id": int(account["account_id"]),
  377. "audience_name": account.get("audience_name"),
  378. **values,
  379. }
  380. )
  381. def _execute_pause(
  382. command: dict[str, Any],
  383. account: dict[str, Any],
  384. *,
  385. now: datetime,
  386. start_hour: int,
  387. client: TencentClient,
  388. items: list[dict[str, Any]],
  389. ) -> tuple[int, int, int]:
  390. account_id = int(account["account_id"])
  391. try:
  392. ads = {
  393. int(ad.get("adgroup_id") or 0): ad
  394. for ad in client.get_ads(account_id)
  395. }
  396. except Exception as exc:
  397. for item in items:
  398. _record_item(
  399. command,
  400. account,
  401. item=item,
  402. execution_status="FAILED",
  403. error_message=f"Tencent ad query failed: {exc}",
  404. )
  405. return 0, len(items), 0
  406. states = load_ad_states(account_id)
  407. successes = failures = skipped = 0
  408. mode = (
  409. PAUSE_UNTIL_NEXT_DELIVERY
  410. if command["action"] == ACTION_DAY_PAUSE
  411. else PAUSE_UNTIL_MANUAL
  412. )
  413. resume_at = _next_delivery_start(now, start_hour) if mode == PAUSE_UNTIL_NEXT_DELIVERY else None
  414. for item in items:
  415. adgroup_id = int(item["adgroup_id"])
  416. ad = ads.get(adgroup_id)
  417. if not ad:
  418. _record_item(
  419. command, account, item=item,
  420. execution_status="FAILED", error_message="Tencent ad not found",
  421. )
  422. failures += 1
  423. continue
  424. before_status = str(ad.get("configured_status") or "")
  425. if before_status != ACTIVE_STATUS:
  426. existing_state = states.get(adgroup_id)
  427. if (
  428. command["action"] == ACTION_STOP
  429. and before_status == SUSPEND_STATUS
  430. and existing_state
  431. and existing_state.get("operator_pause_mode")
  432. ):
  433. set_operator_pause(
  434. account_id=account_id,
  435. adgroup_id=adgroup_id,
  436. mode=PAUSE_UNTIL_MANUAL,
  437. resume_at=None,
  438. command_id=command["command_id"],
  439. paused_from_status=(
  440. existing_state.get("operator_paused_from_status") or ACTIVE_STATUS
  441. ),
  442. paused_at=now,
  443. )
  444. _record_item(
  445. command,
  446. account,
  447. item=item,
  448. adgroup_id=adgroup_id,
  449. adgroup_name=ad.get("adgroup_name"),
  450. before_status=before_status,
  451. target_status=SUSPEND_STATUS,
  452. readback_status=before_status,
  453. execution_status="SUCCESS",
  454. )
  455. successes += 1
  456. continue
  457. _record_item(
  458. command,
  459. account,
  460. item=item,
  461. adgroup_id=adgroup_id,
  462. adgroup_name=ad.get("adgroup_name"),
  463. before_status=before_status,
  464. target_status=(
  465. before_status
  466. if mode == PAUSE_UNTIL_NEXT_DELIVERY
  467. else SUSPEND_STATUS
  468. ),
  469. readback_status=before_status,
  470. execution_status="SKIPPED_NOT_ACTIVE",
  471. )
  472. skipped += 1
  473. continue
  474. if (
  475. mode == PAUSE_UNTIL_NEXT_DELIVERY
  476. and str(ad.get("begin_date") or "") > now.date().isoformat()
  477. ):
  478. _record_item(
  479. command,
  480. account,
  481. item=item,
  482. adgroup_id=adgroup_id,
  483. adgroup_name=ad.get("adgroup_name"),
  484. before_status=before_status,
  485. target_status=before_status,
  486. readback_status=before_status,
  487. execution_status="SKIPPED_ALREADY_DEFERRED",
  488. )
  489. skipped += 1
  490. continue
  491. if (
  492. mode == PAUSE_UNTIL_NEXT_DELIVERY
  493. and str(ad.get("end_date") or "")
  494. and str(ad.get("end_date")) < resume_at.date().isoformat()
  495. ):
  496. _record_item(
  497. command,
  498. account,
  499. item=item,
  500. adgroup_id=adgroup_id,
  501. adgroup_name=ad.get("adgroup_name"),
  502. before_status=before_status,
  503. target_status=before_status,
  504. readback_status=before_status,
  505. execution_status="SKIPPED_END_DATE",
  506. )
  507. skipped += 1
  508. continue
  509. tencent_updated = False
  510. try:
  511. _ensure_state(account=account, ad=ad, states=states, now=now)
  512. set_operator_pause(
  513. account_id=account_id,
  514. adgroup_id=adgroup_id,
  515. mode=mode,
  516. resume_at=resume_at,
  517. command_id=command["command_id"],
  518. paused_from_status=before_status,
  519. paused_at=now,
  520. )
  521. if mode == PAUSE_UNTIL_NEXT_DELIVERY:
  522. before_begin_date = str(ad.get("begin_date") or "")
  523. next_day = resume_at.date().isoformat()
  524. target_begin_date = max(before_begin_date, next_day)
  525. if before_begin_date != target_begin_date:
  526. client.update_ad_begin_dates(
  527. account_id,
  528. [adgroup_id],
  529. target_begin_date,
  530. )
  531. tencent_updated = True
  532. readback_status = before_status
  533. target_status = before_status
  534. else:
  535. readback = client.update_ad(
  536. account_id,
  537. adgroup_id,
  538. target_status=SUSPEND_STATUS,
  539. )
  540. tencent_updated = True
  541. readback_status = readback.get("configured_status")
  542. target_status = SUSPEND_STATUS
  543. _record_item(
  544. command,
  545. account,
  546. item=item,
  547. adgroup_id=adgroup_id,
  548. adgroup_name=ad.get("adgroup_name"),
  549. before_status=before_status,
  550. target_status=target_status,
  551. readback_status=readback_status,
  552. execution_status="SUCCESS",
  553. )
  554. successes += 1
  555. except Exception as exc:
  556. if isinstance(
  557. exc,
  558. (PostWriteVerificationError, TencentWriteOutcomeUnknownError),
  559. ):
  560. tencent_updated = True
  561. safe_to_clear = isinstance(
  562. exc,
  563. (TencentWriteNotSentError, TencentWriteRejectedError),
  564. )
  565. if not tencent_updated and safe_to_clear:
  566. clear_operator_pause(
  567. account_id,
  568. adgroup_id,
  569. action="OPERATOR_PAUSE_FAILED",
  570. action_at=now,
  571. )
  572. try:
  573. _record_item(
  574. command,
  575. account,
  576. item=item,
  577. adgroup_id=adgroup_id,
  578. adgroup_name=ad.get("adgroup_name"),
  579. before_status=before_status,
  580. target_status=(
  581. before_status
  582. if mode == PAUSE_UNTIL_NEXT_DELIVERY
  583. else SUSPEND_STATUS
  584. ),
  585. readback_status=(
  586. exc.actual.get("configured_status")
  587. if isinstance(exc, PostWriteVerificationError)
  588. else (
  589. before_status
  590. if mode == PAUSE_UNTIL_NEXT_DELIVERY
  591. else SUSPEND_STATUS
  592. )
  593. if tencent_updated
  594. else None
  595. ),
  596. execution_status=(
  597. "VERIFY_FAILED"
  598. if isinstance(exc, PostWriteVerificationError)
  599. else "OUTCOME_UNKNOWN"
  600. if isinstance(exc, TencentWriteOutcomeUnknownError)
  601. else "AUDIT_FAILED"
  602. if tencent_updated
  603. else "FAILED"
  604. ),
  605. error_message=str(exc),
  606. )
  607. except Exception:
  608. logger.exception(
  609. "Failed to persist operator command failure item "
  610. "command=%s account=%s ad=%s",
  611. command["command_id"],
  612. account_id,
  613. adgroup_id,
  614. )
  615. failures += 1
  616. return successes, failures, skipped
  617. def _execute_resume(
  618. command: dict[str, Any],
  619. account: dict[str, Any],
  620. *,
  621. now: datetime,
  622. client: TencentClient,
  623. items: list[dict[str, Any]],
  624. ) -> tuple[int, int, int]:
  625. account_id = int(account["account_id"])
  626. pauses = {
  627. int(row["adgroup_id"]): row
  628. for row in load_operator_pauses([account_id])
  629. }
  630. try:
  631. ads = {
  632. int(ad.get("adgroup_id") or 0): ad
  633. for ad in client.get_ads(account_id)
  634. }
  635. except Exception as exc:
  636. for item in items:
  637. _record_item(
  638. command,
  639. account,
  640. item=item,
  641. execution_status="FAILED",
  642. error_message=f"Tencent ad query failed: {exc}",
  643. )
  644. return 0, len(items), 0
  645. successes = failures = skipped = 0
  646. for item in items:
  647. adgroup_id = int(item["adgroup_id"])
  648. state = pauses.get(adgroup_id)
  649. if not state:
  650. _record_item(
  651. command, account, item=item,
  652. execution_status="SKIPPED_NOT_PAUSED",
  653. )
  654. skipped += 1
  655. continue
  656. ad = ads.get(adgroup_id)
  657. if not ad:
  658. _record_item(
  659. command,
  660. account,
  661. item=item,
  662. adgroup_id=adgroup_id,
  663. adgroup_name=state.get("adgroup_name"),
  664. execution_status="FAILED",
  665. error_message="Tencent ad not found",
  666. )
  667. failures += 1
  668. continue
  669. before_status = str(ad.get("configured_status") or "")
  670. try:
  671. readback_status = before_status
  672. target_status = None
  673. if (
  674. state.get("operator_pause_mode") == PAUSE_UNTIL_NEXT_DELIVERY
  675. and str(ad.get("begin_date") or "") > now.date().isoformat()
  676. and not bool(state.get("paused_by_strategy"))
  677. ):
  678. client.update_ad_begin_dates(
  679. account_id,
  680. [adgroup_id],
  681. now.date().isoformat(),
  682. )
  683. elif (
  684. before_status == SUSPEND_STATUS
  685. and not bool(state.get("paused_by_strategy"))
  686. ):
  687. target_status = ACTIVE_STATUS
  688. readback = client.update_ad(
  689. account_id,
  690. adgroup_id,
  691. target_status=ACTIVE_STATUS,
  692. )
  693. readback_status = str(readback.get("configured_status") or "")
  694. clear_operator_pause(
  695. account_id,
  696. adgroup_id,
  697. action="OPERATOR_RESUME",
  698. action_at=now,
  699. )
  700. _record_item(
  701. command,
  702. account,
  703. item=item,
  704. adgroup_id=adgroup_id,
  705. adgroup_name=ad.get("adgroup_name"),
  706. before_status=before_status,
  707. target_status=target_status,
  708. readback_status=readback_status,
  709. execution_status="SUCCESS",
  710. )
  711. successes += 1
  712. except Exception as exc:
  713. _record_item(
  714. command,
  715. account,
  716. item=item,
  717. adgroup_id=adgroup_id,
  718. adgroup_name=ad.get("adgroup_name"),
  719. before_status=before_status,
  720. target_status=ACTIVE_STATUS,
  721. execution_status="FAILED",
  722. error_message=str(exc),
  723. )
  724. failures += 1
  725. return successes, failures, skipped
  726. def execute_confirmed_command(
  727. command_id: str,
  728. *,
  729. sender_open_id: str,
  730. now: datetime,
  731. start_hour: int,
  732. lock_name: str,
  733. tencent: TencentClient | None = None,
  734. ) -> dict[str, Any]:
  735. command = load_operator_command(command_id)
  736. if not command:
  737. raise ValueError(f"命令不存在: {command_id}")
  738. if command["sender_open_id"] != sender_open_id:
  739. raise PermissionError("只能确认自己发起的命令")
  740. if command["status"] in FINAL_COMMAND_STATUSES:
  741. return command
  742. if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
  743. raise RuntimeError(f"命令当前不可确认: {command['status']}")
  744. with advisory_lock(lock_name) as acquired:
  745. if not acquired:
  746. raise RuntimeError("实时控制正在执行,请稍后再次确认")
  747. command = load_operator_command(command_id) or command
  748. if command["status"] in FINAL_COMMAND_STATUSES:
  749. return command
  750. if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
  751. raise RuntimeError(f"命令当前不可确认: {command['status']}")
  752. if (
  753. command["status"] == COMMAND_PENDING
  754. and command.get("expires_at")
  755. and now.replace(tzinfo=None) > command["expires_at"]
  756. ):
  757. transition_operator_command(
  758. command_id,
  759. expected_statuses={COMMAND_PENDING},
  760. target_status=COMMAND_EXPIRED,
  761. executed_at=now,
  762. )
  763. return load_operator_command(command_id) or command
  764. if command["status"] == COMMAND_PENDING:
  765. transitioned = transition_operator_command(
  766. command_id,
  767. expected_statuses={COMMAND_PENDING},
  768. target_status=COMMAND_EXECUTING,
  769. confirmed_at=now,
  770. )
  771. if not transitioned:
  772. latest = load_operator_command(command_id) or command
  773. if latest["status"] in FINAL_COMMAND_STATUSES:
  774. return latest
  775. raise RuntimeError(
  776. f"命令状态并发变化: {latest['status']}"
  777. )
  778. try:
  779. managed = _managed_account_map()
  780. missing = [
  781. account_id
  782. for account_id in command["target_account_ids"]
  783. if account_id not in managed
  784. ]
  785. if missing:
  786. raise RuntimeError(
  787. f"确认时账户已不在自动化管理范围: {missing}"
  788. )
  789. accounts = [
  790. managed[account_id]
  791. for account_id in command["target_account_ids"]
  792. ]
  793. client = tencent or TencentClient()
  794. frozen_items = load_operator_command_items(command_id)
  795. if not frozen_items:
  796. raise RuntimeError("命令缺少冻结广告明细,请重新发起")
  797. items_by_account: dict[int, list[dict[str, Any]]] = {}
  798. for item in frozen_items:
  799. items_by_account.setdefault(int(item["account_id"]), []).append(item)
  800. successes = failures = skipped = 0
  801. for account in accounts:
  802. if command["action"] in {ACTION_DAY_PAUSE, ACTION_STOP}:
  803. ok, failed, ignored = _execute_pause(
  804. command,
  805. account,
  806. now=now,
  807. start_hour=start_hour,
  808. client=client,
  809. items=items_by_account.get(int(account["account_id"]), []),
  810. )
  811. elif command["action"] == ACTION_RESUME:
  812. ok, failed, ignored = _execute_resume(
  813. command,
  814. account,
  815. now=now,
  816. client=client,
  817. items=items_by_account.get(int(account["account_id"]), []),
  818. )
  819. else:
  820. raise ValueError(f"Unsupported command action: {command['action']}")
  821. successes += ok
  822. failures += failed
  823. skipped += ignored
  824. except Exception as exc:
  825. update_operator_command(
  826. command_id,
  827. COMMAND_FAILED,
  828. executed_at=now,
  829. error_message=str(exc),
  830. )
  831. raise
  832. status = (
  833. COMMAND_FAILED
  834. if failures and not successes
  835. else COMMAND_PARTIAL
  836. if failures
  837. else COMMAND_SUCCEEDED
  838. )
  839. update_operator_command(command_id, status, executed_at=now)
  840. result = load_operator_command(command_id) or command
  841. result["successes"] = successes
  842. result["failures"] = failures
  843. result["skipped"] = skipped
  844. return result
  845. def pause_status_summary(now: datetime) -> dict[str, Any]:
  846. account_ids = [int(row["account_id"]) for row in load_realtime_accounts()]
  847. pauses = [
  848. row for row in load_operator_pauses(account_ids)
  849. if _operator_pause_is_active(row, now)
  850. ]
  851. return {
  852. "total": len(pauses),
  853. "until_next_delivery": sum(
  854. row["operator_pause_mode"] == PAUSE_UNTIL_NEXT_DELIVERY
  855. for row in pauses
  856. ),
  857. "until_manual": sum(
  858. row["operator_pause_mode"] == PAUSE_UNTIL_MANUAL
  859. for row in pauses
  860. ),
  861. "accounts": sorted({int(row["account_id"]) for row in pauses}),
  862. }