operator_control.py 32 KB

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