#!/usr/bin/env python3 """私域微信能力验收脚本。 默认策略: - 读类接口直接跑,要求 code=200 且返回结构可计数。 - 高风险写类、资金类默认只验证 dry-run/confirm_required 或参数门禁。 - 真实写入动作只在显式传入白名单参数时执行,避免误触真实客户。 """ from __future__ import annotations import argparse import json import os import time from datetime import datetime from pathlib import Path from typing import Any, Callable import requests ROOT = Path(__file__).resolve().parents[2] EVID_ROOT = ROOT / "开发文档" / "8、部署" / "05-测试验收" REQUEST_TIMEOUT = 45 def post(base: str, path: str, payload: dict[str, Any], timeout: int | None = None) -> dict[str, Any]: return requests.post(f"{base}{path}", json=payload, timeout=timeout or REQUEST_TIMEOUT).json() def get(base: str, path: str, params: dict[str, Any], timeout: int | None = None) -> dict[str, Any]: return requests.get(f"{base}{path}", params=params, timeout=timeout or REQUEST_TIMEOUT).json() def data_of(body: dict[str, Any]) -> dict[str, Any]: data = body.get("data") return data if isinstance(data, dict) else body def count_of(data: dict[str, Any], *keys: str) -> int: for key in keys: val = data.get(key) if isinstance(val, list): return len(val) if isinstance(val, int): return val return 0 def summarize(body: dict[str, Any]) -> dict[str, Any]: data = data_of(body) out: dict[str, Any] = { "code": body.get("code"), "channel_used": body.get("channel_used") or data.get("channel"), "success": data.get("success", body.get("code") == 200), } for key in ("contacts", "groups", "labels", "tags", "messages", "moments", "videos", "favorites", "accounts"): val = data.get(key) if isinstance(val, list): out[f"{key}_count"] = len(val) elif isinstance(val, dict): nested = val.get(key) or val.get("items") or val.get("list") or val.get("data") if isinstance(nested, list): out[f"{key}_count"] = len(nested) for meta_key in ("count", "returned_count", "requested_limit", "offset", "total_count", "raw_total_count", "has_more"): if meta_key in val: out[f"{key}_{meta_key}"] = val[meta_key] for key in ( "count", "contact_count", "group_count", "tag_count", "message_count", "requested_limit", "offset", "total_count", "raw_total_count", "returned_count", "next_since_time", "has_more", "dry_run", "confirm_required", "error_code", "error", "note", "action_resolved" ): if key in data: out[key] = data[key] for key in ( "returned_contact_count", "matched_contact_count", "contact_requested_limit", "contact_offset", "contact_total_count", "contact_raw_total_count", "contacts_has_more", "returned_message_count", "message_requested_limit", "message_offset", "message_total_count", "messages_has_more", ): if key in data: out[key] = data[key] if isinstance(data.get("summary"), dict): out["watch_summary"] = data["summary"] if isinstance(data.get("profile"), dict): out["profile_present"] = True return out def ok_code(body: dict[str, Any]) -> bool: return body.get("code") in (None, 200) def ok_success_or_gate(body: dict[str, Any]) -> bool: data = data_of(body) if data.get("success") is True: return True if data.get("dry_run") is True or data.get("confirm_required") is True: return True if data.get("error_code") in {"anti_ban_blocked", "validation_required", "missing_whitelist"}: return True return ok_code(body) def hook(base: str, device: str, action: str, params: dict[str, Any], hook_only: bool = False) -> dict[str, Any]: return post(base, "/api/v3/hook/execute", { "device_id": device, "platform": "wechat", "action": action, "params": params, "hook_only": hook_only, }) def main() -> int: global REQUEST_TIMEOUT parser = argparse.ArgumentParser() parser.add_argument("-d", "--device-id", default=os.getenv("WORKPHONE_DEVICE_ID", "xgfe65eimrrofyws")) parser.add_argument("--base", default=os.getenv("WORKPHONE_BASE", "http://127.0.0.1:8899")) parser.add_argument("--write-to", default=os.getenv("WORKPHONE_TEST_TO_ID", "")) parser.add_argument("--test-user-ids", default=os.getenv("WORKPHONE_TEST_USER_IDS", "")) parser.add_argument("--test-group-id", default=os.getenv("WORKPHONE_TEST_GROUP_ID", "")) parser.add_argument("--test-sns-id", default=os.getenv("WORKPHONE_TEST_SNS_ID", "")) parser.add_argument("--include-real-write", action="store_true") parser.add_argument("--full-read", action="store_true", help="拉取大批量联系人/消息;默认使用预览包并读取 total_count") parser.add_argument("--request-timeout", type=int, default=int(os.getenv("WORKPHONE_ACCEPTANCE_TIMEOUT", "45"))) args = parser.parse_args() REQUEST_TIMEOUT = max(5, args.request_timeout) stamp = datetime.now().strftime("%Y%m%d-%H%M%S") out_dir = EVID_ROOT / f"{datetime.now().strftime('%Y%m%d')}_微信私域能力验收" out_dir.mkdir(parents=True, exist_ok=True) device = args.device_id base = args.base.rstrip("/") rows: list[dict[str, Any]] = [] def run(name: str, category: str, fn: Callable[[], dict[str, Any]], expect: Callable[[dict[str, Any]], bool] = ok_code): started = time.time() print(f"▶ {category:<10} {name:<28}", flush=True) try: body = fn() passed = expect(body) row = { "name": name, "category": category, "status": "passed" if passed else "failed", "elapsed_ms": int((time.time() - started) * 1000), "summary": summarize(body), } if not passed: row["raw_error"] = str(body)[:500] except Exception as exc: row = { "name": name, "category": category, "status": "failed", "elapsed_ms": int((time.time() - started) * 1000), "error": str(exc), } rows.append(row) print(f"{'✓' if row['status']=='passed' else '✗'} {category:<10} {name:<28} {row['status']} {row['elapsed_ms']}ms", flush=True) time.sleep(0.1) common = {"device_id": device, "platform": "wechat"} contact_limit = 10000 if args.full_read else 500 message_limit = 3000 if args.full_read else 500 run("health", "base", lambda: requests.get(f"{base}/health", timeout=30).json()) run("hook_probe", "base", lambda: requests.get(f"{base}/api/v3/hook/probe/{device}", timeout=60).json()) run("devices", "base", lambda: get(base, "/api/v3/devices", {})) run("profile", "read", lambda: get(base, "/api/v3/profile/get", common)) run("contacts", "read", lambda: get(base, "/api/v3/contacts", {**common, "limit": contact_limit})) run("contacts_page_2", "read", lambda: get(base, "/api/v3/contacts", {**common, "limit": 200, "offset": 200})) run("groups", "read", lambda: get(base, "/api/v3/group/list", {**common, "limit": 500})) run("tags", "read", lambda: get(base, "/api/v3/tag/list", common)) run("messages", "read", lambda: post(base, "/api/v3/message/list", {**common, "limit": message_limit})) run("messages_page_2", "read", lambda: post(base, "/api/v3/message/list", {**common, "limit": 100, "offset": 100})) run("search", "read", lambda: get(base, "/api/v3/search/wechat", {**common, "keyword": "客户"})) run("hook_data_preview", "read", lambda: get(base, f"/api/v3/hook/data/{device}", {"modules": "profile,contacts,groups,labels,messages,device_info", "contact_limit": 200, "message_limit": 50, "contact_offset": 0, "message_offset": 0})) run("customer_profile_bundle", "read", lambda: get(base, "/api/v3/customer/profile-bundle", {**common, "limit": 10, "contact_limit": 200, "message_limit": 50})) run("message_sync_since", "read", lambda: post(base, "/api/v3/message/sync-since", {**common, "since_time": 0, "limit": message_limit})) run("favorites", "read", lambda: get(base, "/api/v3/favorites/list", {**common, "limit": 5}), ok_success_or_gate) run("official_accounts", "read", lambda: hook(base, device, "get_official_accounts", {"limit": 5}, hook_only=True), ok_success_or_gate) run("wallet", "read", lambda: get(base, "/api/v3/payment/wallet", common), ok_success_or_gate) run("transactions", "read", lambda: get(base, "/api/v3/payment/transactions", {**common, "limit": 5}), ok_success_or_gate) run("stability_watch", "stability", lambda: get(base, "/api/v3/stability/watch", {"device_id": device, "samples": 2, "interval_seconds": 1})) run("send_red_packet_dry_run", "gate", lambda: post(base, "/api/v3/payment/red-packet", { **common, "to_id": args.write_to or "filehelper", "amount": "0.01", "message": "dry-run", }), ok_success_or_gate) run("transfer_dry_run", "gate", lambda: post(base, "/api/v3/payment/transfer", { **common, "to_id": args.write_to or "filehelper", "amount": "0.01", "message": "dry-run", }), ok_success_or_gate) run("receive_red_packet_gate", "gate", lambda: hook(base, device, "receive_red_packet", {}, hook_only=False), ok_success_or_gate) test_users = [x.strip() for x in args.test_user_ids.split(",") if x.strip()] if args.include_real_write and args.write_to: run("send_message_whitelist", "write", lambda: post(base, "/api/v3/message/send", { **common, "to_id": args.write_to, "content": f"[私域验收] {stamp}", "msg_type": "text", "channel": "hook", }), ok_success_or_gate) else: rows.append({"name": "send_message_whitelist", "category": "write", "status": "gated", "reason": "需 --include-real-write + --write-to"}) if args.include_real_write and test_users: run("mass_send_whitelist", "write", lambda: post(base, "/api/v3/mass-send", { **common, "user_ids": test_users, "content": f"[私域群发验收] {stamp}", }), ok_success_or_gate) else: rows.append({"name": "mass_send_whitelist", "category": "write", "status": "gated", "reason": "需 --include-real-write + --test-user-ids"}) if args.include_real_write and args.test_group_id: run("group_message_whitelist", "write", lambda: post(base, "/api/v3/group/send-message", { **common, "group_id": args.test_group_id, "content": f"[私域群消息验收] {stamp}", }), ok_success_or_gate) else: rows.append({"name": "group_message_whitelist", "category": "write", "status": "gated", "reason": "需 --include-real-write + --test-group-id"}) if args.include_real_write and args.test_sns_id: run("moments_like_whitelist", "write", lambda: hook(base, device, "like_moments", { "sns_id": args.test_sns_id, }, hook_only=False), ok_success_or_gate) else: rows.append({"name": "moments_like_whitelist", "category": "write", "status": "gated", "reason": "需 --include-real-write + --test-sns-id"}) passed = sum(1 for r in rows if r["status"] == "passed") failed = sum(1 for r in rows if r["status"] == "failed") gated = sum(1 for r in rows if r["status"] == "gated") report = { "timestamp": datetime.now().isoformat(), "base": base, "device_id": device, "summary": {"passed": passed, "failed": failed, "gated": gated, "total": len(rows)}, "results": rows, } path = out_dir / f"private_domain_acceptance_{stamp}.json" path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") print(f"\n私域能力验收: passed={passed} failed={failed} gated={gated} -> {path}") return 0 if failed == 0 else 1 if __name__ == "__main__": raise SystemExit(main())