Files
workphone-sdk/sdk/scripts/run_wechat_private_domain_acceptance.py

264 lines
12 KiB
Python
Executable File

#!/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())