264 lines
12 KiB
Python
Executable File
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())
|