operator_control.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  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,
  17. insert_operator_command_item,
  18. load_ad_states,
  19. load_managed_accounts,
  20. load_operator_command,
  21. load_operator_pauses,
  22. set_operator_pause,
  23. transition_operator_command,
  24. update_operator_command,
  25. upsert_ad_state,
  26. )
  27. from tencent_client import (
  28. ACTIVE_STATUS,
  29. SUSPEND_STATUS,
  30. PostWriteVerificationError,
  31. TencentWriteNotSentError,
  32. TencentWriteOutcomeUnknownError,
  33. TencentWriteRejectedError,
  34. TencentClient,
  35. current_bid_fen,
  36. resolve_bid_field,
  37. )
  38. COMMAND_PENDING = "PENDING_CONFIRMATION"
  39. COMMAND_EXECUTING = "EXECUTING"
  40. COMMAND_SUCCEEDED = "SUCCEEDED"
  41. COMMAND_PARTIAL = "PARTIAL"
  42. COMMAND_FAILED = "FAILED"
  43. COMMAND_CANCELLED = "CANCELLED"
  44. COMMAND_EXPIRED = "EXPIRED"
  45. FINAL_COMMAND_STATUSES = {
  46. COMMAND_SUCCEEDED,
  47. COMMAND_PARTIAL,
  48. COMMAND_FAILED,
  49. COMMAND_CANCELLED,
  50. COMMAND_EXPIRED,
  51. }
  52. PAUSE_UNTIL_NEXT_DELIVERY = "UNTIL_NEXT_DELIVERY"
  53. PAUSE_UNTIL_MANUAL = "UNTIL_MANUAL"
  54. logger = logging.getLogger("tencent_realtime_control.operator_control")
  55. def _managed_account_map() -> dict[int, dict[str, Any]]:
  56. return {
  57. int(row["account_id"]): row
  58. for row in load_managed_accounts()
  59. }
  60. def resolve_target_accounts(parsed: ParsedCommand) -> list[dict[str, Any]]:
  61. managed = _managed_account_map()
  62. if parsed.scope_type == "ALL":
  63. accounts = list(managed.values())
  64. if not accounts:
  65. raise ValueError("当前没有启用白名单的自动化管理账户")
  66. return accounts
  67. missing = [account_id for account_id in parsed.account_ids if account_id not in managed]
  68. if missing:
  69. raise ValueError(f"账户不在自动化管理范围: {missing}")
  70. return [managed[account_id] for account_id in parsed.account_ids]
  71. def _next_delivery_start(now: datetime, start_hour: int) -> datetime:
  72. return datetime.combine(
  73. now.date() + timedelta(days=1),
  74. datetime.min.time().replace(hour=start_hour),
  75. now.tzinfo,
  76. )
  77. def preview_write_command(
  78. parsed: ParsedCommand,
  79. *,
  80. now: datetime,
  81. source_message_id: str,
  82. chat_id: str,
  83. sender_open_id: str,
  84. sender_name: str | None,
  85. confirmation_ttl_minutes: int,
  86. start_hour: int,
  87. tencent: TencentClient | None = None,
  88. ) -> dict[str, Any]:
  89. accounts = resolve_target_accounts(parsed)
  90. client = tencent or TencentClient()
  91. preview_ad_count = 0
  92. if parsed.action in {ACTION_DAY_PAUSE, ACTION_STOP}:
  93. for account in accounts:
  94. ads = client.get_ads(int(account["account_id"]))
  95. preview_ad_count += sum(
  96. str(ad.get("configured_status") or "") == ACTIVE_STATUS
  97. for ad in ads
  98. )
  99. elif parsed.action == ACTION_RESUME:
  100. preview_ad_count = len(
  101. load_operator_pauses([int(row["account_id"]) for row in accounts])
  102. )
  103. else:
  104. raise ValueError(f"Unsupported write action: {parsed.action}")
  105. command_id = f"cmd_{now.strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
  106. expires_at = now + timedelta(minutes=confirmation_ttl_minutes)
  107. command = create_operator_command(
  108. {
  109. "command_id": command_id,
  110. "source_message_id": source_message_id,
  111. "chat_id": chat_id,
  112. "sender_open_id": sender_open_id,
  113. "sender_name": sender_name,
  114. "action": parsed.action,
  115. "scope_type": parsed.scope_type,
  116. "target_account_ids": [int(row["account_id"]) for row in accounts],
  117. "status": COMMAND_PENDING,
  118. "preview_account_count": len(accounts),
  119. "preview_ad_count": preview_ad_count,
  120. "expires_at": expires_at,
  121. }
  122. )
  123. command["resume_at"] = (
  124. _next_delivery_start(now, start_hour)
  125. if parsed.action == ACTION_DAY_PAUSE
  126. else None
  127. )
  128. return command
  129. def cancel_command(command_id: str, sender_open_id: str, now: datetime) -> dict[str, Any]:
  130. command = load_operator_command(command_id)
  131. if not command:
  132. raise ValueError(f"命令不存在: {command_id}")
  133. if command["sender_open_id"] != sender_open_id:
  134. raise PermissionError("只能取消自己发起的命令")
  135. if command["status"] != COMMAND_PENDING:
  136. return command
  137. transition_operator_command(
  138. command_id,
  139. expected_statuses={COMMAND_PENDING},
  140. target_status=COMMAND_CANCELLED,
  141. executed_at=now,
  142. )
  143. return load_operator_command(command_id) or command
  144. def _ensure_state(
  145. *,
  146. account: dict[str, Any],
  147. ad: dict[str, Any],
  148. states: dict[int, dict[str, Any]],
  149. now: datetime,
  150. ) -> dict[str, Any]:
  151. adgroup_id = int(ad["adgroup_id"])
  152. state = states.get(adgroup_id)
  153. if state:
  154. return state
  155. bid_field = resolve_bid_field(ad, account.get("bid_scene"))
  156. base_bid = current_bid_fen(ad, bid_field)
  157. if base_bid is None or base_bid <= 0:
  158. raise ValueError(
  159. f"广告缺少有效基础出价: account={account['account_id']} ad={adgroup_id}"
  160. )
  161. upsert_ad_state(
  162. account_id=int(account["account_id"]),
  163. adgroup_id=adgroup_id,
  164. adgroup_name=str(ad.get("adgroup_name") or ""),
  165. bid_field=bid_field,
  166. base_bid_fen=base_bid,
  167. boosted_date=None,
  168. paused_by_strategy=False,
  169. pause_reason=None,
  170. last_action="REGISTER_BASE",
  171. action_at=now,
  172. )
  173. state = {
  174. "account_id": int(account["account_id"]),
  175. "adgroup_id": adgroup_id,
  176. "bid_field": bid_field,
  177. "base_bid_fen": base_bid,
  178. "paused_by_strategy": False,
  179. }
  180. states[adgroup_id] = state
  181. return state
  182. def _record_item(command: dict[str, Any], account: dict[str, Any], **values: Any) -> None:
  183. insert_operator_command_item(
  184. {
  185. "command_id": command["command_id"],
  186. "account_id": int(account["account_id"]),
  187. "audience_name": account.get("audience_name"),
  188. **values,
  189. }
  190. )
  191. def _execute_pause(
  192. command: dict[str, Any],
  193. account: dict[str, Any],
  194. *,
  195. now: datetime,
  196. start_hour: int,
  197. client: TencentClient,
  198. ) -> tuple[int, int]:
  199. account_id = int(account["account_id"])
  200. ads = client.get_ads(account_id)
  201. states = load_ad_states(account_id)
  202. successes = failures = 0
  203. mode = (
  204. PAUSE_UNTIL_NEXT_DELIVERY
  205. if command["action"] == ACTION_DAY_PAUSE
  206. else PAUSE_UNTIL_MANUAL
  207. )
  208. resume_at = _next_delivery_start(now, start_hour) if mode == PAUSE_UNTIL_NEXT_DELIVERY else None
  209. for ad in ads:
  210. adgroup_id = int(ad.get("adgroup_id") or 0)
  211. before_status = str(ad.get("configured_status") or "")
  212. if before_status != ACTIVE_STATUS:
  213. _record_item(
  214. command,
  215. account,
  216. adgroup_id=adgroup_id,
  217. adgroup_name=ad.get("adgroup_name"),
  218. before_status=before_status,
  219. target_status=SUSPEND_STATUS,
  220. readback_status=before_status,
  221. execution_status="SKIPPED_NOT_ACTIVE",
  222. )
  223. continue
  224. tencent_updated = False
  225. try:
  226. _ensure_state(account=account, ad=ad, states=states, now=now)
  227. set_operator_pause(
  228. account_id=account_id,
  229. adgroup_id=adgroup_id,
  230. mode=mode,
  231. resume_at=resume_at,
  232. command_id=command["command_id"],
  233. paused_from_status=before_status,
  234. paused_at=now,
  235. )
  236. readback = client.update_ad(
  237. account_id,
  238. adgroup_id,
  239. target_status=SUSPEND_STATUS,
  240. )
  241. tencent_updated = True
  242. _record_item(
  243. command,
  244. account,
  245. adgroup_id=adgroup_id,
  246. adgroup_name=ad.get("adgroup_name"),
  247. before_status=before_status,
  248. target_status=SUSPEND_STATUS,
  249. readback_status=readback.get("configured_status"),
  250. execution_status="SUCCESS",
  251. )
  252. successes += 1
  253. except Exception as exc:
  254. if isinstance(
  255. exc,
  256. (PostWriteVerificationError, TencentWriteOutcomeUnknownError),
  257. ):
  258. tencent_updated = True
  259. safe_to_clear = isinstance(
  260. exc,
  261. (TencentWriteNotSentError, TencentWriteRejectedError),
  262. )
  263. if not tencent_updated and safe_to_clear:
  264. clear_operator_pause(
  265. account_id,
  266. adgroup_id,
  267. action="OPERATOR_PAUSE_FAILED",
  268. action_at=now,
  269. )
  270. try:
  271. _record_item(
  272. command,
  273. account,
  274. adgroup_id=adgroup_id,
  275. adgroup_name=ad.get("adgroup_name"),
  276. before_status=before_status,
  277. target_status=SUSPEND_STATUS,
  278. readback_status=(
  279. exc.actual.get("configured_status")
  280. if isinstance(exc, PostWriteVerificationError)
  281. else SUSPEND_STATUS
  282. if tencent_updated
  283. else None
  284. ),
  285. execution_status=(
  286. "VERIFY_FAILED"
  287. if isinstance(exc, PostWriteVerificationError)
  288. else "OUTCOME_UNKNOWN"
  289. if isinstance(exc, TencentWriteOutcomeUnknownError)
  290. else "AUDIT_FAILED"
  291. if tencent_updated
  292. else "FAILED"
  293. ),
  294. error_message=str(exc),
  295. )
  296. except Exception:
  297. logger.exception(
  298. "Failed to persist operator command failure item "
  299. "command=%s account=%s ad=%s",
  300. command["command_id"],
  301. account_id,
  302. adgroup_id,
  303. )
  304. failures += 1
  305. return successes, failures
  306. def _execute_resume(
  307. command: dict[str, Any],
  308. account: dict[str, Any],
  309. *,
  310. now: datetime,
  311. client: TencentClient,
  312. ) -> tuple[int, int]:
  313. account_id = int(account["account_id"])
  314. pauses = load_operator_pauses([account_id])
  315. if not pauses:
  316. return 0, 0
  317. ads = {
  318. int(ad.get("adgroup_id") or 0): ad
  319. for ad in client.get_ads(account_id)
  320. }
  321. successes = failures = 0
  322. for state in pauses:
  323. adgroup_id = int(state["adgroup_id"])
  324. ad = ads.get(adgroup_id)
  325. if not ad:
  326. _record_item(
  327. command,
  328. account,
  329. adgroup_id=adgroup_id,
  330. adgroup_name=state.get("adgroup_name"),
  331. execution_status="FAILED",
  332. error_message="Tencent ad not found",
  333. )
  334. failures += 1
  335. continue
  336. before_status = str(ad.get("configured_status") or "")
  337. try:
  338. readback_status = before_status
  339. target_status = None
  340. if before_status == SUSPEND_STATUS and not bool(state.get("paused_by_strategy")):
  341. target_status = ACTIVE_STATUS
  342. readback = client.update_ad(
  343. account_id,
  344. adgroup_id,
  345. target_status=ACTIVE_STATUS,
  346. )
  347. readback_status = str(readback.get("configured_status") or "")
  348. clear_operator_pause(
  349. account_id,
  350. adgroup_id,
  351. action="OPERATOR_RESUME",
  352. action_at=now,
  353. )
  354. _record_item(
  355. command,
  356. account,
  357. adgroup_id=adgroup_id,
  358. adgroup_name=ad.get("adgroup_name"),
  359. before_status=before_status,
  360. target_status=target_status,
  361. readback_status=readback_status,
  362. execution_status="SUCCESS",
  363. )
  364. successes += 1
  365. except Exception as exc:
  366. _record_item(
  367. command,
  368. account,
  369. adgroup_id=adgroup_id,
  370. adgroup_name=ad.get("adgroup_name"),
  371. before_status=before_status,
  372. target_status=ACTIVE_STATUS,
  373. execution_status="FAILED",
  374. error_message=str(exc),
  375. )
  376. failures += 1
  377. return successes, failures
  378. def execute_confirmed_command(
  379. command_id: str,
  380. *,
  381. sender_open_id: str,
  382. now: datetime,
  383. start_hour: int,
  384. lock_name: str,
  385. tencent: TencentClient | None = None,
  386. ) -> dict[str, Any]:
  387. command = load_operator_command(command_id)
  388. if not command:
  389. raise ValueError(f"命令不存在: {command_id}")
  390. if command["sender_open_id"] != sender_open_id:
  391. raise PermissionError("只能确认自己发起的命令")
  392. if command["status"] in FINAL_COMMAND_STATUSES:
  393. return command
  394. if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
  395. raise RuntimeError(f"命令当前不可确认: {command['status']}")
  396. with advisory_lock(lock_name) as acquired:
  397. if not acquired:
  398. raise RuntimeError("实时控制正在执行,请稍后再次确认")
  399. command = load_operator_command(command_id) or command
  400. if command["status"] in FINAL_COMMAND_STATUSES:
  401. return command
  402. if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
  403. raise RuntimeError(f"命令当前不可确认: {command['status']}")
  404. if (
  405. command["status"] == COMMAND_PENDING
  406. and command.get("expires_at")
  407. and now.replace(tzinfo=None) > command["expires_at"]
  408. ):
  409. transition_operator_command(
  410. command_id,
  411. expected_statuses={COMMAND_PENDING},
  412. target_status=COMMAND_EXPIRED,
  413. executed_at=now,
  414. )
  415. return load_operator_command(command_id) or command
  416. if command["status"] == COMMAND_PENDING:
  417. transitioned = transition_operator_command(
  418. command_id,
  419. expected_statuses={COMMAND_PENDING},
  420. target_status=COMMAND_EXECUTING,
  421. confirmed_at=now,
  422. )
  423. if not transitioned:
  424. latest = load_operator_command(command_id) or command
  425. if latest["status"] in FINAL_COMMAND_STATUSES:
  426. return latest
  427. raise RuntimeError(
  428. f"命令状态并发变化: {latest['status']}"
  429. )
  430. try:
  431. managed = _managed_account_map()
  432. missing = [
  433. account_id
  434. for account_id in command["target_account_ids"]
  435. if account_id not in managed
  436. ]
  437. if missing:
  438. raise RuntimeError(
  439. f"确认时账户已不在自动化管理范围: {missing}"
  440. )
  441. accounts = [
  442. managed[account_id]
  443. for account_id in command["target_account_ids"]
  444. ]
  445. client = tencent or TencentClient()
  446. successes = failures = 0
  447. for account in accounts:
  448. if command["action"] in {ACTION_DAY_PAUSE, ACTION_STOP}:
  449. ok, failed = _execute_pause(
  450. command,
  451. account,
  452. now=now,
  453. start_hour=start_hour,
  454. client=client,
  455. )
  456. elif command["action"] == ACTION_RESUME:
  457. ok, failed = _execute_resume(
  458. command,
  459. account,
  460. now=now,
  461. client=client,
  462. )
  463. else:
  464. raise ValueError(f"Unsupported command action: {command['action']}")
  465. successes += ok
  466. failures += failed
  467. except Exception as exc:
  468. update_operator_command(
  469. command_id,
  470. COMMAND_FAILED,
  471. executed_at=now,
  472. error_message=str(exc),
  473. )
  474. raise
  475. status = (
  476. COMMAND_FAILED
  477. if failures and not successes
  478. else COMMAND_PARTIAL
  479. if failures
  480. else COMMAND_SUCCEEDED
  481. )
  482. update_operator_command(command_id, status, executed_at=now)
  483. result = load_operator_command(command_id) or command
  484. result["successes"] = successes
  485. result["failures"] = failures
  486. return result
  487. def pause_status_summary() -> dict[str, Any]:
  488. account_ids = [int(row["account_id"]) for row in load_managed_accounts()]
  489. pauses = load_operator_pauses(account_ids)
  490. return {
  491. "total": len(pauses),
  492. "until_next_delivery": sum(
  493. row["operator_pause_mode"] == PAUSE_UNTIL_NEXT_DELIVERY
  494. for row in pauses
  495. ),
  496. "until_manual": sum(
  497. row["operator_pause_mode"] == PAUSE_UNTIL_MANUAL
  498. for row in pauses
  499. ),
  500. "accounts": sorted({int(row["account_id"]) for row in pauses}),
  501. }