|
|
@@ -0,0 +1,531 @@
|
|
|
+"""Preview and execute confirmed Feishu operator commands."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import logging
|
|
|
+import uuid
|
|
|
+from datetime import datetime, timedelta
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+from operator_commands import (
|
|
|
+ ACTION_DAY_PAUSE,
|
|
|
+ ACTION_RESUME,
|
|
|
+ ACTION_STOP,
|
|
|
+ ParsedCommand,
|
|
|
+)
|
|
|
+from storage import (
|
|
|
+ advisory_lock,
|
|
|
+ clear_operator_pause,
|
|
|
+ create_operator_command,
|
|
|
+ insert_operator_command_item,
|
|
|
+ load_ad_states,
|
|
|
+ load_managed_accounts,
|
|
|
+ load_operator_command,
|
|
|
+ load_operator_pauses,
|
|
|
+ set_operator_pause,
|
|
|
+ transition_operator_command,
|
|
|
+ update_operator_command,
|
|
|
+ upsert_ad_state,
|
|
|
+)
|
|
|
+from tencent_client import (
|
|
|
+ ACTIVE_STATUS,
|
|
|
+ SUSPEND_STATUS,
|
|
|
+ PostWriteVerificationError,
|
|
|
+ TencentWriteNotSentError,
|
|
|
+ TencentWriteOutcomeUnknownError,
|
|
|
+ TencentWriteRejectedError,
|
|
|
+ TencentClient,
|
|
|
+ current_bid_fen,
|
|
|
+ resolve_bid_field,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+COMMAND_PENDING = "PENDING_CONFIRMATION"
|
|
|
+COMMAND_EXECUTING = "EXECUTING"
|
|
|
+COMMAND_SUCCEEDED = "SUCCEEDED"
|
|
|
+COMMAND_PARTIAL = "PARTIAL"
|
|
|
+COMMAND_FAILED = "FAILED"
|
|
|
+COMMAND_CANCELLED = "CANCELLED"
|
|
|
+COMMAND_EXPIRED = "EXPIRED"
|
|
|
+FINAL_COMMAND_STATUSES = {
|
|
|
+ COMMAND_SUCCEEDED,
|
|
|
+ COMMAND_PARTIAL,
|
|
|
+ COMMAND_FAILED,
|
|
|
+ COMMAND_CANCELLED,
|
|
|
+ COMMAND_EXPIRED,
|
|
|
+}
|
|
|
+
|
|
|
+PAUSE_UNTIL_NEXT_DELIVERY = "UNTIL_NEXT_DELIVERY"
|
|
|
+PAUSE_UNTIL_MANUAL = "UNTIL_MANUAL"
|
|
|
+logger = logging.getLogger("tencent_realtime_control.operator_control")
|
|
|
+
|
|
|
+
|
|
|
+def _managed_account_map() -> dict[int, dict[str, Any]]:
|
|
|
+ return {
|
|
|
+ int(row["account_id"]): row
|
|
|
+ for row in load_managed_accounts()
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def resolve_target_accounts(parsed: ParsedCommand) -> list[dict[str, Any]]:
|
|
|
+ managed = _managed_account_map()
|
|
|
+ if parsed.scope_type == "ALL":
|
|
|
+ accounts = list(managed.values())
|
|
|
+ if not accounts:
|
|
|
+ raise ValueError("当前没有启用白名单的自动化管理账户")
|
|
|
+ return accounts
|
|
|
+ missing = [account_id for account_id in parsed.account_ids if account_id not in managed]
|
|
|
+ if missing:
|
|
|
+ raise ValueError(f"账户不在自动化管理范围: {missing}")
|
|
|
+ return [managed[account_id] for account_id in parsed.account_ids]
|
|
|
+
|
|
|
+
|
|
|
+def _next_delivery_start(now: datetime, start_hour: int) -> datetime:
|
|
|
+ return datetime.combine(
|
|
|
+ now.date() + timedelta(days=1),
|
|
|
+ datetime.min.time().replace(hour=start_hour),
|
|
|
+ now.tzinfo,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def preview_write_command(
|
|
|
+ parsed: ParsedCommand,
|
|
|
+ *,
|
|
|
+ now: datetime,
|
|
|
+ source_message_id: str,
|
|
|
+ chat_id: str,
|
|
|
+ sender_open_id: str,
|
|
|
+ sender_name: str | None,
|
|
|
+ confirmation_ttl_minutes: int,
|
|
|
+ start_hour: int,
|
|
|
+ tencent: TencentClient | None = None,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ accounts = resolve_target_accounts(parsed)
|
|
|
+ client = tencent or TencentClient()
|
|
|
+ preview_ad_count = 0
|
|
|
+ if parsed.action in {ACTION_DAY_PAUSE, ACTION_STOP}:
|
|
|
+ for account in accounts:
|
|
|
+ ads = client.get_ads(int(account["account_id"]))
|
|
|
+ preview_ad_count += sum(
|
|
|
+ str(ad.get("configured_status") or "") == ACTIVE_STATUS
|
|
|
+ for ad in ads
|
|
|
+ )
|
|
|
+ elif parsed.action == ACTION_RESUME:
|
|
|
+ preview_ad_count = len(
|
|
|
+ load_operator_pauses([int(row["account_id"]) for row in accounts])
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ raise ValueError(f"Unsupported write action: {parsed.action}")
|
|
|
+
|
|
|
+ command_id = f"cmd_{now.strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
|
|
+ expires_at = now + timedelta(minutes=confirmation_ttl_minutes)
|
|
|
+ command = create_operator_command(
|
|
|
+ {
|
|
|
+ "command_id": command_id,
|
|
|
+ "source_message_id": source_message_id,
|
|
|
+ "chat_id": chat_id,
|
|
|
+ "sender_open_id": sender_open_id,
|
|
|
+ "sender_name": sender_name,
|
|
|
+ "action": parsed.action,
|
|
|
+ "scope_type": parsed.scope_type,
|
|
|
+ "target_account_ids": [int(row["account_id"]) for row in accounts],
|
|
|
+ "status": COMMAND_PENDING,
|
|
|
+ "preview_account_count": len(accounts),
|
|
|
+ "preview_ad_count": preview_ad_count,
|
|
|
+ "expires_at": expires_at,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ command["resume_at"] = (
|
|
|
+ _next_delivery_start(now, start_hour)
|
|
|
+ if parsed.action == ACTION_DAY_PAUSE
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ return command
|
|
|
+
|
|
|
+
|
|
|
+def cancel_command(command_id: str, sender_open_id: str, now: datetime) -> dict[str, Any]:
|
|
|
+ command = load_operator_command(command_id)
|
|
|
+ if not command:
|
|
|
+ raise ValueError(f"命令不存在: {command_id}")
|
|
|
+ if command["sender_open_id"] != sender_open_id:
|
|
|
+ raise PermissionError("只能取消自己发起的命令")
|
|
|
+ if command["status"] != COMMAND_PENDING:
|
|
|
+ return command
|
|
|
+ transition_operator_command(
|
|
|
+ command_id,
|
|
|
+ expected_statuses={COMMAND_PENDING},
|
|
|
+ target_status=COMMAND_CANCELLED,
|
|
|
+ executed_at=now,
|
|
|
+ )
|
|
|
+ return load_operator_command(command_id) or command
|
|
|
+
|
|
|
+
|
|
|
+def _ensure_state(
|
|
|
+ *,
|
|
|
+ account: dict[str, Any],
|
|
|
+ ad: dict[str, Any],
|
|
|
+ states: dict[int, dict[str, Any]],
|
|
|
+ now: datetime,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ adgroup_id = int(ad["adgroup_id"])
|
|
|
+ state = states.get(adgroup_id)
|
|
|
+ if state:
|
|
|
+ return state
|
|
|
+ bid_field = resolve_bid_field(ad, account.get("bid_scene"))
|
|
|
+ base_bid = current_bid_fen(ad, bid_field)
|
|
|
+ if base_bid is None or base_bid <= 0:
|
|
|
+ raise ValueError(
|
|
|
+ f"广告缺少有效基础出价: account={account['account_id']} ad={adgroup_id}"
|
|
|
+ )
|
|
|
+ upsert_ad_state(
|
|
|
+ account_id=int(account["account_id"]),
|
|
|
+ adgroup_id=adgroup_id,
|
|
|
+ adgroup_name=str(ad.get("adgroup_name") or ""),
|
|
|
+ bid_field=bid_field,
|
|
|
+ base_bid_fen=base_bid,
|
|
|
+ boosted_date=None,
|
|
|
+ paused_by_strategy=False,
|
|
|
+ pause_reason=None,
|
|
|
+ last_action="REGISTER_BASE",
|
|
|
+ action_at=now,
|
|
|
+ )
|
|
|
+ state = {
|
|
|
+ "account_id": int(account["account_id"]),
|
|
|
+ "adgroup_id": adgroup_id,
|
|
|
+ "bid_field": bid_field,
|
|
|
+ "base_bid_fen": base_bid,
|
|
|
+ "paused_by_strategy": False,
|
|
|
+ }
|
|
|
+ states[adgroup_id] = state
|
|
|
+ return state
|
|
|
+
|
|
|
+
|
|
|
+def _record_item(command: dict[str, Any], account: dict[str, Any], **values: Any) -> None:
|
|
|
+ insert_operator_command_item(
|
|
|
+ {
|
|
|
+ "command_id": command["command_id"],
|
|
|
+ "account_id": int(account["account_id"]),
|
|
|
+ "audience_name": account.get("audience_name"),
|
|
|
+ **values,
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _execute_pause(
|
|
|
+ command: dict[str, Any],
|
|
|
+ account: dict[str, Any],
|
|
|
+ *,
|
|
|
+ now: datetime,
|
|
|
+ start_hour: int,
|
|
|
+ client: TencentClient,
|
|
|
+) -> tuple[int, int]:
|
|
|
+ account_id = int(account["account_id"])
|
|
|
+ ads = client.get_ads(account_id)
|
|
|
+ states = load_ad_states(account_id)
|
|
|
+ successes = failures = 0
|
|
|
+ mode = (
|
|
|
+ PAUSE_UNTIL_NEXT_DELIVERY
|
|
|
+ if command["action"] == ACTION_DAY_PAUSE
|
|
|
+ else PAUSE_UNTIL_MANUAL
|
|
|
+ )
|
|
|
+ resume_at = _next_delivery_start(now, start_hour) if mode == PAUSE_UNTIL_NEXT_DELIVERY else None
|
|
|
+ for ad in ads:
|
|
|
+ adgroup_id = int(ad.get("adgroup_id") or 0)
|
|
|
+ before_status = str(ad.get("configured_status") or "")
|
|
|
+ if before_status != ACTIVE_STATUS:
|
|
|
+ _record_item(
|
|
|
+ command,
|
|
|
+ account,
|
|
|
+ adgroup_id=adgroup_id,
|
|
|
+ adgroup_name=ad.get("adgroup_name"),
|
|
|
+ before_status=before_status,
|
|
|
+ target_status=SUSPEND_STATUS,
|
|
|
+ readback_status=before_status,
|
|
|
+ execution_status="SKIPPED_NOT_ACTIVE",
|
|
|
+ )
|
|
|
+ continue
|
|
|
+ tencent_updated = False
|
|
|
+ try:
|
|
|
+ _ensure_state(account=account, ad=ad, states=states, now=now)
|
|
|
+ set_operator_pause(
|
|
|
+ account_id=account_id,
|
|
|
+ adgroup_id=adgroup_id,
|
|
|
+ mode=mode,
|
|
|
+ resume_at=resume_at,
|
|
|
+ command_id=command["command_id"],
|
|
|
+ paused_from_status=before_status,
|
|
|
+ paused_at=now,
|
|
|
+ )
|
|
|
+ readback = client.update_ad(
|
|
|
+ account_id,
|
|
|
+ adgroup_id,
|
|
|
+ target_status=SUSPEND_STATUS,
|
|
|
+ )
|
|
|
+ tencent_updated = True
|
|
|
+ _record_item(
|
|
|
+ command,
|
|
|
+ account,
|
|
|
+ adgroup_id=adgroup_id,
|
|
|
+ adgroup_name=ad.get("adgroup_name"),
|
|
|
+ before_status=before_status,
|
|
|
+ target_status=SUSPEND_STATUS,
|
|
|
+ readback_status=readback.get("configured_status"),
|
|
|
+ execution_status="SUCCESS",
|
|
|
+ )
|
|
|
+ successes += 1
|
|
|
+ except Exception as exc:
|
|
|
+ if isinstance(
|
|
|
+ exc,
|
|
|
+ (PostWriteVerificationError, TencentWriteOutcomeUnknownError),
|
|
|
+ ):
|
|
|
+ tencent_updated = True
|
|
|
+ safe_to_clear = isinstance(
|
|
|
+ exc,
|
|
|
+ (TencentWriteNotSentError, TencentWriteRejectedError),
|
|
|
+ )
|
|
|
+ if not tencent_updated and safe_to_clear:
|
|
|
+ clear_operator_pause(
|
|
|
+ account_id,
|
|
|
+ adgroup_id,
|
|
|
+ action="OPERATOR_PAUSE_FAILED",
|
|
|
+ action_at=now,
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ _record_item(
|
|
|
+ command,
|
|
|
+ account,
|
|
|
+ adgroup_id=adgroup_id,
|
|
|
+ adgroup_name=ad.get("adgroup_name"),
|
|
|
+ before_status=before_status,
|
|
|
+ target_status=SUSPEND_STATUS,
|
|
|
+ readback_status=(
|
|
|
+ exc.actual.get("configured_status")
|
|
|
+ if isinstance(exc, PostWriteVerificationError)
|
|
|
+ else SUSPEND_STATUS
|
|
|
+ if tencent_updated
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ execution_status=(
|
|
|
+ "VERIFY_FAILED"
|
|
|
+ if isinstance(exc, PostWriteVerificationError)
|
|
|
+ else "OUTCOME_UNKNOWN"
|
|
|
+ if isinstance(exc, TencentWriteOutcomeUnknownError)
|
|
|
+ else "AUDIT_FAILED"
|
|
|
+ if tencent_updated
|
|
|
+ else "FAILED"
|
|
|
+ ),
|
|
|
+ error_message=str(exc),
|
|
|
+ )
|
|
|
+ except Exception:
|
|
|
+ logger.exception(
|
|
|
+ "Failed to persist operator command failure item "
|
|
|
+ "command=%s account=%s ad=%s",
|
|
|
+ command["command_id"],
|
|
|
+ account_id,
|
|
|
+ adgroup_id,
|
|
|
+ )
|
|
|
+ failures += 1
|
|
|
+ return successes, failures
|
|
|
+
|
|
|
+
|
|
|
+def _execute_resume(
|
|
|
+ command: dict[str, Any],
|
|
|
+ account: dict[str, Any],
|
|
|
+ *,
|
|
|
+ now: datetime,
|
|
|
+ client: TencentClient,
|
|
|
+) -> tuple[int, int]:
|
|
|
+ account_id = int(account["account_id"])
|
|
|
+ pauses = load_operator_pauses([account_id])
|
|
|
+ if not pauses:
|
|
|
+ return 0, 0
|
|
|
+ ads = {
|
|
|
+ int(ad.get("adgroup_id") or 0): ad
|
|
|
+ for ad in client.get_ads(account_id)
|
|
|
+ }
|
|
|
+ successes = failures = 0
|
|
|
+ for state in pauses:
|
|
|
+ adgroup_id = int(state["adgroup_id"])
|
|
|
+ ad = ads.get(adgroup_id)
|
|
|
+ if not ad:
|
|
|
+ _record_item(
|
|
|
+ command,
|
|
|
+ account,
|
|
|
+ adgroup_id=adgroup_id,
|
|
|
+ adgroup_name=state.get("adgroup_name"),
|
|
|
+ execution_status="FAILED",
|
|
|
+ error_message="Tencent ad not found",
|
|
|
+ )
|
|
|
+ failures += 1
|
|
|
+ continue
|
|
|
+ before_status = str(ad.get("configured_status") or "")
|
|
|
+ try:
|
|
|
+ readback_status = before_status
|
|
|
+ target_status = None
|
|
|
+ if before_status == SUSPEND_STATUS and not bool(state.get("paused_by_strategy")):
|
|
|
+ target_status = ACTIVE_STATUS
|
|
|
+ readback = client.update_ad(
|
|
|
+ account_id,
|
|
|
+ adgroup_id,
|
|
|
+ target_status=ACTIVE_STATUS,
|
|
|
+ )
|
|
|
+ readback_status = str(readback.get("configured_status") or "")
|
|
|
+ clear_operator_pause(
|
|
|
+ account_id,
|
|
|
+ adgroup_id,
|
|
|
+ action="OPERATOR_RESUME",
|
|
|
+ action_at=now,
|
|
|
+ )
|
|
|
+ _record_item(
|
|
|
+ command,
|
|
|
+ account,
|
|
|
+ adgroup_id=adgroup_id,
|
|
|
+ adgroup_name=ad.get("adgroup_name"),
|
|
|
+ before_status=before_status,
|
|
|
+ target_status=target_status,
|
|
|
+ readback_status=readback_status,
|
|
|
+ execution_status="SUCCESS",
|
|
|
+ )
|
|
|
+ successes += 1
|
|
|
+ except Exception as exc:
|
|
|
+ _record_item(
|
|
|
+ command,
|
|
|
+ account,
|
|
|
+ adgroup_id=adgroup_id,
|
|
|
+ adgroup_name=ad.get("adgroup_name"),
|
|
|
+ before_status=before_status,
|
|
|
+ target_status=ACTIVE_STATUS,
|
|
|
+ execution_status="FAILED",
|
|
|
+ error_message=str(exc),
|
|
|
+ )
|
|
|
+ failures += 1
|
|
|
+ return successes, failures
|
|
|
+
|
|
|
+
|
|
|
+def execute_confirmed_command(
|
|
|
+ command_id: str,
|
|
|
+ *,
|
|
|
+ sender_open_id: str,
|
|
|
+ now: datetime,
|
|
|
+ start_hour: int,
|
|
|
+ lock_name: str,
|
|
|
+ tencent: TencentClient | None = None,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ command = load_operator_command(command_id)
|
|
|
+ if not command:
|
|
|
+ raise ValueError(f"命令不存在: {command_id}")
|
|
|
+ if command["sender_open_id"] != sender_open_id:
|
|
|
+ raise PermissionError("只能确认自己发起的命令")
|
|
|
+ if command["status"] in FINAL_COMMAND_STATUSES:
|
|
|
+ return command
|
|
|
+ if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
|
|
|
+ raise RuntimeError(f"命令当前不可确认: {command['status']}")
|
|
|
+ with advisory_lock(lock_name) as acquired:
|
|
|
+ if not acquired:
|
|
|
+ raise RuntimeError("实时控制正在执行,请稍后再次确认")
|
|
|
+ command = load_operator_command(command_id) or command
|
|
|
+ if command["status"] in FINAL_COMMAND_STATUSES:
|
|
|
+ return command
|
|
|
+ if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
|
|
|
+ raise RuntimeError(f"命令当前不可确认: {command['status']}")
|
|
|
+ if (
|
|
|
+ command["status"] == COMMAND_PENDING
|
|
|
+ and command.get("expires_at")
|
|
|
+ and now.replace(tzinfo=None) > command["expires_at"]
|
|
|
+ ):
|
|
|
+ transition_operator_command(
|
|
|
+ command_id,
|
|
|
+ expected_statuses={COMMAND_PENDING},
|
|
|
+ target_status=COMMAND_EXPIRED,
|
|
|
+ executed_at=now,
|
|
|
+ )
|
|
|
+ return load_operator_command(command_id) or command
|
|
|
+ if command["status"] == COMMAND_PENDING:
|
|
|
+ transitioned = transition_operator_command(
|
|
|
+ command_id,
|
|
|
+ expected_statuses={COMMAND_PENDING},
|
|
|
+ target_status=COMMAND_EXECUTING,
|
|
|
+ confirmed_at=now,
|
|
|
+ )
|
|
|
+ if not transitioned:
|
|
|
+ latest = load_operator_command(command_id) or command
|
|
|
+ if latest["status"] in FINAL_COMMAND_STATUSES:
|
|
|
+ return latest
|
|
|
+ raise RuntimeError(
|
|
|
+ f"命令状态并发变化: {latest['status']}"
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ managed = _managed_account_map()
|
|
|
+ missing = [
|
|
|
+ account_id
|
|
|
+ for account_id in command["target_account_ids"]
|
|
|
+ if account_id not in managed
|
|
|
+ ]
|
|
|
+ if missing:
|
|
|
+ raise RuntimeError(
|
|
|
+ f"确认时账户已不在自动化管理范围: {missing}"
|
|
|
+ )
|
|
|
+ accounts = [
|
|
|
+ managed[account_id]
|
|
|
+ for account_id in command["target_account_ids"]
|
|
|
+ ]
|
|
|
+ client = tencent or TencentClient()
|
|
|
+ successes = failures = 0
|
|
|
+ for account in accounts:
|
|
|
+ if command["action"] in {ACTION_DAY_PAUSE, ACTION_STOP}:
|
|
|
+ ok, failed = _execute_pause(
|
|
|
+ command,
|
|
|
+ account,
|
|
|
+ now=now,
|
|
|
+ start_hour=start_hour,
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ elif command["action"] == ACTION_RESUME:
|
|
|
+ ok, failed = _execute_resume(
|
|
|
+ command,
|
|
|
+ account,
|
|
|
+ now=now,
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ raise ValueError(f"Unsupported command action: {command['action']}")
|
|
|
+ successes += ok
|
|
|
+ failures += failed
|
|
|
+ except Exception as exc:
|
|
|
+ update_operator_command(
|
|
|
+ command_id,
|
|
|
+ COMMAND_FAILED,
|
|
|
+ executed_at=now,
|
|
|
+ error_message=str(exc),
|
|
|
+ )
|
|
|
+ raise
|
|
|
+
|
|
|
+ status = (
|
|
|
+ COMMAND_FAILED
|
|
|
+ if failures and not successes
|
|
|
+ else COMMAND_PARTIAL
|
|
|
+ if failures
|
|
|
+ else COMMAND_SUCCEEDED
|
|
|
+ )
|
|
|
+ update_operator_command(command_id, status, executed_at=now)
|
|
|
+ result = load_operator_command(command_id) or command
|
|
|
+ result["successes"] = successes
|
|
|
+ result["failures"] = failures
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def pause_status_summary() -> dict[str, Any]:
|
|
|
+ account_ids = [int(row["account_id"]) for row in load_managed_accounts()]
|
|
|
+ pauses = load_operator_pauses(account_ids)
|
|
|
+ return {
|
|
|
+ "total": len(pauses),
|
|
|
+ "until_next_delivery": sum(
|
|
|
+ row["operator_pause_mode"] == PAUSE_UNTIL_NEXT_DELIVERY
|
|
|
+ for row in pauses
|
|
|
+ ),
|
|
|
+ "until_manual": sum(
|
|
|
+ row["operator_pause_mode"] == PAUSE_UNTIL_MANUAL
|
|
|
+ for row in pauses
|
|
|
+ ),
|
|
|
+ "accounts": sorted({int(row["account_id"]) for row in pauses}),
|
|
|
+ }
|