|
|
@@ -0,0 +1,550 @@
|
|
|
+"""查询 ODPS 全部腾讯账号下广告的实时投放状态并生成飞书报表。"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import logging
|
|
|
+import os
|
|
|
+from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
+from datetime import date, datetime
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any, Callable
|
|
|
+from zoneinfo import ZoneInfo
|
|
|
+
|
|
|
+from openpyxl import Workbook
|
|
|
+from openpyxl.styles import Alignment, Font, PatternFill
|
|
|
+from openpyxl.utils import get_column_letter
|
|
|
+
|
|
|
+from tencent_client import ACTIVE_STATUS, SUSPEND_STATUS, TencentClient, is_deleted_ad
|
|
|
+
|
|
|
+
|
|
|
+ROOT = Path(__file__).resolve().parent
|
|
|
+SOURCE_TABLE = "loghubods.ad_put_tencent_account"
|
|
|
+FINAL_STATUSES = (
|
|
|
+ "投放中",
|
|
|
+ "非投放时段",
|
|
|
+ "已暂停",
|
|
|
+ "未开始",
|
|
|
+ "已结束",
|
|
|
+ "状态未知",
|
|
|
+)
|
|
|
+SHANGHAI = ZoneInfo("Asia/Shanghai")
|
|
|
+TIME_SERIES_SLOTS_PER_DAY = 48
|
|
|
+TIME_SERIES_SLOT_MINUTES = 30
|
|
|
+TIME_SERIES_LENGTH = TIME_SERIES_SLOTS_PER_DAY * 7
|
|
|
+WEEKDAY_NAMES = ("周一", "周二", "周三", "周四", "周五", "周六", "周日")
|
|
|
+TOKEN_ERROR_MARKERS = (
|
|
|
+ "code=11002",
|
|
|
+ "code=12201",
|
|
|
+ "invalid access token",
|
|
|
+ "invalid token",
|
|
|
+ "access_token 无效",
|
|
|
+ "token api",
|
|
|
+ "getaccesstoken",
|
|
|
+ "no access token",
|
|
|
+ "http 401",
|
|
|
+)
|
|
|
+
|
|
|
+logger = logging.getLogger("tencent_realtime_control.account_status")
|
|
|
+
|
|
|
+
|
|
|
+def build_account_source_sql() -> str:
|
|
|
+ """读取账号表每个账号最新且未删除的一行,不依赖自动化配置表。"""
|
|
|
+ return f"""
|
|
|
+SELECT account_id, account_name, agent_name, status
|
|
|
+FROM (
|
|
|
+ SELECT
|
|
|
+ id,
|
|
|
+ account_id,
|
|
|
+ account_name,
|
|
|
+ agent_name,
|
|
|
+ status,
|
|
|
+ is_delete,
|
|
|
+ create_time,
|
|
|
+ update_time,
|
|
|
+ ROW_NUMBER() OVER (
|
|
|
+ PARTITION BY account_id
|
|
|
+ ORDER BY COALESCE(update_time, create_time) DESC, id DESC
|
|
|
+ ) AS row_number
|
|
|
+ FROM {SOURCE_TABLE}
|
|
|
+ WHERE account_id IS NOT NULL
|
|
|
+ AND TRIM(account_id) <> ''
|
|
|
+) latest
|
|
|
+WHERE row_number = 1
|
|
|
+ AND NVL(is_delete, 0) = 0
|
|
|
+ORDER BY account_id
|
|
|
+""".strip()
|
|
|
+
|
|
|
+
|
|
|
+def _execute_frame(client: Any, sql: str) -> Any:
|
|
|
+ result = client.execute_sql(
|
|
|
+ sql,
|
|
|
+ hints={"odps.sql.submit.mode": "script"},
|
|
|
+ )
|
|
|
+ if hasattr(result, "to_dict"):
|
|
|
+ return result
|
|
|
+ with result.open_reader(tunnel=True) as reader:
|
|
|
+ return reader.to_pandas()
|
|
|
+
|
|
|
+
|
|
|
+def _clean_text(value: Any) -> str:
|
|
|
+ if value is None:
|
|
|
+ return ""
|
|
|
+ try:
|
|
|
+ if value != value:
|
|
|
+ return ""
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ return str(value).strip()
|
|
|
+
|
|
|
+
|
|
|
+def fetch_source_accounts(odps: Any | None = None) -> list[dict[str, Any]]:
|
|
|
+ """从大数据账号表读取全部当前账号。"""
|
|
|
+ if odps is None:
|
|
|
+ from odps_source import build_odps_client
|
|
|
+
|
|
|
+ client = build_odps_client()
|
|
|
+ else:
|
|
|
+ client = odps
|
|
|
+ frame = _execute_frame(client, build_account_source_sql())
|
|
|
+ accounts: list[dict[str, Any]] = []
|
|
|
+ seen: set[int] = set()
|
|
|
+ for raw in frame.to_dict(orient="records"):
|
|
|
+ try:
|
|
|
+ account_id = int(raw.get("account_id"))
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ continue
|
|
|
+ if account_id <= 0 or account_id in seen:
|
|
|
+ continue
|
|
|
+ seen.add(account_id)
|
|
|
+ accounts.append(
|
|
|
+ {
|
|
|
+ "account_id": account_id,
|
|
|
+ "account_name": _clean_text(raw.get("account_name")),
|
|
|
+ "agent_name": _clean_text(raw.get("agent_name")),
|
|
|
+ "source_status": _clean_text(raw.get("status")),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return sorted(accounts, key=lambda row: int(row["account_id"]))
|
|
|
+
|
|
|
+
|
|
|
+def _date_value(value: Any) -> tuple[str, date | None, bool]:
|
|
|
+ raw = _clean_text(value)
|
|
|
+ if not raw or raw == "0":
|
|
|
+ return raw, None, True
|
|
|
+ normalized = raw[:10]
|
|
|
+ try:
|
|
|
+ return normalized, date.fromisoformat(normalized), True
|
|
|
+ except ValueError:
|
|
|
+ return raw, None, False
|
|
|
+
|
|
|
+
|
|
|
+def _shanghai_now(value: datetime) -> datetime:
|
|
|
+ if value.tzinfo is None:
|
|
|
+ return value.replace(tzinfo=SHANGHAI)
|
|
|
+ return value.astimezone(SHANGHAI)
|
|
|
+
|
|
|
+
|
|
|
+def _slot_time(slot: int) -> str:
|
|
|
+ minutes = slot * TIME_SERIES_SLOT_MINUTES
|
|
|
+ return f"{minutes // 60:02d}:{minutes % 60:02d}"
|
|
|
+
|
|
|
+
|
|
|
+def _active_ranges(day_series: str) -> str:
|
|
|
+ ranges: list[str] = []
|
|
|
+ start: int | None = None
|
|
|
+ for slot, enabled in enumerate(f"{day_series}0"):
|
|
|
+ if enabled == "1" and start is None:
|
|
|
+ start = slot
|
|
|
+ elif enabled == "0" and start is not None:
|
|
|
+ ranges.append(f"{_slot_time(start)}-{_slot_time(slot)}")
|
|
|
+ start = None
|
|
|
+ return "、".join(ranges) or "无"
|
|
|
+
|
|
|
+
|
|
|
+def _time_series_value(value: Any, *, now: datetime) -> dict[str, Any]:
|
|
|
+ raw = _clean_text(value)
|
|
|
+ current = _shanghai_now(now)
|
|
|
+ weekday = current.weekday()
|
|
|
+ day_start = weekday * TIME_SERIES_SLOTS_PER_DAY
|
|
|
+ slot_in_day = current.hour * 2 + current.minute // TIME_SERIES_SLOT_MINUTES
|
|
|
+ slot_start = slot_in_day * TIME_SERIES_SLOT_MINUTES
|
|
|
+ current_period = (
|
|
|
+ f"{WEEKDAY_NAMES[weekday]} "
|
|
|
+ f"{slot_start // 60:02d}:{slot_start % 60:02d}-"
|
|
|
+ f"{(slot_start + TIME_SERIES_SLOT_MINUTES) // 60:02d}:"
|
|
|
+ f"{(slot_start + TIME_SERIES_SLOT_MINUTES) % 60:02d}"
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ len(raw) != TIME_SERIES_LENGTH
|
|
|
+ or set(raw) - {"0", "1"}
|
|
|
+ or "1" not in raw
|
|
|
+ ):
|
|
|
+ return {
|
|
|
+ "time_series": raw,
|
|
|
+ "time_series_valid": False,
|
|
|
+ "today_delivery_periods": "字段缺失或格式异常",
|
|
|
+ "current_time_period": current_period,
|
|
|
+ "current_period_status": "未知",
|
|
|
+ "current_period_enabled": None,
|
|
|
+ }
|
|
|
+
|
|
|
+ day_series = raw[day_start : day_start + TIME_SERIES_SLOTS_PER_DAY]
|
|
|
+ enabled = raw[day_start + slot_in_day] == "1"
|
|
|
+ return {
|
|
|
+ "time_series": raw,
|
|
|
+ "time_series_valid": True,
|
|
|
+ "today_delivery_periods": _active_ranges(day_series),
|
|
|
+ "current_time_period": current_period,
|
|
|
+ "current_period_status": "允许投放" if enabled else "不投放",
|
|
|
+ "current_period_enabled": enabled,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def classify_ad_delivery_status(
|
|
|
+ ad: dict[str, Any],
|
|
|
+ *,
|
|
|
+ now: datetime,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ """结合投放日期、广告开关和当前半小时时段,生成最终投放状态。"""
|
|
|
+ current = _shanghai_now(now)
|
|
|
+ today = current.date()
|
|
|
+ begin_text, begin_date, begin_valid = _date_value(ad.get("begin_date"))
|
|
|
+ end_text, end_date, end_valid = _date_value(ad.get("end_date"))
|
|
|
+ configured_status = _clean_text(ad.get("configured_status"))
|
|
|
+ time_series = _time_series_value(ad.get("time_series"), now=current)
|
|
|
+ if configured_status == ACTIVE_STATUS:
|
|
|
+ switch_label = "开启"
|
|
|
+ elif configured_status == SUSPEND_STATUS:
|
|
|
+ switch_label = "暂停"
|
|
|
+ else:
|
|
|
+ switch_label = configured_status or "未知"
|
|
|
+
|
|
|
+ if not begin_valid or not end_valid:
|
|
|
+ date_status = "日期异常"
|
|
|
+ final_status = "状态未知"
|
|
|
+ elif begin_date and begin_date > today:
|
|
|
+ date_status = "未到开始日期"
|
|
|
+ final_status = "未开始"
|
|
|
+ elif end_date and end_date < today:
|
|
|
+ date_status = "已过结束日期"
|
|
|
+ final_status = "已结束"
|
|
|
+ else:
|
|
|
+ date_status = "投放期内"
|
|
|
+ if configured_status == SUSPEND_STATUS:
|
|
|
+ final_status = "已暂停"
|
|
|
+ elif configured_status != ACTIVE_STATUS:
|
|
|
+ final_status = "状态未知"
|
|
|
+ elif time_series["current_period_enabled"] is True:
|
|
|
+ final_status = "投放中"
|
|
|
+ elif time_series["current_period_enabled"] is False:
|
|
|
+ final_status = "非投放时段"
|
|
|
+ else:
|
|
|
+ final_status = "状态未知"
|
|
|
+
|
|
|
+ return {
|
|
|
+ "begin_date": begin_text,
|
|
|
+ "end_date": end_text,
|
|
|
+ "date_status": date_status,
|
|
|
+ "switch_status": switch_label,
|
|
|
+ "delivery_status": final_status,
|
|
|
+ "judgment": (
|
|
|
+ f"日期={date_status};广告开关={switch_label};"
|
|
|
+ f"当前时段={time_series['current_time_period']} "
|
|
|
+ f"{time_series['current_period_status']}"
|
|
|
+ ),
|
|
|
+ **time_series,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _is_token_error(error: Any) -> bool:
|
|
|
+ message = str(error or "").lower()
|
|
|
+ return any(marker in message for marker in TOKEN_ERROR_MARKERS)
|
|
|
+
|
|
|
+
|
|
|
+def _scan_account(
|
|
|
+ account: dict[str, Any],
|
|
|
+ *,
|
|
|
+ now: datetime,
|
|
|
+ client: TencentClient,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ account_id = int(account["account_id"])
|
|
|
+ try:
|
|
|
+ ads = client.get_ads(account_id)
|
|
|
+ except Exception as exc:
|
|
|
+ token_error = _is_token_error(exc)
|
|
|
+ log = logger.warning if token_error else logger.error
|
|
|
+ log(
|
|
|
+ "Account ad status scan failed account=%s type=%s error=%s",
|
|
|
+ account_id,
|
|
|
+ "token" if token_error else "read",
|
|
|
+ exc,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ **account,
|
|
|
+ "error_type": "TOKEN异常" if token_error else "读取失败",
|
|
|
+ "error": str(exc),
|
|
|
+ }
|
|
|
+
|
|
|
+ details: list[dict[str, Any]] = []
|
|
|
+ counts = {status: 0 for status in FINAL_STATUSES}
|
|
|
+ for ad in ads:
|
|
|
+ if is_deleted_ad(ad):
|
|
|
+ continue
|
|
|
+ status = classify_ad_delivery_status(ad, now=now)
|
|
|
+ counts[status["delivery_status"]] += 1
|
|
|
+ details.append(
|
|
|
+ {
|
|
|
+ "account_id": account_id,
|
|
|
+ "account_name": account["account_name"],
|
|
|
+ "agent_name": account["agent_name"],
|
|
|
+ "account_source_status": account["source_status"],
|
|
|
+ "adgroup_id": int(ad.get("adgroup_id") or 0),
|
|
|
+ "adgroup_name": _clean_text(ad.get("adgroup_name")),
|
|
|
+ "configured_status": _clean_text(ad.get("configured_status")),
|
|
|
+ "system_status": _clean_text(ad.get("system_status")),
|
|
|
+ **status,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ **account,
|
|
|
+ "ad_count": len(details),
|
|
|
+ "status_counts": counts,
|
|
|
+ "ads": details,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _scan_with_factory(
|
|
|
+ account: dict[str, Any],
|
|
|
+ *,
|
|
|
+ now: datetime,
|
|
|
+ factory: Callable[[], TencentClient],
|
|
|
+) -> dict[str, Any]:
|
|
|
+ client = factory()
|
|
|
+ try:
|
|
|
+ return _scan_account(account, now=now, client=client)
|
|
|
+ finally:
|
|
|
+ session = getattr(client, "session", None)
|
|
|
+ if session is not None and hasattr(session, "close"):
|
|
|
+ session.close()
|
|
|
+
|
|
|
+
|
|
|
+def _worker_count(account_count: int) -> int:
|
|
|
+ workers = int(os.getenv("RTC_ACCOUNT_STATUS_QUERY_WORKERS", "8"))
|
|
|
+ if workers < 1 or workers > 32:
|
|
|
+ raise ValueError("RTC_ACCOUNT_STATUS_QUERY_WORKERS must be in [1, 32]")
|
|
|
+ return min(workers, max(account_count, 1))
|
|
|
+
|
|
|
+
|
|
|
+def query_all_account_statuses(
|
|
|
+ *,
|
|
|
+ now: datetime,
|
|
|
+ odps: Any | None = None,
|
|
|
+ tencent: TencentClient | None = None,
|
|
|
+ tencent_factory: Callable[[], TencentClient] = TencentClient,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ """查询大数据表全部账号,仅统计 token 正常账号下的广告。"""
|
|
|
+ source_accounts = fetch_source_accounts(odps)
|
|
|
+ if not source_accounts:
|
|
|
+ raise ValueError(f"{SOURCE_TABLE} 中没有当前有效账号")
|
|
|
+
|
|
|
+ results: list[dict[str, Any]] = []
|
|
|
+ if tencent is not None:
|
|
|
+ results = [
|
|
|
+ _scan_account(account, now=now, client=tencent)
|
|
|
+ for account in source_accounts
|
|
|
+ ]
|
|
|
+ else:
|
|
|
+ with ThreadPoolExecutor(
|
|
|
+ max_workers=_worker_count(len(source_accounts)),
|
|
|
+ thread_name_prefix="account-status",
|
|
|
+ ) as executor:
|
|
|
+ futures = {
|
|
|
+ executor.submit(
|
|
|
+ _scan_with_factory,
|
|
|
+ account,
|
|
|
+ now=now,
|
|
|
+ factory=tencent_factory,
|
|
|
+ ): int(account["account_id"])
|
|
|
+ for account in source_accounts
|
|
|
+ }
|
|
|
+ for future in as_completed(futures):
|
|
|
+ results.append(future.result())
|
|
|
+
|
|
|
+ results.sort(key=lambda row: int(row["account_id"]))
|
|
|
+ successful = [row for row in results if not row.get("error")]
|
|
|
+ token_skipped = [row for row in results if row.get("error_type") == "TOKEN异常"]
|
|
|
+ failed = [row for row in results if row.get("error_type") == "读取失败"]
|
|
|
+ ads = sorted(
|
|
|
+ [ad for row in successful for ad in row.get("ads") or []],
|
|
|
+ key=lambda row: (int(row["account_id"]), int(row["adgroup_id"])),
|
|
|
+ )
|
|
|
+ totals = {status: 0 for status in FINAL_STATUSES}
|
|
|
+ for row in successful:
|
|
|
+ for status in FINAL_STATUSES:
|
|
|
+ totals[status] += int(row["status_counts"][status])
|
|
|
+
|
|
|
+ return {
|
|
|
+ "source_table": SOURCE_TABLE,
|
|
|
+ "queried_at": now,
|
|
|
+ "source_account_count": len(source_accounts),
|
|
|
+ "token_normal_account_count": len(successful),
|
|
|
+ "token_abnormal_account_count": len(token_skipped),
|
|
|
+ "failed_account_count": len(failed),
|
|
|
+ "complete": not failed,
|
|
|
+ "ad_count": len(ads),
|
|
|
+ "status_counts": totals,
|
|
|
+ "accounts": successful,
|
|
|
+ "ads": ads,
|
|
|
+ "token_abnormal_accounts": token_skipped,
|
|
|
+ "failed_accounts": failed,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _style_table(sheet: Any, header_row: int, widths: list[int]) -> None:
|
|
|
+ fill = PatternFill("solid", fgColor="1F4E78")
|
|
|
+ for cell in sheet[header_row]:
|
|
|
+ cell.fill = fill
|
|
|
+ cell.font = Font(color="FFFFFF", bold=True)
|
|
|
+ cell.alignment = Alignment(horizontal="center", vertical="center")
|
|
|
+ for index, width in enumerate(widths, start=1):
|
|
|
+ sheet.column_dimensions[get_column_letter(index)].width = width
|
|
|
+ for row in sheet.iter_rows(min_row=header_row + 1):
|
|
|
+ for cell in row:
|
|
|
+ cell.alignment = Alignment(horizontal="center", vertical="center")
|
|
|
+ sheet.freeze_panes = f"A{header_row + 1}"
|
|
|
+ sheet.auto_filter.ref = f"A{header_row}:{get_column_letter(len(widths))}{sheet.max_row}"
|
|
|
+
|
|
|
+
|
|
|
+def create_account_status_xlsx(
|
|
|
+ summary: dict[str, Any],
|
|
|
+ *,
|
|
|
+ output_dir: Path | None = None,
|
|
|
+) -> Path:
|
|
|
+ """生成账号汇总、广告明细和异常账号三个 Sheet。"""
|
|
|
+ target_dir = output_dir or (ROOT / "outputs")
|
|
|
+ target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+ now = summary["queried_at"]
|
|
|
+ path = target_dir / f"全部账号广告投放状态_{now:%Y%m%d_%H%M%S}.xlsx"
|
|
|
+
|
|
|
+ workbook = Workbook()
|
|
|
+ account_sheet = workbook.active
|
|
|
+ account_sheet.title = "账号汇总"
|
|
|
+ account_sheet.append(["数据源", summary["source_table"]])
|
|
|
+ account_sheet.append(["查询时间", now.strftime("%Y-%m-%d %H:%M:%S")])
|
|
|
+ account_sheet.append(
|
|
|
+ [
|
|
|
+ "源账号数",
|
|
|
+ summary["source_account_count"],
|
|
|
+ "Token正常账号",
|
|
|
+ summary["token_normal_account_count"],
|
|
|
+ "Token异常账号",
|
|
|
+ summary["token_abnormal_account_count"],
|
|
|
+ "其他读取失败",
|
|
|
+ summary["failed_account_count"],
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ account_headers = [
|
|
|
+ "账号ID", "账号名称", "代理商", "账号源状态", "广告数",
|
|
|
+ *FINAL_STATUSES,
|
|
|
+ ]
|
|
|
+ account_sheet.append(account_headers)
|
|
|
+ for row in summary["accounts"]:
|
|
|
+ counts = row["status_counts"]
|
|
|
+ account_sheet.append(
|
|
|
+ [
|
|
|
+ str(row["account_id"]), row["account_name"], row["agent_name"],
|
|
|
+ row["source_status"], row["ad_count"],
|
|
|
+ *[counts[status] for status in FINAL_STATUSES],
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ _style_table(
|
|
|
+ account_sheet,
|
|
|
+ 4,
|
|
|
+ [16, 28, 22, 14, 12, *([14] * len(FINAL_STATUSES))],
|
|
|
+ )
|
|
|
+
|
|
|
+ detail_sheet = workbook.create_sheet("广告明细")
|
|
|
+ detail_headers = [
|
|
|
+ "账号ID", "账号名称", "代理商", "广告ID", "广告名称",
|
|
|
+ "投放开始日期", "投放结束日期", "日期状态", "广告开关",
|
|
|
+ "今日投放时段", "当前半小时时段", "当前时段状态", "投放状态",
|
|
|
+ "判断依据", "腾讯配置状态", "腾讯系统状态", "time_series 原值", "查询时间",
|
|
|
+ ]
|
|
|
+ detail_sheet.append(detail_headers)
|
|
|
+ for row in summary["ads"]:
|
|
|
+ detail_sheet.append(
|
|
|
+ [
|
|
|
+ str(row["account_id"]), row["account_name"], row["agent_name"],
|
|
|
+ str(row["adgroup_id"]), row["adgroup_name"], row["begin_date"],
|
|
|
+ row["end_date"] or "长期", row["date_status"], row["switch_status"],
|
|
|
+ row["today_delivery_periods"], row["current_time_period"],
|
|
|
+ row["current_period_status"], row["delivery_status"], row["judgment"],
|
|
|
+ row["configured_status"], row["system_status"], row["time_series"],
|
|
|
+ now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ _style_table(
|
|
|
+ detail_sheet,
|
|
|
+ 1,
|
|
|
+ [16, 28, 22, 18, 36, 16, 16, 18, 14, 28, 24, 16, 16, 52, 24, 30, 28, 21],
|
|
|
+ )
|
|
|
+
|
|
|
+ error_sheet = workbook.create_sheet("异常账号")
|
|
|
+ error_sheet.append(["账号ID", "账号名称", "代理商", "异常类型", "异常信息"])
|
|
|
+ for row in [*summary["token_abnormal_accounts"], *summary["failed_accounts"]]:
|
|
|
+ error_sheet.append(
|
|
|
+ [
|
|
|
+ str(row["account_id"]), row["account_name"], row["agent_name"],
|
|
|
+ row["error_type"], row["error"],
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ _style_table(error_sheet, 1, [16, 28, 22, 16, 70])
|
|
|
+
|
|
|
+ workbook.save(path)
|
|
|
+ return path
|
|
|
+
|
|
|
+
|
|
|
+def build_account_status_card(
|
|
|
+ summary: dict[str, Any],
|
|
|
+ *,
|
|
|
+ sheet_url: str,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ """构建带“打开广告状态表格”按钮的飞书卡片。"""
|
|
|
+ counts = summary["status_counts"]
|
|
|
+ content = (
|
|
|
+ f"**数据源:** `{summary['source_table']}`\n"
|
|
|
+ f"**查询时间:** {summary['queried_at'].strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
|
+ f"**账号范围:** 源账号 {summary['source_account_count']} 个,"
|
|
|
+ f"Token 正常 {summary['token_normal_account_count']} 个,"
|
|
|
+ f"Token 异常 {summary['token_abnormal_account_count']} 个,"
|
|
|
+ f"其他读取失败 {summary['failed_account_count']} 个\n"
|
|
|
+ f"**广告总数:** {summary['ad_count']} 条\n"
|
|
|
+ f"**投放状态:** 投放中 {counts['投放中']} 条,非投放时段 "
|
|
|
+ f"{counts['非投放时段']} 条,已暂停 {counts['已暂停']} 条,"
|
|
|
+ f"未开始 {counts['未开始']} 条,"
|
|
|
+ f"已结束 {counts['已结束']} 条,状态未知 {counts['状态未知']} 条"
|
|
|
+ )
|
|
|
+ partial = bool(
|
|
|
+ summary["token_abnormal_account_count"] or summary["failed_account_count"]
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "config": {"wide_screen_mode": True},
|
|
|
+ "header": {
|
|
|
+ "template": "orange" if partial else "blue",
|
|
|
+ "title": {"tag": "plain_text", "content": "全部账号广告投放状态"},
|
|
|
+ },
|
|
|
+ "elements": [
|
|
|
+ {"tag": "div", "text": {"tag": "lark_md", "content": content}},
|
|
|
+ {
|
|
|
+ "tag": "action",
|
|
|
+ "actions": [
|
|
|
+ {
|
|
|
+ "tag": "button",
|
|
|
+ "type": "primary",
|
|
|
+ "text": {"tag": "plain_text", "content": "打开广告状态表格"},
|
|
|
+ "url": sheet_url,
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }
|