Просмотр исходного кода

feat: 添加腾讯广告 API user_token 支持和安全加固

关键变更:
- tools/ad_api.py: 在写操作中自动添加 user_token(实名认证令牌)
- tools/execution_engine.py: 添加白名单检查和 token 预验证
- tools/im_approval.py: 修复审批结果解析,返回结构化状态
- .env.example: 添加 TENCENT_AD_USER_TOKEN 配置说明

修复问题:
- 解决错误码 11101(请求缺失实名认证令牌)
- 增强执行引擎的白名单安全防护
- 改进审批流程的自然语言解析

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
刘立冬 1 месяц назад
Родитель
Сommit
ebf9afbd45

+ 8 - 0
examples/auto_put_ad_mini/.env.example

@@ -5,7 +5,15 @@
 # 腾讯广告 API 配置
 # ========================================
 TENCENT_AD_ACCOUNT_ID=80769799
+
+# Access Token(应用级认证)
 # TENCENT_AD_ACCESS_TOKEN=xxx        # 可选,优先使用 Token API 动态获取
+
+# User Token(用户级实名认证令牌)
+# ⚠️ 重要:写操作(暂停广告/修改出价)必须配置 user_token
+# 获取方式:https://docs.qq.com/doc/DSVdkdk1LQ1hOam5n
+TENCENT_AD_USER_TOKEN=xxx
+
 # TENCENT_AD_BASE_URL=https://api.e.qq.com/v3.0
 
 # ========================================

+ 15 - 1
examples/auto_put_ad_mini/tools/ad_api.py

@@ -125,9 +125,23 @@ def _post(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
     """
     发送 POST 请求。
     公共参数在 URL query,业务参数在 JSON body。
+
+    ⚠️ 重要:腾讯广告写操作需要 user_token(实名认证令牌)
     """
     account_id = body.get("account_id", 0)
-    query = urlencode(_common_params(account_id))
+    params = _common_params(account_id)
+
+    # 写操作需要额外的 user_token(读操作不需要)
+    user_token = os.getenv("TENCENT_AD_USER_TOKEN", "")
+    if user_token:
+        params["user_token"] = user_token
+    else:
+        logger.warning(
+            "[TencentAPI] 未配置 TENCENT_AD_USER_TOKEN,"
+            "写操作可能失败(错误码 11101)"
+        )
+
+    query = urlencode(params)
     url = f"{BASE_URL}{path}?{query}"
     logger.debug("[TencentAPI] POST %s body=%s", url, json.dumps(body, ensure_ascii=False)[:200])
 

+ 38 - 2
examples/auto_put_ad_mini/tools/execution_engine.py

@@ -116,7 +116,25 @@ class TencentAdExecutor:
 
     async def update_bid(self, ad_id: int, account_id: int, bid_amount_fen: int) -> Dict:
         """更新广告出价(分)。"""
-        from ad_api import _post, _check
+        # 白名单安全检查(最后防线)
+        from config import WHITELIST_ENABLED, WHITELIST_ACCOUNTS
+        if WHITELIST_ENABLED and account_id not in WHITELIST_ACCOUNTS:
+            logger.error(
+                f"⚠️ 白名单安全阻断:账户 {account_id} 不在白名单内,拒绝执行 update_bid。"
+                f"白名单: {WHITELIST_ACCOUNTS}"
+            )
+            return {"code": -1, "message": f"账户 {account_id} 不在白名单内"}
+
+        # 预先获取并验证 access_token
+        from ad_api import _post, _check, _get_access_token
+        try:
+            token = _get_access_token(account_id)
+            if not token or len(token) < 10:
+                return {"code": -1, "message": f"获取 access_token 失败:token={token[:20]}"}
+            logger.info(f"[update_bid] 已获取 access_token (账户={account_id}): {token[:10]}...")
+        except Exception as e:
+            logger.error(f"[update_bid] 获取 access_token 异常: {e}")
+            return {"code": -1, "message": f"获取 access_token 异常: {e}"}
 
         for attempt in range(API_MAX_RETRIES):
             try:
@@ -139,7 +157,25 @@ class TencentAdExecutor:
 
     async def pause_ad(self, ad_id: int, account_id: int) -> Dict:
         """暂停广告。"""
-        from ad_api import _post, _check
+        # 白名单安全检查(最后防线)
+        from config import WHITELIST_ENABLED, WHITELIST_ACCOUNTS
+        if WHITELIST_ENABLED and account_id not in WHITELIST_ACCOUNTS:
+            logger.error(
+                f"⚠️ 白名单安全阻断:账户 {account_id} 不在白名单内,拒绝执行 pause_ad。"
+                f"白名单: {WHITELIST_ACCOUNTS}"
+            )
+            return {"code": -1, "message": f"账户 {account_id} 不在白名单内"}
+
+        # 预先获取并验证 access_token
+        from ad_api import _post, _check, _get_access_token
+        try:
+            token = _get_access_token(account_id)
+            if not token or len(token) < 10:
+                return {"code": -1, "message": f"获取 access_token 失败:token={token[:20]}"}
+            logger.info(f"[pause_ad] 已获取 access_token (账户={account_id}): {token[:10]}...")
+        except Exception as e:
+            logger.error(f"[pause_ad] 获取 access_token 异常: {e}")
+            return {"code": -1, "message": f"获取 access_token 异常: {e}"}
 
         for attempt in range(API_MAX_RETRIES):
             try:

+ 50 - 6
examples/auto_put_ad_mini/tools/im_approval.py

@@ -819,22 +819,66 @@ async def send_approval_request(
                                     "ad_ids": tier2_ad_ids,
                                 })
                                 logger.info("HTTP 轮询收到运营回复: %s", text[:200])
+
+                                # 解析审批结果
+                                text_lower = text.lower()
+                                is_approved = any(kw in text_lower for kw in ["同意", "批准", "通过", "执行", "ok", "确认"])
+                                is_rejected = any(kw in text_lower for kw in ["拒绝", "驳回", "不同意", "不批准"])
+
+                                # 解析具体批准/拒绝的广告ID(如果有)
+                                import re
+                                mentioned_ids = []
+                                for match in re.finditer(r'\b(\d{11})\b', text):  # 腾讯广告ID通常11位
+                                    ad_id = int(match.group(1))
+                                    if ad_id in tier2_ad_ids:
+                                        mentioned_ids.append(ad_id)
+
+                                # 确定最终状态和ID列表
+                                if is_approved and not is_rejected:
+                                    # 全部批准或部分批准
+                                    status = "approved"
+                                    approved_ids = mentioned_ids if mentioned_ids else tier2_ad_ids
+                                    rejected_ids = []
+                                elif is_rejected and not is_approved:
+                                    # 全部拒绝
+                                    status = "rejected"
+                                    approved_ids = []
+                                    rejected_ids = tier2_ad_ids
+                                elif mentioned_ids:
+                                    # 提到具体ID:只批准/拒绝这些ID
+                                    if is_approved:
+                                        status = "partial_approved"
+                                        approved_ids = mentioned_ids
+                                        rejected_ids = [x for x in tier2_ad_ids if x not in mentioned_ids]
+                                    else:
+                                        status = "partial_rejected"
+                                        approved_ids = [x for x in tier2_ad_ids if x not in mentioned_ids]
+                                        rejected_ids = mentioned_ids
+                                else:
+                                    # 无法判断,需要Agent处理
+                                    status = "unclear"
+                                    approved_ids = []
+                                    rejected_ids = []
+
                                 ad_ids_str = ", ".join(str(x) for x in tier2_ad_ids[:10])
                                 if len(tier2_ad_ids) > 10:
                                     ad_ids_str += f"...共{len(tier2_ad_ids)}个"
+
                                 return ToolResult(
-                                    title="运营已回复",
+                                    title=f"运营已回复({status})",
                                     output=(
                                         f"运营飞书回复原文: {text}\n"
                                         f"等待审批的广告ID: {ad_ids_str}\n"
-                                        f"等待时间: {poll_count * poll_interval_seconds} 秒\n\n"
-                                        f"请根据运营的自然语言回复判断后续操作:\n"
-                                        f"- 运营同意/批准/通过 → 调用 execute_decisions 执行\n"
-                                        f"- 运营拒绝/驳回 → 停止执行,告知原因\n"
-                                        f"- 运营要求修改(如\"广告X不要暂停\"/\"降价的去掉\")→ modify_decisions → validate → 重新审批(等待再次明确'同意')"
+                                        f"解析结果: {status}\n"
+                                        f"  - 批准: {len(approved_ids)} 个\n"
+                                        f"  - 拒绝: {len(rejected_ids)} 个\n"
+                                        f"等待时间: {poll_count * poll_interval_seconds} 秒"
                                     ),
                                     metadata={
                                         "request_id": request_id,
+                                        "status": status,
+                                        "approved_ids": approved_ids,
+                                        "rejected_ids": rejected_ids,
                                         "feishu_sent": feishu_sent,
                                         "msg_path": str(msg_path),
                                         "poll_count": poll_count,