operator_control.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  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 not _delivery_end_allows_day(
  494. ad.get("end_date"), resume_at.date().isoformat()
  495. )
  496. ):
  497. _record_item(
  498. command,
  499. account,
  500. item=item,
  501. adgroup_id=adgroup_id,
  502. adgroup_name=ad.get("adgroup_name"),
  503. before_status=before_status,
  504. target_status=before_status,
  505. readback_status=before_status,
  506. execution_status="SKIPPED_END_DATE",
  507. )
  508. skipped += 1
  509. continue
  510. tencent_updated = False
  511. try:
  512. _ensure_state(account=account, ad=ad, states=states, now=now)
  513. set_operator_pause(
  514. account_id=account_id,
  515. adgroup_id=adgroup_id,
  516. mode=mode,
  517. resume_at=resume_at,
  518. command_id=command["command_id"],
  519. paused_from_status=before_status,
  520. paused_at=now,
  521. )
  522. if mode == PAUSE_UNTIL_NEXT_DELIVERY:
  523. before_begin_date = str(ad.get("begin_date") or "")
  524. next_day = resume_at.date().isoformat()
  525. target_begin_date = max(before_begin_date, next_day)
  526. if before_begin_date != target_begin_date:
  527. client.update_ad_begin_dates(
  528. account_id,
  529. [adgroup_id],
  530. target_begin_date,
  531. )
  532. tencent_updated = True
  533. readback_status = before_status
  534. target_status = before_status
  535. else:
  536. readback = client.update_ad(
  537. account_id,
  538. adgroup_id,
  539. target_status=SUSPEND_STATUS,
  540. )
  541. tencent_updated = True
  542. readback_status = readback.get("configured_status")
  543. target_status = SUSPEND_STATUS
  544. _record_item(
  545. command,
  546. account,
  547. item=item,
  548. adgroup_id=adgroup_id,
  549. adgroup_name=ad.get("adgroup_name"),
  550. before_status=before_status,
  551. target_status=target_status,
  552. readback_status=readback_status,
  553. execution_status="SUCCESS",
  554. )
  555. successes += 1
  556. except Exception as exc:
  557. if isinstance(
  558. exc,
  559. (PostWriteVerificationError, TencentWriteOutcomeUnknownError),
  560. ):
  561. tencent_updated = True
  562. safe_to_clear = isinstance(
  563. exc,
  564. (TencentWriteNotSentError, TencentWriteRejectedError),
  565. )
  566. if not tencent_updated and safe_to_clear:
  567. clear_operator_pause(
  568. account_id,
  569. adgroup_id,
  570. action="OPERATOR_PAUSE_FAILED",
  571. action_at=now,
  572. )
  573. try:
  574. _record_item(
  575. command,
  576. account,
  577. item=item,
  578. adgroup_id=adgroup_id,
  579. adgroup_name=ad.get("adgroup_name"),
  580. before_status=before_status,
  581. target_status=(
  582. before_status
  583. if mode == PAUSE_UNTIL_NEXT_DELIVERY
  584. else SUSPEND_STATUS
  585. ),
  586. readback_status=(
  587. exc.actual.get("configured_status")
  588. if isinstance(exc, PostWriteVerificationError)
  589. else (
  590. before_status
  591. if mode == PAUSE_UNTIL_NEXT_DELIVERY
  592. else SUSPEND_STATUS
  593. )
  594. if tencent_updated
  595. else None
  596. ),
  597. execution_status=(
  598. "VERIFY_FAILED"
  599. if isinstance(exc, PostWriteVerificationError)
  600. else "OUTCOME_UNKNOWN"
  601. if isinstance(exc, TencentWriteOutcomeUnknownError)
  602. else "AUDIT_FAILED"
  603. if tencent_updated
  604. else "FAILED"
  605. ),
  606. error_message=str(exc),
  607. )
  608. except Exception:
  609. logger.exception(
  610. "Failed to persist operator command failure item "
  611. "command=%s account=%s ad=%s",
  612. command["command_id"],
  613. account_id,
  614. adgroup_id,
  615. )
  616. failures += 1
  617. return successes, failures, skipped
  618. def _execute_resume(
  619. command: dict[str, Any],
  620. account: dict[str, Any],
  621. *,
  622. now: datetime,
  623. client: TencentClient,
  624. items: list[dict[str, Any]],
  625. ) -> tuple[int, int, int]:
  626. account_id = int(account["account_id"])
  627. pauses = {
  628. int(row["adgroup_id"]): row
  629. for row in load_operator_pauses([account_id])
  630. }
  631. try:
  632. ads = {
  633. int(ad.get("adgroup_id") or 0): ad
  634. for ad in client.get_ads(account_id)
  635. }
  636. except Exception as exc:
  637. for item in items:
  638. _record_item(
  639. command,
  640. account,
  641. item=item,
  642. execution_status="FAILED",
  643. error_message=f"Tencent ad query failed: {exc}",
  644. )
  645. return 0, len(items), 0
  646. successes = failures = skipped = 0
  647. for item in items:
  648. adgroup_id = int(item["adgroup_id"])
  649. state = pauses.get(adgroup_id)
  650. if not state:
  651. _record_item(
  652. command, account, item=item,
  653. execution_status="SKIPPED_NOT_PAUSED",
  654. )
  655. skipped += 1
  656. continue
  657. ad = ads.get(adgroup_id)
  658. if not ad:
  659. _record_item(
  660. command,
  661. account,
  662. item=item,
  663. adgroup_id=adgroup_id,
  664. adgroup_name=state.get("adgroup_name"),
  665. execution_status="FAILED",
  666. error_message="Tencent ad not found",
  667. )
  668. failures += 1
  669. continue
  670. before_status = str(ad.get("configured_status") or "")
  671. try:
  672. readback_status = before_status
  673. target_status = None
  674. if (
  675. state.get("operator_pause_mode") == PAUSE_UNTIL_NEXT_DELIVERY
  676. and str(ad.get("begin_date") or "") > now.date().isoformat()
  677. and not bool(state.get("paused_by_strategy"))
  678. ):
  679. client.update_ad_begin_dates(
  680. account_id,
  681. [adgroup_id],
  682. now.date().isoformat(),
  683. )
  684. elif (
  685. before_status == SUSPEND_STATUS
  686. and not bool(state.get("paused_by_strategy"))
  687. ):
  688. target_status = ACTIVE_STATUS
  689. readback = client.update_ad(
  690. account_id,
  691. adgroup_id,
  692. target_status=ACTIVE_STATUS,
  693. )
  694. readback_status = str(readback.get("configured_status") or "")
  695. clear_operator_pause(
  696. account_id,
  697. adgroup_id,
  698. action="OPERATOR_RESUME",
  699. action_at=now,
  700. )
  701. _record_item(
  702. command,
  703. account,
  704. item=item,
  705. adgroup_id=adgroup_id,
  706. adgroup_name=ad.get("adgroup_name"),
  707. before_status=before_status,
  708. target_status=target_status,
  709. readback_status=readback_status,
  710. execution_status="SUCCESS",
  711. )
  712. successes += 1
  713. except Exception as exc:
  714. _record_item(
  715. command,
  716. account,
  717. item=item,
  718. adgroup_id=adgroup_id,
  719. adgroup_name=ad.get("adgroup_name"),
  720. before_status=before_status,
  721. target_status=ACTIVE_STATUS,
  722. execution_status="FAILED",
  723. error_message=str(exc),
  724. )
  725. failures += 1
  726. return successes, failures, skipped
  727. def execute_confirmed_command(
  728. command_id: str,
  729. *,
  730. sender_open_id: str,
  731. now: datetime,
  732. start_hour: int,
  733. lock_name: str,
  734. tencent: TencentClient | None = None,
  735. ) -> dict[str, Any]:
  736. command = load_operator_command(command_id)
  737. if not command:
  738. raise ValueError(f"命令不存在: {command_id}")
  739. if command["sender_open_id"] != sender_open_id:
  740. raise PermissionError("只能确认自己发起的命令")
  741. if command["status"] in FINAL_COMMAND_STATUSES:
  742. return command
  743. if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
  744. raise RuntimeError(f"命令当前不可确认: {command['status']}")
  745. with advisory_lock(lock_name) as acquired:
  746. if not acquired:
  747. raise RuntimeError("实时控制正在执行,请稍后再次确认")
  748. command = load_operator_command(command_id) or command
  749. if command["status"] in FINAL_COMMAND_STATUSES:
  750. return command
  751. if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
  752. raise RuntimeError(f"命令当前不可确认: {command['status']}")
  753. if (
  754. command["status"] == COMMAND_PENDING
  755. and command.get("expires_at")
  756. and now.replace(tzinfo=None) > command["expires_at"]
  757. ):
  758. transition_operator_command(
  759. command_id,
  760. expected_statuses={COMMAND_PENDING},
  761. target_status=COMMAND_EXPIRED,
  762. executed_at=now,
  763. )
  764. return load_operator_command(command_id) or command
  765. if command["status"] == COMMAND_PENDING:
  766. transitioned = transition_operator_command(
  767. command_id,
  768. expected_statuses={COMMAND_PENDING},
  769. target_status=COMMAND_EXECUTING,
  770. confirmed_at=now,
  771. )
  772. if not transitioned:
  773. latest = load_operator_command(command_id) or command
  774. if latest["status"] in FINAL_COMMAND_STATUSES:
  775. return latest
  776. raise RuntimeError(
  777. f"命令状态并发变化: {latest['status']}"
  778. )
  779. try:
  780. managed = _managed_account_map()
  781. missing = [
  782. account_id
  783. for account_id in command["target_account_ids"]
  784. if account_id not in managed
  785. ]
  786. if missing:
  787. raise RuntimeError(
  788. f"确认时账户已不在自动化管理范围: {missing}"
  789. )
  790. accounts = [
  791. managed[account_id]
  792. for account_id in command["target_account_ids"]
  793. ]
  794. client = tencent or TencentClient()
  795. frozen_items = load_operator_command_items(command_id)
  796. if not frozen_items:
  797. raise RuntimeError("命令缺少冻结广告明细,请重新发起")
  798. items_by_account: dict[int, list[dict[str, Any]]] = {}
  799. for item in frozen_items:
  800. items_by_account.setdefault(int(item["account_id"]), []).append(item)
  801. successes = failures = skipped = 0
  802. for account in accounts:
  803. if command["action"] in {ACTION_DAY_PAUSE, ACTION_STOP}:
  804. ok, failed, ignored = _execute_pause(
  805. command,
  806. account,
  807. now=now,
  808. start_hour=start_hour,
  809. client=client,
  810. items=items_by_account.get(int(account["account_id"]), []),
  811. )
  812. elif command["action"] == ACTION_RESUME:
  813. ok, failed, ignored = _execute_resume(
  814. command,
  815. account,
  816. now=now,
  817. client=client,
  818. items=items_by_account.get(int(account["account_id"]), []),
  819. )
  820. else:
  821. raise ValueError(f"Unsupported command action: {command['action']}")
  822. successes += ok
  823. failures += failed
  824. skipped += ignored
  825. except Exception as exc:
  826. update_operator_command(
  827. command_id,
  828. COMMAND_FAILED,
  829. executed_at=now,
  830. error_message=str(exc),
  831. )
  832. raise
  833. status = (
  834. COMMAND_FAILED
  835. if failures and not successes
  836. else COMMAND_PARTIAL
  837. if failures
  838. else COMMAND_SUCCEEDED
  839. )
  840. update_operator_command(command_id, status, executed_at=now)
  841. result = load_operator_command(command_id) or command
  842. result["successes"] = successes
  843. result["failures"] = failures
  844. result["skipped"] = skipped
  845. return result
  846. def pause_status_summary(now: datetime) -> dict[str, Any]:
  847. account_ids = [int(row["account_id"]) for row in load_realtime_accounts()]
  848. pauses = [
  849. row for row in load_operator_pauses(account_ids)
  850. if _operator_pause_is_active(row, now)
  851. ]
  852. return {
  853. "total": len(pauses),
  854. "until_next_delivery": sum(
  855. row["operator_pause_mode"] == PAUSE_UNTIL_NEXT_DELIVERY
  856. for row in pauses
  857. ),
  858. "until_manual": sum(
  859. row["operator_pause_mode"] == PAUSE_UNTIL_MANUAL
  860. for row in pauses
  861. ),
  862. "accounts": sorted({int(row["account_id"]) for row in pauses}),
  863. }