소스 검색

飞书群 全部账号投放状态查询

wangyunpeng 1 일 전
부모
커밋
2067ba57a7

+ 30 - 7
examples/auto_put_ad_mini/docs/unified_services_deployment.md

@@ -117,6 +117,9 @@ DAILY_PARTIAL_CREATIVE_COST_THRESHOLD_YUAN=50
 DAILY_WECHAT_MINI_PROGRAM_CREATIVE_COST_THRESHOLD_YUAN=100
 DAILY_REJECTED_CREATIVE_DELETE_CLAIM_STALE_MINUTES=30
 DAILY_REJECTED_CREATIVE_NOTIFICATION_LOCK_NAME=ad_rejected_creative_notification
+# 与清理任务失败告警同一位置;删除失败也复用该位置发送独立通知。
+CREATIVE_CLEANUP_FAILURE_FEISHU_CHAT_ID=
+CREATIVE_CLEANUP_INTERNAL_ONLY_AGENCIES=小程序-自动化
 TENCENT_AD_DELETE_WORKERS=4
 TENCENT_AD_DELETE_MIN_INTERVAL_SECONDS=0.25
 TENCENT_AD_DELETE_RATE_LIMIT_RETRIES=2
@@ -242,7 +245,9 @@ T-3 至 T-1 三个完整自然日消耗,并另查 T 日当日消耗;两者
 `TENCENT_AD_TOKEN_PREFETCH_WORKERS`(默认 8)并发
 预取并缓存 access token;腾讯只读 GET 遇到连接/TLS 中断、超时、408、429 或 5xx 时按
 `TENCENT_AD_GET_RETRY_ATTEMPTS`(默认总尝试 3 次)和指数退避自动重试。腾讯查询、数据库
-审计和清理操作仍保持原有顺序。账户级创意、广告和审核结果读取按
+审计和清理操作仍保持原有顺序。账户未取得 token,或腾讯返回 `11002` 表示 token 无效时,
+本轮按账户安全跳过,不再读取该账户广告、审核结果或消耗,也不计入任务失败退出码;跳过账户 ID
+保留在运行摘要和日志中,待授权恢复后由下一次任务自动补扫。账户级创意、广告和审核结果读取按
 `TENCENT_AD_ACCOUNT_SCAN_WORKERS`(默认 8)有限并发,每个账户任务使用独立 HTTP Session;
 创意命中判断只在内存完成,达到 `DAILY_CLEANUP_CANDIDATE_BATCH_MIN_SIZE`(默认 20)后,候选按
 `DAILY_CLEANUP_CANDIDATE_BATCH_SIZE`(默认 100)复用一个 MySQL 事务连接,并由
@@ -275,6 +280,9 @@ Excel 中
 原始枚举值。报表通过现有
 `ROI_AGENCY_WEBHOOKS_JSON` 精确路由到代理群;同批次还会生成包含全部代理创意的汇总表,通过飞书应用
 发送到 `FEISHU_AD_PROJECT_CHAT_ID` 投放调控通知群。代理通知与投放调控汇总通知分别记录成功状态;
+`CREATIVE_CLEANUP_INTERNAL_ONLY_AGENCIES` 使用逗号分隔并精确匹配,默认包含“小程序-自动化”;命中后
+只进入 `FEISHU_AD_PROJECT_CHAT_ID` 内部汇总,不生成代理商报表、不要求代理 webhook,内部通知成功后
+同时完成该记录的代理通知抑制标记,后续任务不会重复尝试代理通知。
 长期未起量创意和近 3 日零消耗广告绝不进入代理商报表或代理商群;系统为这部分单独生成
 `YYYYMMDD_投放调控_长期未起量创意及广告清理汇总_批次摘要.xlsx`,并在同一次任务中向
 `FEISHU_AD_PROJECT_CHAT_ID` 另发一条“长期未起量创意及广告清理汇总通知”。未起量删除不依赖代理归属或
@@ -286,14 +294,21 @@ Excel 中
 会按当天新检查再次提醒。代理归属为空,或已解析代理但没有配置对应代理群时,记录置为 `DEFERRED`,
 禁止执行腾讯删除,待归属和路由完整后再处理。启用长期未起量扫描必须设置
 `FEISHU_AD_PROJECT_CHAT_ID`;这类通知和删除均不要求 `ROI_AGENCY_WEBHOOK_ENABLED=1`。
-审核异常真实删除仍要求代理 webhook 和内部群均已配置,缺少相应配置时任务会在
-腾讯写操作前拒绝启动。生产启用顺序为:先只开启任务完成只读审核结果核对;审核异常链路再开启
+除 `CREATIVE_CLEANUP_INTERNAL_ONLY_AGENCIES` 外,审核异常真实删除仍要求对应代理 webhook;所有审核
+异常删除都要求内部群已配置。普通代理缺少对应路由时该记录在腾讯写前置为 `DEFERRED`,内部专属归属
+不受 `ROI_AGENCY_WEBHOOK_ENABLED` 限制。生产启用顺序为:先只开启任务完成只读审核结果核对;审核异常链路再开启
 `ROI_AGENCY_WEBHOOK_ENABLED=1` 和 `DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1`;长期未起量链路先开启
 `DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED=1` 预演并核对内部群的独立消息,确认后再开启
 `DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED=1`;广告链路同样先开启
 `DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED=1` 预演,确认后再开启
 `DAILY_UNDERPERFORMING_AD_APPLY_ENABLED=1`。
 
+腾讯删除失败会继续保留 `FAILED`、`DEFERRED` 或 `WRITE_OUTCOME_UNKNOWN` 数据库审计状态,供后续任务
+按现有回读和重试规则补处理,但不再参与清理任务的 `code 1` 判定。每次任务把删除失败合并为一条独立
+飞书告警,发送位置与“创意审核异常及未起量清理任务失败”一致,均使用
+`CREATIVE_CLEANUP_FAILURE_FEISHU_CHAT_ID`,为空时回退 `ROI_FAILURE_FEISHU_CHAT_ID` /
+`FEISHU_OPERATOR_CHAT_ID`。只有非 Token 类扫描错误或内部/代理通知真实失败仍会让任务返回非零退出码。
+
 本地只预演未起量规则时使用:
 
 ```bash
@@ -401,10 +416,18 @@ docker compose --env-file /dev/null run --rm \
 
 飞书验证:
 
-1. 在配置群 @机器人发送 `查看暂停状态`。
-2. 发送 `查询今天自动化账户消耗`,确认返回腾讯实时汇总且不要求审批。
-3. 发送一个单账户暂停命令,确认机器人返回预览和 `cmd_xxx`。
-4. 回复确认,服务必须提示 dry-run 禁止写腾讯。
+1. 在配置群 @机器人发送 `查询暂停状态`,确认仍按历史自动化账户与启用的
+   `realtime_control_account_scope` 额外纳管账户范围,汇总当前有效运营暂停;不得读取
+   `loghubods.ad_put_tencent_account` 全部账号。
+2. 发送 `查询全部账户状态`、`查询全部账户投放状态` 或将“账户”写成
+   “账号”;确认账号范围来自 `loghubods.ad_put_tencent_account` 最新未删除记录,只有 token
+   正常账号进入广告统计。只有广告在起止日期内、开关正常,且当前北京时间对应的 `time_series`
+   半小时位为 `1` 时才统计为“投放中”;对应位为 `0` 时统计为“非投放时段”。群内返回
+   “打开广告状态表格”按钮,表格包含账号汇总、广告明细和异常账号,广告明细需展示今日投放
+   时段、当前半小时时段和当前时段状态。
+3. 发送 `查询今天自动化账户消耗`,确认返回腾讯实时汇总且不要求审批。
+4. 发送一个单账户暂停命令,确认机器人返回预览和 `cmd_xxx`。
+5. 回复确认,服务必须提示 dry-run 禁止写腾讯。
 
 ## 5. 指定账户执行一次创建流程
 

+ 1 - 0
examples/tencent_realtime_control/.env.example

@@ -19,6 +19,7 @@ RTC_COMMAND_CHAT_ID=
 RTC_COMMAND_ALLOWED_OPEN_IDS=
 RTC_COMMAND_CONFIRM_TTL_MINUTES=10
 RTC_COMMAND_WORKERS=2
+RTC_ACCOUNT_STATUS_QUERY_WORKERS=8
 RTC_COMMAND_NEXT_DELIVERY_HOUR=6
 RTC_COMMAND_DRAFT_TTL_MINUTES=5
 RTC_NL_COMMAND_ENABLED=0

+ 1 - 0
examples/tencent_realtime_control/PROJECT_STRUCTURE.md

@@ -80,6 +80,7 @@ decide(cpm, low, high):
 | `operator_commands.py` | 198 | **确定性命令解析**。`ParsedCommand` / `CommandIntent` 数据类,暂停/停止/恢复/状态/今日消耗等动作的规则解析 |
 | `command_intent_parser.py` | 218 | **受限 NLU**。标准命令走确定性解析;`RTC_NL_COMMAND_ENABLED=1` 时模型只返回动作+范围 JSON,不可读库/调接口 |
 | `operator_control.py` | 899 | 命令**预览与执行**。预览冻结广告快照 + 冲突检测;确认后按账户/广告执行暂停/恢复/停止,含写后回读、状态机流转 |
+| `account_status_query.py` | - | 从 ODPS 账号表读取全部当前账号,校验 token 后按投放起止日期和广告开关判断广告投放状态,生成飞书三 Sheet 报表与按钮卡片 |
 | `today_spend_query.py` | 79 | 只读的今日消耗汇总查询(全部/自动化/指定账户),逐账户调腾讯报表聚合 |
 
 **命令生命周期**:`解析 → 预览(冻结明细) → 10分钟内确认 → 执行 → 回读校验 → 审计落库`

+ 26 - 3
examples/tencent_realtime_control/README.md

@@ -127,18 +127,41 @@ ODPS 小时分区可能延迟。当天尚未出现 `06` 点及之后的分区时
 @机器人 停止 86748335
 @机器人 恢复 86748335
 @机器人 查询暂停状态
+@机器人 查询全部账户状态
+@机器人 查询全部账户投放状态
+@机器人 查询全部账号投放状态
 @机器人 查询今天自动化账户消耗
 @机器人 查询今天全部账户消耗
 ```
 
+“查询暂停状态”和“查询全部账户投放状态”是两个独立只读动作。“查询暂停状态”保持原逻辑:
+只读取实时调控账户范围内当前仍生效的运营暂停记录,并汇总仅暂停今天、持续停止和涉及账户;
+不会读取大数据全账号表。“查询全部账户状态/投放状态”才进入下述全账号广告状态查询。
+
 开启 `RTC_NL_COMMAND_ENABLED=1` 后,无法由标准解析器识别的表达才会交给模型。
 模型只返回动作和范围 JSON,不能读取数据库或调用腾讯接口。裸“暂停”会进入
 5 分钟多轮补问;“暂停”只到下一投放日 06:00,“停止”持续到人工恢复。
 今日消耗查询不需要二次确认,触发时实时读取腾讯 API,只回复账户数、有消耗账户数、
 总消耗、曝光、点击和转化总览,不展示逐账户明细。部分账户查询失败时会明确标记为部分数据。
-
-“全部”范围包括历史自动化账户与 `realtime_control_account_scope` 中显式纳管的
-账户。历史自动化账户始终使用 `FULL` 模式;同一账户同时出现在显式纳管范围时,
+全部账号广告投放状态查询同样只读且不需要二次确认。“账户”“账号”“帐号”均可识别;账号范围
+从 `loghubods.ad_put_tencent_account` 读取每个账号最新且 `is_delete=0` 的记录,不依赖
+自动创建、实时调控或 MySQL 白名单配置。系统逐账号实时读取腾讯广告列表,只有 token 正常、
+广告列表读取成功的账号进入统计;无效 token 和其他读取异常在报表中单独列出。
+
+每条广告同时读取 `begin_date`、`end_date`、`configured_status` 和 `time_series`。`time_series`
+按腾讯格式为从周一 00:00 到周日 24:00 的 336 位字符串,每位代表半小时,`1` 允许投放、
+`0` 不投放。投放起止日期缺失或为 `0` 时按无对应日期边界处理,即一直处于投放日期范围内;
+未来开始为“未开始”,超过结束日期为“已结束”,投放期内开关暂停为“已暂停”;
+只有投放期内、开关正常且当前北京时间对应位为 `1` 才是“投放中”,对应位为 `0` 时为
+“非投放时段”。非空日期格式异常或开关无法识别时为“状态未知”;投放期内且开关正常时,`time_series`
+缺失、全 `0` 或格式异常也为“状态未知”。广告明细同时展示今日
+投放时段、当前半小时时段、当前时段状态及原始 `time_series`。结果包含账号汇总、广告明细、异常账号三个 Sheet,
+上传为只读飞书在线表格,并在原群消息下发送“打开广告状态表格”按钮,不在群内展开全部明细。
+账号扫描并发数由 `RTC_ACCOUNT_STATUS_QUERY_WORKERS` 管理,默认 8,允许 1–32。
+
+暂停、停止和恢复写命令中的“全部”范围包括历史自动化账户与
+`realtime_control_account_scope` 中显式纳管的账户。历史自动化账户始终使用
+`FULL` 模式;同一账户同时出现在显式纳管范围时,
 不能被 `PAUSE_ONLY` 降级。预览会查询腾讯当日广告报表,逐账户展示影响广告数和今日消耗;报表失败
 时禁止确认。所有写操作必须在 10 分钟内回复带命令 ID 的确认消息。重叠的待确认
 命令会被拒绝,执行时只处理预览阶段冻结的广告 ID,并继续使用共享数据库锁和

+ 550 - 0
examples/tencent_realtime_control/account_status_query.py

@@ -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,
+                    }
+                ],
+            },
+        ],
+    }

+ 20 - 2
examples/tencent_realtime_control/command_intent_parser.py

@@ -11,6 +11,7 @@ from typing import Any
 import requests
 
 from operator_commands import (
+    ACTION_ACCOUNT_DELIVERY_STATUS,
     ACTION_STATUS,
     ACTION_TODAY_SPEND,
     CommandIntent,
@@ -20,6 +21,8 @@ from operator_commands import (
     SCOPE_AUTOMATION,
     SCOPE_MISSING,
     extract_account_ids,
+    is_all_account_delivery_status_query,
+    normalize_text,
     parse_deterministic_intent,
     parse_scope_reply,
 )
@@ -27,10 +30,14 @@ from operator_commands import (
 
 _SYSTEM_PROMPT = """你是广告投放运营命令分类器。你只能理解命令,不能调用工具或执行操作。
 只输出 JSON 对象,字段为 action、scope_type、missing_fields、confidence。
-action 只能是 DAY_PAUSE、STOP、RESUME、STATUS、TODAY_SPEND、UNKNOWN。
+action 只能是 DAY_PAUSE、STOP、RESUME、STATUS、ACCOUNT_DELIVERY_STATUS、TODAY_SPEND、UNKNOWN。
 scope_type 只能是 ALL、AUTOMATION、ACCOUNTS、MISSING。
 DAY_PAUSE 表示仅暂停今天或暂停到下一投放日;STOP 表示持续停止直到人工恢复。
 TODAY_SPEND 表示查询今天的腾讯广告消耗总览;其中 AUTOMATION 表示自动化账户,ALL 表示全部启用白名单账户。
+STATUS 只表示查询当前仍生效的运营暂停状态,保持原实时调控账户范围;“查询暂停状态”属于 STATUS。
+ACCOUNT_DELIVERY_STATUS 表示从大数据账号表读取全部账号,并查询账号下广告的投放状态,scope_type 必须为 ALL。
+“查询全部账户状态”“查询全部账户投放状态”以及把“账户”写成“账号”都属于 ACCOUNT_DELIVERY_STATUS。
+只有用户原文同时包含“全部”或“所有”、账户/账号/帐号以及状态查询时,才能返回 ACCOUNT_DELIVERY_STATUS。
 如果用户只说暂停、停止或恢复而没有范围,scope_type=MISSING,missing_fields=["scope"]。
 写操作中的“自动化账户”“纳管账户”归类为 ALL;TODAY_SPEND 查询中的“自动化账户”归类为 AUTOMATION。
 如果 active_draft 存在,当前消息是在补充该草稿,必须保持草稿 action 不变。
@@ -176,6 +183,11 @@ class CommandIntentParser:
             return None
         if action not in INTENT_ACTIONS:
             raise ValueError(f"模型返回非法 action: {action}")
+        if (
+            action == ACTION_ACCOUNT_DELIVERY_STATUS
+            and not is_all_account_delivery_status_query(normalize_text(raw_text))
+        ):
+            return None
         scope_type = str(parsed.get("scope_type") or SCOPE_MISSING).upper()
         if scope_type not in {
             SCOPE_ALL,
@@ -201,12 +213,18 @@ class CommandIntentParser:
             scope_type = SCOPE_MISSING
         if action != ACTION_TODAY_SPEND and scope_type == SCOPE_AUTOMATION:
             scope_type = SCOPE_ALL
+        if action in {ACTION_STATUS, ACTION_ACCOUNT_DELIVERY_STATUS}:
+            scope_type = SCOPE_ALL
         missing = tuple(
             str(value)
             for value in (parsed.get("missing_fields") or [])
             if str(value) in {"scope", "clarification"}
         )
-        if action != ACTION_STATUS and scope_type == SCOPE_MISSING and "scope" not in missing:
+        if (
+            action not in {ACTION_STATUS, ACTION_ACCOUNT_DELIVERY_STATUS}
+            and scope_type == SCOPE_MISSING
+            and "scope" not in missing
+        ):
             missing = (*missing, "scope")
         return CommandIntent(
             action=action,

+ 28 - 0
examples/tencent_realtime_control/feishu_command_service.py

@@ -17,8 +17,15 @@ from agent.tools.builtin.feishu.feishu_client import (
     FeishuMessageEvent,
 )
 
+from account_status_query import (
+    build_account_status_card,
+    create_account_status_xlsx,
+    query_all_account_statuses,
+)
 from command_intent_parser import CommandIntentParser
+from feishu_notifier import FeishuNotifier
 from operator_commands import (
+    ACTION_ACCOUNT_DELIVERY_STATUS,
     ACTION_CANCEL,
     ACTION_CONFIRM,
     ACTION_DAY_PAUSE,
@@ -125,6 +132,18 @@ class FeishuCommandService:
             reply_to_message_id=event.message_id,
         )
 
+    def _reply_account_status_card(
+        self,
+        event: FeishuMessageEvent,
+        summary: dict[str, Any],
+        sheet_url: str,
+    ) -> None:
+        self.client.send_card(
+            to=event.chat_id,
+            card=build_account_status_card(summary, sheet_url=sheet_url),
+            reply_to_message_id=event.message_id,
+        )
+
     def _authorized(self, event: FeishuMessageEvent) -> bool:
         return (
             event.content_type in {"text", "post"}
@@ -341,6 +360,15 @@ class FeishuCommandService:
                     f"- 涉及账户:{accounts}",
                 )
                 return
+            if intent.action == ACTION_ACCOUNT_DELIVERY_STATUS:
+                summary = query_all_account_statuses(now=now)
+                report_path = create_account_status_xlsx(summary)
+                sheet_url = FeishuNotifier().import_spreadsheet(
+                    report_path,
+                    readonly_link=True,
+                )
+                self._reply_account_status_card(event, summary, sheet_url)
+                return
             if intent.action == ACTION_TODAY_SPEND:
                 summary = query_today_spend(
                     intent.to_parsed_command(),

+ 39 - 4
examples/tencent_realtime_control/operator_commands.py

@@ -10,13 +10,18 @@ ACTION_DAY_PAUSE = "DAY_PAUSE"
 ACTION_STOP = "STOP"
 ACTION_RESUME = "RESUME"
 ACTION_STATUS = "STATUS"
+ACTION_ACCOUNT_DELIVERY_STATUS = "ACCOUNT_DELIVERY_STATUS"
 ACTION_TODAY_SPEND = "TODAY_SPEND"
 ACTION_CONFIRM = "CONFIRM"
 ACTION_CANCEL = "CANCEL"
 ACTION_REJECT = "REJECT"
 
 WRITE_ACTIONS = {ACTION_DAY_PAUSE, ACTION_STOP, ACTION_RESUME}
-READ_ACTIONS = {ACTION_STATUS, ACTION_TODAY_SPEND}
+READ_ACTIONS = {
+    ACTION_STATUS,
+    ACTION_ACCOUNT_DELIVERY_STATUS,
+    ACTION_TODAY_SPEND,
+}
 INTENT_ACTIONS = WRITE_ACTIONS | READ_ACTIONS
 
 SCOPE_ALL = "ALL"
@@ -47,7 +52,7 @@ class CommandIntent:
 
     @property
     def complete(self) -> bool:
-        if self.action == ACTION_STATUS:
+        if self.action in {ACTION_STATUS, ACTION_ACCOUNT_DELIVERY_STATUS}:
             return True
         if self.action == ACTION_TODAY_SPEND:
             if self.scope_type in {SCOPE_ALL, SCOPE_AUTOMATION}:
@@ -93,14 +98,39 @@ def extract_account_ids(raw: str) -> tuple[int, ...]:
     )
 
 
+def _is_pause_status_query(text: str) -> bool:
+    """识别原有运营暂停状态查询。"""
+    return "查看暂停状态" in text or "查询暂停状态" in text
+
+
+def is_all_account_delivery_status_query(text: str) -> bool:
+    """识别从大数据账号表读取全部广告投放状态的查询。"""
+    if _is_pause_status_query(text):
+        return False
+    has_all_word = any(value in text for value in ("全部", "所有"))
+    has_account_word = any(value in text for value in ("账户", "账号", "帐号"))
+    query_verbs = ("查询", "查看", "看看", "看下", "看一下")
+    return (
+        has_all_word
+        and has_account_word
+        and "状态" in text
+        and any(value in text for value in query_verbs)
+    )
+
+
 def parse_deterministic_intent(raw: str) -> CommandIntent | None:
     """解析安全命令格式,包括需要继续补问的不完整命令。"""
     text = normalize_text(raw)
     if not text:
         return None
 
-    if "查看暂停状态" in text or "查询暂停状态" in text:
+    if _is_pause_status_query(text):
         return CommandIntent(action=ACTION_STATUS, scope_type=SCOPE_ALL)
+    if is_all_account_delivery_status_query(text):
+        return CommandIntent(
+            action=ACTION_ACCOUNT_DELIVERY_STATUS,
+            scope_type=SCOPE_ALL,
+        )
 
     if (
         text.startswith(("今天", "暂停", "停止"))
@@ -173,8 +203,13 @@ def parse_command(raw: str) -> ParsedCommand | None:
             command_id=command_match.group(0).lower(),
         )
 
-    if "查看暂停状态" in text or "查询暂停状态" in text:
+    if _is_pause_status_query(text):
         return ParsedCommand(action=ACTION_STATUS, scope_type="ALL")
+    if is_all_account_delivery_status_query(text):
+        return ParsedCommand(
+            action=ACTION_ACCOUNT_DELIVERY_STATUS,
+            scope_type="ALL",
+        )
 
     if text.startswith(("停止", "持续停止", "永久停止")):
         action = ACTION_STOP

+ 401 - 0
examples/tencent_realtime_control/test_account_status_query.py

@@ -0,0 +1,401 @@
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from datetime import datetime
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+from openpyxl import load_workbook
+
+
+HERE = Path(__file__).resolve().parent
+if str(HERE) not in sys.path:
+    sys.path.insert(0, str(HERE))
+
+from account_status_query import (
+    build_account_source_sql,
+    build_account_status_card,
+    classify_ad_delivery_status,
+    create_account_status_xlsx,
+    query_all_account_statuses,
+)
+from command_intent_parser import CommandIntentParser, IntentParserConfig
+from operator_commands import (
+    ACTION_ACCOUNT_DELIVERY_STATUS,
+    ACTION_STATUS,
+    SCOPE_ALL,
+    parse_deterministic_intent,
+)
+
+
+SHANGHAI = ZoneInfo("Asia/Shanghai")
+
+
+class AccountStatusParsingTest(unittest.TestCase):
+    def test_account_and_account_id_wording_are_deterministic(self) -> None:
+        for text in (
+            "查询全部账户状态",
+            "查询全部账户投放状态",
+            "查询全部账号状态",
+            "查询全部账号投放状态",
+            "查询全部帐号投放状态",
+        ):
+            with self.subTest(text=text):
+                intent = parse_deterministic_intent(text)
+                self.assertIsNotNone(intent)
+                self.assertEqual(ACTION_ACCOUNT_DELIVERY_STATUS, intent.action)
+                self.assertEqual(SCOPE_ALL, intent.scope_type)
+                self.assertTrue(intent.complete)
+
+    def test_model_status_scope_is_forced_to_all_accounts(self) -> None:
+        parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
+        intent = parser._validate_model_intent(
+            {
+                "action": "ACCOUNT_DELIVERY_STATUS",
+                "scope_type": "ACCOUNTS",
+                "missing_fields": [],
+                "confidence": 0.99,
+            },
+            "查询所有账号现在的投放状态",
+        )
+        self.assertEqual(ACTION_ACCOUNT_DELIVERY_STATUS, intent.action)
+        self.assertEqual(SCOPE_ALL, intent.scope_type)
+        self.assertEqual((), intent.account_ids)
+
+    def test_pause_status_keeps_original_independent_action(self) -> None:
+        for text in ("查询暂停状态", "查看暂停状态"):
+            with self.subTest(text=text):
+                intent = parse_deterministic_intent(text)
+                self.assertIsNotNone(intent)
+                self.assertEqual(ACTION_STATUS, intent.action)
+                self.assertEqual(SCOPE_ALL, intent.scope_type)
+                self.assertTrue(intent.complete)
+
+        parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
+        intent = parser._validate_model_intent(
+            {
+                "action": "STATUS",
+                "scope_type": "ALL",
+                "missing_fields": [],
+                "confidence": 0.99,
+            },
+            "查询暂停状态",
+        )
+        self.assertEqual(ACTION_STATUS, intent.action)
+
+    def test_non_all_account_status_wording_does_not_trigger_full_scan(self) -> None:
+        for text in (
+            "查询账号 86748335 状态",
+            "查看账户token状态",
+            "查询某账号开户状态",
+            "看看账号现在的投放状态",
+        ):
+            with self.subTest(text=text):
+                self.assertIsNone(parse_deterministic_intent(text))
+
+        parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
+        intent = parser._validate_model_intent(
+            {
+                "action": "ACCOUNT_DELIVERY_STATUS",
+                "scope_type": "ALL",
+                "missing_fields": [],
+                "confidence": 0.99,
+            },
+            "查询账号 86748335 状态",
+        )
+        self.assertIsNone(intent)
+
+
+class AccountStatusClassificationTest(unittest.TestCase):
+    def test_missing_delivery_dates_mean_no_date_boundary(self) -> None:
+        result = classify_ad_delivery_status(
+            {
+                "configured_status": "AD_STATUS_NORMAL",
+                "time_series": "1" * 336,
+            },
+            now=datetime(2026, 8, 21, 12, 15, tzinfo=SHANGHAI),
+        )
+        self.assertEqual("投放期内", result["date_status"])
+        self.assertEqual("投放中", result["delivery_status"])
+
+    def test_combines_delivery_dates_switch_and_current_time_series(self) -> None:
+        now = datetime(2026, 8, 21, 12, 15, tzinfo=SHANGHAI)
+        cases = (
+            (
+                {
+                    "begin_date": "2026-08-01",
+                    "end_date": "2026-08-31",
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "time_series": "1" * 336,
+                },
+                "投放中",
+            ),
+            (
+                {
+                    "begin_date": "2026-08-01",
+                    "end_date": "2026-08-31",
+                    "configured_status": "AD_STATUS_SUSPEND",
+                    "time_series": "1" * 336,
+                },
+                "已暂停",
+            ),
+            (
+                {
+                    "begin_date": "2026-08-22",
+                    "end_date": "2026-08-31",
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "time_series": "1" * 336,
+                },
+                "未开始",
+            ),
+            (
+                {
+                    "begin_date": "2026-08-01",
+                    "end_date": "2026-08-20",
+                    "configured_status": "AD_STATUS_SUSPEND",
+                    "time_series": "1" * 336,
+                },
+                "已结束",
+            ),
+            (
+                {
+                    "begin_date": "2026-08-01",
+                    "end_date": "2026-08-31",
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "time_series": "1" + "0" * 335,
+                },
+                "非投放时段",
+            ),
+            (
+                {
+                    "begin_date": "2026-08-01",
+                    "end_date": "2026-08-31",
+                    "configured_status": "AD_STATUS_NORMAL",
+                },
+                "状态未知",
+            ),
+        )
+        for ad, expected in cases:
+            with self.subTest(expected=expected):
+                result = classify_ad_delivery_status(ad, now=now)
+                self.assertEqual(expected, result["delivery_status"])
+
+    def test_default_0600_2000_schedule_uses_half_hour_boundaries(self) -> None:
+        one_day = "0" * 12 + "1" * 28 + "0" * 8
+        ad = {
+            "begin_date": "2026-08-01",
+            "end_date": "2026-08-31",
+            "configured_status": "AD_STATUS_NORMAL",
+            "time_series": one_day * 7,
+        }
+        cases = (
+            ((5, 59), "非投放时段"),
+            ((6, 0), "投放中"),
+            ((19, 59), "投放中"),
+            ((20, 0), "非投放时段"),
+        )
+        for (hour, minute), expected in cases:
+            with self.subTest(hour=hour, minute=minute):
+                result = classify_ad_delivery_status(
+                    ad,
+                    now=datetime(2026, 8, 21, hour, minute, tzinfo=SHANGHAI),
+                )
+                self.assertEqual(expected, result["delivery_status"])
+                self.assertEqual("06:00-20:00", result["today_delivery_periods"])
+
+    def test_time_series_starts_at_monday_midnight(self) -> None:
+        monday_noon_slot = 24
+        series = ["0"] * 336
+        series[monday_noon_slot] = "1"
+        ad = {
+            "begin_date": "2026-08-01",
+            "end_date": "2026-08-31",
+            "configured_status": "AD_STATUS_NORMAL",
+            "time_series": "".join(series),
+        }
+        result = classify_ad_delivery_status(
+            ad,
+            now=datetime(2026, 8, 17, 12, 15, tzinfo=SHANGHAI),
+        )
+        self.assertEqual("投放中", result["delivery_status"])
+        self.assertEqual("周一 12:00-12:30", result["current_time_period"])
+
+
+class AccountStatusQueryTest(unittest.TestCase):
+    class Odps:
+        def execute_sql(self, sql, hints=None):
+            self.sql = sql
+            self.hints = hints
+            return pd.DataFrame(
+                [
+                    {
+                        "account_id": "10000001",
+                        "account_name": "账号一",
+                        "agent_name": "代理一",
+                        "status": 1,
+                    },
+                    {
+                        "account_id": "10000002",
+                        "account_name": "账号二",
+                        "agent_name": "代理二",
+                        "status": 0,
+                    },
+                    {
+                        "account_id": "10000003",
+                        "account_name": "账号三",
+                        "agent_name": "",
+                        "status": 1,
+                    },
+                    {
+                        "account_id": "10000004",
+                        "account_name": "账号四",
+                        "agent_name": "",
+                        "status": 1,
+                    },
+                ]
+            )
+
+    class Tencent:
+        def get_ads(self, account_id: int):
+            if account_id == 10000002:
+                raise RuntimeError("get_ads failed: code=11002 invalid access token")
+            if account_id == 10000003:
+                raise RuntimeError(
+                    "get_ads failed: code=12201 account_id 参数无效,"
+                    "当前 token 无账号权限"
+                )
+            if account_id == 10000004:
+                raise RuntimeError("api unavailable")
+            return [
+                {
+                    "adgroup_id": 101,
+                    "adgroup_name": "投放中",
+                    "begin_date": "2026-08-01",
+                    "end_date": "2026-08-31",
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "time_series": "1" * 336,
+                },
+                {
+                    "adgroup_id": 102,
+                    "adgroup_name": "暂停",
+                    "begin_date": "2026-08-01",
+                    "end_date": "0",
+                    "configured_status": "AD_STATUS_SUSPEND",
+                    "time_series": "1" * 336,
+                },
+                {
+                    "adgroup_id": 103,
+                    "adgroup_name": "未开始",
+                    "begin_date": "2026-08-22",
+                    "end_date": "2026-09-01",
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "time_series": "1" * 336,
+                },
+                {
+                    "adgroup_id": 104,
+                    "adgroup_name": "已结束",
+                    "begin_date": "2026-07-01",
+                    "end_date": "2026-08-20",
+                    "configured_status": "AD_STATUS_SUSPEND",
+                    "time_series": "1" * 336,
+                },
+                {
+                    "adgroup_id": 105,
+                    "adgroup_name": "未知",
+                    "begin_date": "2026-08-01",
+                    "end_date": "0",
+                    "configured_status": "AD_STATUS_PENDING",
+                    "time_series": "1" * 336,
+                },
+                {
+                    "adgroup_id": 107,
+                    "adgroup_name": "时段外",
+                    "begin_date": "2026-08-01",
+                    "end_date": "0",
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "time_series": "1" + "0" * 335,
+                },
+                {
+                    "adgroup_id": 106,
+                    "configured_status": "AD_STATUS_NORMAL",
+                    "is_deleted": True,
+                },
+            ]
+
+    def test_source_sql_uses_latest_non_deleted_bigdata_account_rows(self) -> None:
+        sql = build_account_source_sql()
+        self.assertIn("FROM loghubods.ad_put_tencent_account", sql)
+        self.assertIn("PARTITION BY account_id", sql)
+        self.assertIn("COALESCE(update_time, create_time) DESC, id DESC", sql)
+        self.assertIn("row_number = 1", sql)
+        self.assertIn("NVL(is_delete, 0) = 0", sql)
+        self.assertNotIn("status = 1", sql)
+
+    def test_only_token_normal_accounts_enter_ad_statistics(self) -> None:
+        now = datetime(2026, 8, 21, 12, 0, tzinfo=SHANGHAI)
+        odps = self.Odps()
+        summary = query_all_account_statuses(
+            now=now,
+            odps=odps,
+            tencent=self.Tencent(),
+        )
+
+        self.assertEqual(4, summary["source_account_count"])
+        self.assertEqual(1, summary["token_normal_account_count"])
+        self.assertEqual(2, summary["token_abnormal_account_count"])
+        self.assertEqual(1, summary["failed_account_count"])
+        self.assertEqual(6, summary["ad_count"])
+        self.assertEqual(
+            {
+                "投放中": 1,
+                "非投放时段": 1,
+                "已暂停": 1,
+                "未开始": 1,
+                "已结束": 1,
+                "状态未知": 1,
+            },
+            summary["status_counts"],
+        )
+        self.assertEqual(10000001, summary["accounts"][0]["account_id"])
+        self.assertEqual(
+            10000002,
+            summary["token_abnormal_accounts"][0]["account_id"],
+        )
+        self.assertEqual(
+            10000003,
+            summary["token_abnormal_accounts"][1]["account_id"],
+        )
+
+    def test_workbook_and_card_contain_report_entry_point(self) -> None:
+        now = datetime(2026, 8, 21, 12, 0, tzinfo=SHANGHAI)
+        summary = query_all_account_statuses(
+            now=now,
+            odps=self.Odps(),
+            tencent=self.Tencent(),
+        )
+        with tempfile.TemporaryDirectory() as directory:
+            path = create_account_status_xlsx(
+                summary,
+                output_dir=Path(directory),
+            )
+            workbook = load_workbook(path, read_only=True)
+            self.assertEqual(["账号汇总", "广告明细", "异常账号"], workbook.sheetnames)
+            self.assertEqual("账号ID", workbook["广告明细"]["A1"].value)
+            self.assertEqual("今日投放时段", workbook["广告明细"]["J1"].value)
+            self.assertEqual("投放状态", workbook["广告明细"]["M1"].value)
+            self.assertEqual("TOKEN异常", workbook["异常账号"]["D2"].value)
+
+        card = build_account_status_card(
+            summary,
+            sheet_url="https://example.test/account-status",
+        )
+        button = card["elements"][1]["actions"][0]
+        self.assertEqual("打开广告状态表格", button["text"]["content"])
+        self.assertEqual("https://example.test/account-status", button["url"])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 1 - 0
runtime.env.example

@@ -232,6 +232,7 @@ RTC_COMMAND_ALLOWED_OPEN_IDS=
 RTC_COMMAND_CONFIRM_TTL_MINUTES=10
 RTC_COMMAND_DRAFT_TTL_MINUTES=5
 RTC_COMMAND_WORKERS=2
+RTC_ACCOUNT_STATUS_QUERY_WORKERS=8
 RTC_COMMAND_NEXT_DELIVERY_HOUR=6
 RTC_NL_COMMAND_ENABLED=0
 RTC_COMMAND_LLM_MODEL=google/gemini-3-flash-preview