feat: add service config templates and extraction script

Former-commit-id: 1de24b7eb79676d1aba9d799a58c5a753290cf52
This commit is contained in:
反编译工作区
2026-05-01 19:38:01 +08:00
parent 3175b7074b
commit 8b15445328
2433 changed files with 8322164 additions and 1604 deletions
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
合并 config/test_matrix.json 与 MySQL,生成 data/catalog_snapshot.json。
员工:每个部门(case.org_id)最多导出 employees_per_department 人(direct org;不足则父组织补足)。
访客:「访客」标签池;对每个员工槽位全局轮询分配 visitor_for_api。
环境变量:MYSQL_HOST MYSQL_PORT MYSQL_USER MYSQL_PASSWORD
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
_ROOT = Path(__file__).resolve().parents[1]
_CFG = _ROOT / "config" / "test_matrix.json"
_OUT = _ROOT / "data" / "catalog_snapshot.json"
def _conn_params():
return dict(
host=os.environ.get("MYSQL_HOST", "127.0.0.1").strip(),
port=int(os.environ.get("MYSQL_PORT", "3306")),
user=os.environ.get("MYSQL_USER", "root").strip(),
password=os.environ.get("MYSQL_PASSWORD", "").strip(),
charset="utf8mb4",
)
def _print_mysql_unavailable(kw: dict, err: Exception) -> None:
print(
"MySQL 无法连接,export_catalog 中止。\n"
f" 错误: {err}\n"
f" 当前: MYSQL_HOST={kw.get('host')!r} MYSQL_PORT={kw.get('port')!r} "
f"MYSQL_USER={kw.get('user')!r}\n"
" 处理: 1) 确认本机/远程库已启动且网络可达 2) 在 .env.visitor_verify 中设置正确的 "
"MYSQL_HOST、MYSQL_PORT、MYSQL_PASSWORD\n"
" 或: 使用已有 data/catalog_snapshot.json 跳过导出 —\n"
" ./run_v2_visitor_default_floor_verify.sh --skip-export",
file=sys.stderr,
)
def _fetch_person_name(cur, person_id: str) -> str | None:
cur.execute(
"SELECT NAME FROM cw_is_person WHERE ID = %s LIMIT 1",
(person_id,),
)
row = cur.fetchone()
return str(row[0]) if row and row[0] is not None else None
def _fetch_visitor_pool(cur, business_id: str, label_id: str, limit: int) -> list[dict[str, str]]:
cur.execute(
"""
SELECT p.ID, p.NAME
FROM cw_is_person p
INNER JOIN cw_is_person_label_ref lr ON lr.PERSON_ID = p.ID
WHERE lr.LABEL_ID = %s
AND p.BUSINESS_ID = %s
AND (p.IS_DEL = 0 OR p.IS_DEL IS NULL)
ORDER BY p.LAST_UPDATE_TIME DESC
LIMIT %s
""",
(label_id, business_id, limit),
)
out: list[dict[str, str]] = []
for row in cur.fetchall():
out.append({"person_id": str(row[0]), "person_name": str(row[1] or "")})
return out
def _pick_hosts(cur, org_id: str, limit: int) -> tuple[list[dict[str, str]], str]:
"""返回 [{person_id, person_name}, ...] 与 resolution 标记。"""
cur.execute(
"""
SELECT p.ID, p.NAME
FROM cw_is_person p
INNER JOIN cw_is_person_organization_ref r ON r.PERSON_ID = p.ID
WHERE r.ORG_ID = %s AND (p.IS_DEL = 0 OR p.IS_DEL IS NULL)
ORDER BY p.NAME ASC
LIMIT %s
""",
(org_id, limit),
)
rows = [{"person_id": str(a), "person_name": str(b or "")} for a, b in cur.fetchall()]
if rows:
return rows, "direct_org"
cur.execute(
"SELECT PARENT_ID FROM cw_is_organization WHERE ID = %s LIMIT 1",
(org_id,),
)
prow = cur.fetchone()
if not prow or not prow[0]:
return [], "none"
pid = prow[0]
cur.execute(
"""
SELECT p.ID, p.NAME
FROM cw_is_person p
INNER JOIN cw_is_person_organization_ref r ON r.PERSON_ID = p.ID
WHERE r.ORG_ID = %s AND (p.IS_DEL = 0 OR p.IS_DEL IS NULL)
ORDER BY p.NAME ASC
LIMIT %s
""",
(pid, limit),
)
rows2 = [{"person_id": str(a), "person_name": str(b or "")} for a, b in cur.fetchall()]
if rows2:
return rows2, "parent_org_fallback"
return [], "none"
def main() -> int:
try:
import pymysql
from pymysql.err import OperationalError
except ImportError:
print("需要: pip install pymysql", file=sys.stderr)
return 2
if not _CFG.is_file():
print(f"缺少 {_CFG}", file=sys.stderr)
return 2
matrix: dict[str, Any] = json.loads(_CFG.read_text(encoding="utf-8"))
org_db = os.environ.get("MYSQL_DB_ORG", "component-organization")
el_db = os.environ.get("MYSQL_DB_ELEVATOR", "cw-elevator-application")
es = matrix.get("export_settings") or {}
emp_limit = int(es.get("employees_per_department") or 10)
pool_cap = int(es.get("visitor_pool_limit") or 120)
vr = matrix.get("visitor_resolution") or {}
label_id = str(vr.get("label_id") or "ed2dbab6d6234a7287106b80857c819e")
pool_limit = max(int(vr.get("pool_limit") or 40), pool_cap, emp_limit * 20)
kw = _conn_params()
snapshot: dict[str, Any] = {
"version": 3,
"exported_at": datetime.now(timezone.utc).isoformat(),
"matrix_version": matrix.get("version"),
"export_settings": {**es, "employees_per_department_resolved": emp_limit},
"terminology": matrix.get("terminology"),
"api_field_mapping": {
"personId": "被访人(员工)人员主键,用于拉取 floorList",
"visitorId": "访客人员主键,用于写 image_rule_ref.personId 与图库绑定",
},
"visitor_resolution": {**vr, "label_id_resolved": label_id, "pool_limit_resolved": pool_limit},
"sources": {
"organization_db": org_db,
"elevator_db": el_db,
"zone_names_from": "image_rule_ref.zone_id / zone_name",
},
"tenant_visitor_floor_policy": [],
"zone_name_by_id": {},
"suites": [],
}
global_slot = 0
try:
corg = pymysql.connect(database=org_db, **kw)
except OperationalError as e:
_print_mysql_unavailable(kw, e)
return 2
with corg:
with corg.cursor() as cur:
business_pools: dict[str, list[dict[str, str]]] = {}
for suite in matrix["suites"]:
bid = str(suite["business_id"])
if bid not in business_pools:
business_pools[bid] = _fetch_visitor_pool(cur, bid, label_id, pool_limit)
for suite in matrix["suites"]:
bid = str(suite["business_id"])
pool = business_pools.get(bid) or []
out_cases = []
for case in suite["cases"]:
oid = case["org_id"]
hosts_raw, how = _pick_hosts(cur, oid, emp_limit)
vis_override = case.get("visitor_person_id")
host_employees: list[dict[str, Any]] = []
for hem in hosts_raw:
he_copy = dict(hem)
if vis_override:
vid = str(vis_override)
vname = _fetch_person_name(cur, vid) or ""
vf = {
"person_id": vid,
"person_name": vname,
"assignment": "matrix_override_dept_wide",
}
elif pool:
p = pool[global_slot % len(pool)]
global_slot += 1
vf = {
"person_id": p["person_id"],
"person_name": p["person_name"],
"assignment": "round_robin_global_slot",
}
else:
vf = {
"person_id": None,
"person_name": None,
"assignment": "none",
"reason": "no_visitor_label_pool_for_business",
}
he_copy["visitor_for_api"] = vf
host_employees.append(he_copy)
first = host_employees[0] if host_employees else {}
first_vis = first.get("visitor_for_api") if first else {}
first_host_id = first.get("person_id") if first else None
row_obj = {
**case,
"host_employees": host_employees,
"employee_count": len(host_employees),
"employee_target": emp_limit,
"host_resolution": how,
"host_employee": {
"person_id": first.get("person_id"),
"person_name": first.get("person_name"),
"org_id": oid,
"org_name": case.get("org_name"),
"resolution": how,
},
"visitor_for_api": first_vis,
"sample_host_person_id": first_host_id,
"host_resolved": bool(first_host_id),
}
out_cases.append(row_obj)
snapshot["suites"].append(
{
"id": suite["id"],
"name": suite["name"],
"business_id": bid,
"visitor_pool_size": len(pool),
"cases": out_cases,
}
)
try:
cel_pol = pymysql.connect(database=el_db, **kw)
except OperationalError as e:
_print_mysql_unavailable(kw, e)
return 2
with cel_pol:
with cel_pol.cursor() as cur:
cur.execute(
"""
SELECT business_id, policy_type, allow_zone_ids, enabled, building_id, remark
FROM tenant_visitor_floor_policy
WHERE enabled = 1
"""
)
cols = [d[0] for d in cur.description]
for row in cur.fetchall():
snapshot["tenant_visitor_floor_policy"].append(dict(zip(cols, row)))
allow_ids: set[str] = set()
for pol in snapshot["tenant_visitor_floor_policy"]:
raw = pol.get("allow_zone_ids") or ""
if not raw.strip():
continue
try:
arr = json.loads(raw)
if isinstance(arr, list):
allow_ids.update(str(x) for x in arr if x is not None)
except json.JSONDecodeError:
pass
if allow_ids:
try:
cel_zone = pymysql.connect(database=el_db, **kw)
except OperationalError as e:
_print_mysql_unavailable(kw, e)
return 2
with cel_zone:
with cel_zone.cursor() as cur:
ph = ",".join(["%s"] * len(allow_ids))
cur.execute(
f"""
SELECT DISTINCT zone_id, zone_name FROM image_rule_ref
WHERE zone_id IN ({ph}) LIMIT 50
""",
tuple(allow_ids),
)
for zid, zname in cur.fetchall():
snapshot["zone_name_by_id"][str(zid)] = zname
_OUT.parent.mkdir(parents=True, exist_ok=True)
_OUT.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"已写入 {_OUT}(每部门最多 {emp_limit} 名员工,全局访客槽位 {global_slot}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
访客楼层策略生产快测脚本。
模式:
- auth:标准鉴权调用(正式验收)
- noauth-probe:无鉴权探测(安全审计,不作为业务通过依据)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict
import requests
ROOT = Path(__file__).resolve().parents[1]
PARITY_ROOT = ROOT.parent / "elevator_api_parity"
if str(PARITY_ROOT) not in sys.path:
sys.path.insert(0, str(PARITY_ROOT))
from parity.client import default_headers # noqa: E402
REPORT_DIR = ROOT / "report"
OK_CODES = {"0", "200"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="访客楼层策略生产快测脚本")
parser.add_argument("--mode", choices=["auth", "noauth-probe"], default="auth")
parser.add_argument("--org-base-url", required=True)
parser.add_argument("--elevator-base-url", required=True)
parser.add_argument(
"--meng-person-id",
default="964454497399468032",
help="被访人 personId(仅接口调用,不访问数据库,默认蒙海文)",
)
parser.add_argument("--visitor-person-id", required=True)
parser.add_argument("--business-id", default="2524639890ba4f2cba9ba1a4eeaa4015")
parser.add_argument("--window-hours", type=int, default=24)
parser.add_argument("--timeout-seconds", type=int, default=30)
parser.add_argument("--strict-name-check", action="store_true")
parser.add_argument("--host-name", default="蒙海文", help="被访人姓名(仅用于报告展示)")
parser.add_argument("--print-visitor-sql-only", action="store_true", help="仅打印黄平手工查询 SQL 后退出")
parser.add_argument(
"--probe-with-businessid",
action="store_true",
help="仅 noauth-probe 模式下生效,是否保留 businessid 头",
)
return parser.parse_args()
def resolve_meng_person_id(args: argparse.Namespace) -> str:
return args.meng_person_id
def visitor_manual_sql(args: argparse.Namespace) -> str:
return (
"SELECT person_id, name, mobile, business_id, deleted, create_time, update_time\n"
"FROM `component-organization`.`cw_is_person`\n"
f"WHERE business_id = '{args.business_id}'\n"
" AND name = '黄平'\n"
" AND mobile = '13926442944'\n"
"ORDER BY update_time DESC;"
)
def build_headers(args: argparse.Namespace) -> Dict[str, str]:
if args.mode == "auth":
os.environ["ELEVATOR_HEADER_BUSINESSID"] = args.business_id
headers = default_headers()
headers["Content-Type"] = "application/json"
if "businessid" not in headers:
headers["businessid"] = args.business_id
return headers
headers = {"Content-Type": "application/json"}
if args.probe_with_businessid:
headers["businessid"] = args.business_id
return headers
def business_code(resp_json: Dict[str, Any] | None) -> str:
if not isinstance(resp_json, dict):
return ""
value = resp_json.get("code")
return "" if value is None else str(value)
def is_business_ok(resp_json: Dict[str, Any] | None) -> bool:
if not isinstance(resp_json, dict):
return False
code = business_code(resp_json)
if code in OK_CODES:
return True
return resp_json.get("success") is True
def call_step(
session: requests.Session,
url: str,
headers: Dict[str, str],
payload: Dict[str, Any],
timeout_seconds: int,
) -> Dict[str, Any]:
response = session.post(url, json=payload, headers=headers, timeout=timeout_seconds)
text = response.text or ""
try:
body = response.json()
except Exception:
body = None
return {
"url": url,
"http_status": response.status_code,
"request": payload,
"response_text_head": text[:1200],
"response_json": body,
"business_code": business_code(body),
"business_ok": is_business_ok(body),
}
def ts_range_ms(hours: int) -> tuple[int, int]:
now = datetime.now(timezone.utc)
return int(now.timestamp() * 1000), int((now + timedelta(hours=hours)).timestamp() * 1000)
def classify(args: argparse.Namespace, steps: Dict[str, Dict[str, Any]]) -> tuple[str, str]:
detail = steps["person_detail"]
add = steps["add_visitor"]
image = steps["passrule_image"]
if args.mode == "noauth-probe":
statuses = [detail["http_status"], add["http_status"], image["http_status"]]
if any(s in (401, 403) for s in statuses):
return "expected_block", "无鉴权请求被拦截(401/403),符合安全预期"
if add["http_status"] < 300 and add["business_ok"]:
return "high_risk", "noauth-probe 下 add/visitor 业务成功,存在安全放开风险"
return "needs_review", "noauth-probe 返回非拦截但未成功,需要人工复核网关策略"
if detail["http_status"] != 200 or add["http_status"] != 200 or image["http_status"] != 200:
return "failed", "auth 模式存在 HTTP 异常"
add_code = add["business_code"]
if add["business_ok"] or add_code == "76260532":
return "passed", "auth 模式调用链路正常(含交集为空预期失败码)"
return "failed", "auth 模式业务码异常"
def main() -> int:
args = parse_args()
if args.print_visitor_sql_only:
print(visitor_manual_sql(args))
return 0
effective_meng_person_id = resolve_meng_person_id(args)
headers = build_headers(args)
session = requests.Session()
started_at = datetime.now(timezone.utc).isoformat()
begin_ms, end_ms = ts_range_ms(args.window_hours)
detail_payload = {"id": effective_meng_person_id, "businessId": args.business_id}
add_payload = {
"visitorId": args.visitor_person_id,
"personId": effective_meng_person_id,
"begVisitorTime": begin_ms,
"endVisitorTime": end_ms,
"floorIds": [],
}
image_payload = {
"personId": args.visitor_person_id,
"businessId": args.business_id,
"imageStoreIds": [],
"includeOrganizations": [],
"includeLabels": [],
}
steps = {
"person_detail": call_step(
session,
args.org_base_url.rstrip("/") + "/component/person/detail",
headers,
detail_payload,
args.timeout_seconds,
),
"add_visitor": call_step(
session,
args.elevator_base_url.rstrip("/") + "/elevator/person/add/visitor",
headers,
add_payload,
args.timeout_seconds,
),
"passrule_image": call_step(
session,
args.elevator_base_url.rstrip("/") + "/elevator/passRule/image",
headers,
image_payload,
args.timeout_seconds,
),
}
detail_data = (steps["person_detail"]["response_json"] or {}).get("data") or {}
detail_name = detail_data.get("name")
detail_floor_list = detail_data.get("floorList")
strict_name_ok = (detail_name == "蒙海文") if args.strict_name_check else True
grade, summary = classify(args, steps)
report = {
"started_at": started_at,
"mode": args.mode,
"grade": grade,
"summary": summary,
"args": {
"org_base_url": args.org_base_url,
"elevator_base_url": args.elevator_base_url,
"business_id": args.business_id,
"meng_person_id": effective_meng_person_id,
"visitor_person_id": args.visitor_person_id,
"window_hours": args.window_hours,
"probe_with_businessid": args.probe_with_businessid,
"host_name": args.host_name,
},
"headers_used": {k: ("***" if k.lower() == "authorization" else v) for k, v in headers.items()},
"derived": {
"detail_name": detail_name,
"detail_floor_count": len(detail_floor_list) if isinstance(detail_floor_list, list) else 0,
"strict_name_ok": strict_name_ok,
"visitor_manual_sql": visitor_manual_sql(args),
},
"steps": steps,
}
REPORT_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
report_file = REPORT_DIR / f"quick-verify-{stamp}.json"
report_file.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print("=== 访客楼层策略快测结果 ===")
print(f"mode: {args.mode}")
print(f"grade: {grade}")
print(f"summary: {summary}")
print(f"report: {report_file}")
print(f"detail_name: {detail_name}")
print(f"detail_floor_count: {report['derived']['detail_floor_count']}")
print(f"add_visitor_status/code: {steps['add_visitor']['http_status']}/{steps['add_visitor']['business_code']}")
print(f"passrule_image_status/code: {steps['passrule_image']['http_status']}/{steps['passrule_image']['business_code']}")
if args.mode == "auth":
return 0 if grade == "passed" and strict_name_ok else 1
return 0 if grade in {"expected_block", "needs_review"} else 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,598 @@
#!/usr/bin/env python3
"""
访客楼层验证套件执行器。
--phase report 仅校验 catalog_snapshot.json(套件完整性、策略行、主机缺口)
--phase all 先 report 再 live,合并为一份 Markdown(一键 V2 验收推荐)
--phase live 访客注册验收(默认):
1) POST /elevator/person/add/visitorfloorIds 为空,由服务端按被访人 floorList
与 tenant_visitor_floor_policy 求交后落库)
2) POST /elevator/passRule/imagepersonId=visitorId)回读该访客已生效的 zone 列表
说明:add/visitor 的 HTTP 契约返回 CloudwalkResult<Boolean>,响应体不含楼层明细;要对「允许访问楼层」
做断言,必须通过注册写入后的规则回读(与本仓库 elevator_api_parity/scripts/verify_gf_visitor_default_floor.py
一致)。若仅需连通性/业务码冒烟,可加 --register-only 跳过回读。
联机需 HTTP 头 businessId 与当前验收租户一致;可从环境变量、--suite / --tenant 或矩阵 tenant_primary_business_id 自动填充(见 resolve_header_business_id)。
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
_ROOT = Path(__file__).resolve().parents[1]
_PARITY = _ROOT.parent / "elevator_api_parity"
if str(_PARITY) not in sys.path:
sys.path.insert(0, str(_PARITY))
from parity.client import default_headers # noqa: E402
REPORT_DIR = _ROOT / "report"
SNAP_DEFAULT = _ROOT / "data" / "catalog_snapshot.json"
MATRIX_DEFAULT = _ROOT / "config" / "test_matrix.json"
CODE_OK = {"0", "200"}
def resolve_header_business_id(suite_id: str, tenant_key: str) -> None:
"""写入 os.environ['ELEVATOR_HEADER_BUSINESSID']。
优先级:suite_id(含 CLI --suite 或 env VISITOR_SUITE_ID> tenant_key--tenant>
已有 ELEVATOR_HEADER_BUSINESSID > test_matrix tenant_primary_business_id。
"""
if suite_id and tenant_key:
print("错误: 不要同时使用 --suite(或 VISITOR_SUITE_ID)与 --tenant", file=sys.stderr)
raise SystemExit(2)
if not MATRIX_DEFAULT.is_file():
if suite_id or tenant_key:
print(f"缺少矩阵文件: {MATRIX_DEFAULT}", file=sys.stderr)
raise SystemExit(2)
return
matrix = json.loads(MATRIX_DEFAULT.read_text(encoding="utf-8"))
if suite_id:
for s in matrix.get("suites") or []:
if s.get("id") == suite_id:
bid = str(s.get("business_id") or "").strip()
if not bid:
print(f"套件 {suite_id!r} 未配置 business_id", file=sys.stderr)
raise SystemExit(2)
os.environ["ELEVATOR_HEADER_BUSINESSID"] = bid
print(
f"[visitor_floor_suite] ELEVATOR_HEADER_BUSINESSID={bid}(套件 id={suite_id}",
file=sys.stderr,
)
return
print(f"test_matrix.json 中无 suites[].id == {suite_id!r}", file=sys.stderr)
raise SystemExit(2)
if tenant_key:
if tenant_key == "primary":
bid = str(matrix.get("tenant_primary_business_id") or "").strip()
label = "tenant_primary_business_id"
elif tenant_key == "secondary":
bid = str(matrix.get("tenant_secondary_business_id") or "").strip()
label = "tenant_secondary_business_id"
else:
print(f"--tenant 仅支持 primary / secondary,收到 {tenant_key!r}", file=sys.stderr)
raise SystemExit(2)
if not bid:
print(f"test_matrix.json 缺少 {label}", file=sys.stderr)
raise SystemExit(2)
os.environ["ELEVATOR_HEADER_BUSINESSID"] = bid
print(f"[visitor_floor_suite] ELEVATOR_HEADER_BUSINESSID={bid}--tenant {tenant_key}", file=sys.stderr)
return
if os.environ.get("ELEVATOR_HEADER_BUSINESSID", "").strip():
return
bid = str(matrix.get("tenant_primary_business_id") or "").strip()
if bid:
os.environ["ELEVATOR_HEADER_BUSINESSID"] = bid
print(
f"[visitor_floor_suite] 未设置 ELEVATOR_HEADER_BUSINESSID,已自动使用 test_matrix.json "
f"tenant_primary_business_id={bid}",
file=sys.stderr,
)
def _ts_ms_range(window_days: int) -> tuple[int, int]:
now = datetime.now(timezone.utc)
start = int(now.timestamp() * 1000)
end = int((now + timedelta(days=window_days)).timestamp() * 1000)
return start, end
def _preflight_base_url(session: requests.Session, base_url: str) -> tuple[bool, str]:
"""联机前探测 BASE_URL 是否可达(避免 Connection refused 时刷屏数百条相同错误)。"""
root = base_url.rstrip("/") + "/"
try:
session.get(root, timeout=5, allow_redirects=True)
return True, ""
except requests.exceptions.ConnectionError:
return (
False,
"无法建立到该地址的 TCP 连接(常见错误:**Connection refused**)。\n"
"1) 在运行联机用例的机器上**启动** V2 电梯应用(如 `cw-elevator-application-starter`),并确认日志中的 **监听端口**。\n"
"2) 将 **ELEVATOR_VERIFY_BASE** 或 **`--base-url`** 改为实际 `http://主机:端口`(默认 18081 仅作示例)。\n"
"3) 设置 **VISITOR_SKIP_LIVE_PREFLIGHT=1** 可跳过本预检(不建议;仅无服务时的调试)。",
)
except requests.exceptions.SSLError as e:
return False, f"TLS/HTTPS 错误: {e}"
except requests.exceptions.Timeout:
return False, "连接 BASE_URL 超时(对端无响应或网络不通)。"
except requests.RequestException as e:
return False, f"预检异常: {e}"
def _post_json(
session: requests.Session, base: str, path: str, body: dict
) -> tuple[int, dict | None, str]:
url = base.rstrip("/") + path
h = {**default_headers(), **dict(session.headers)}
r = session.post(
url,
headers=h,
data=json.dumps(body, ensure_ascii=False),
timeout=120,
)
txt = r.text or ""
try:
j = json.loads(txt) if txt.strip() else None
except json.JSONDecodeError:
j = None
return r.status_code, j if isinstance(j, dict) else None, txt
def _business_code(js: dict | None) -> str | None:
if js is None or "code" not in js:
return None
return str(js.get("code"))
def allow_zone_ids_from_snapshot(snapshot: dict, business_id: str) -> list[str]:
for pol in snapshot.get("tenant_visitor_floor_policy") or []:
if str(pol.get("business_id")) != str(business_id):
continue
raw = pol.get("allow_zone_ids") or ""
try:
arr = json.loads(raw)
if isinstance(arr, list):
return [str(x) for x in arr if x is not None]
except json.JSONDecodeError:
return []
return []
def phase_report(snapshot_path: Path) -> tuple[list[str], int]:
lines: list[str] = []
snap = json.loads(snapshot_path.read_text(encoding="utf-8"))
lines.append("# catalog_snapshot 校验报告")
lines.append("")
lines.append(f"- 导出时间: {snap.get('exported_at')}snapshot version={snap.get('version')}")
es = snap.get("export_settings") or {}
target = int(es.get("employees_per_department_resolved") or es.get("employees_per_department") or 10)
lines.append(f"- 每部门目标员工数: **{target}**(见 export_settings")
lines.append("")
exit_code = 0
pol = snap.get("tenant_visitor_floor_policy") or []
lines.append(f"- 启用策略行数: **{len(pol)}**")
if not pol:
lines.append("- **警告**: 未配置租户策略,求交逻辑将退化为「仅 floorList」。")
exit_code = 1
for suite in snap.get("suites") or []:
sid = suite.get("id")
lines.append("")
lines.append(f"## {sid}{suite.get('name')}")
cases = suite.get("cases") or []
tot_emp = 0
zero_employee_orgs: list[str] = []
partial_employee_orgs: list[str] = []
no_vis_orgs: list[str] = []
for c in cases:
emps = c.get("host_employees") or []
n = len(emps)
tot_emp += n
if n == 0:
zero_employee_orgs.append(c.get("org_name") or "(未命名部门)")
elif n < target:
partial_employee_orgs.append(f"{c.get('org_name')}({n}/{target})")
ok_vis = True
for hem in emps:
vf = hem.get("visitor_for_api") or {}
if not vf.get("person_id"):
ok_vis = False
break
if emps and not ok_vis:
no_vis_orgs.append(c.get("org_name") or "")
lines.append(
f"- 部门用例数: {len(cases)}**员工槽位合计**: {tot_emp}visitor_pool_size={suite.get('visitor_pool_size', 'n/a')}"
)
if partial_employee_orgs:
lines.append(
f"- **提示**(人数少于目标 {target},联机时对**现有全部员工**跑用例,直至 `--max-employees-per-department` 上限): "
+ ", ".join(partial_employee_orgs[:12])
)
if len(partial_employee_orgs) > 12:
lines.append("")
if zero_employee_orgs:
lines.append(f"- **无员工可测**: " + ", ".join(zero_employee_orgs[:12]))
if len(zero_employee_orgs) > 12:
lines.append("")
exit_code = 1
if no_vis_orgs:
lines.append(f"- **存在员工但访客未解析**: " + ", ".join(no_vis_orgs[:8]))
exit_code = 1
return lines, exit_code
def phase_live(
snapshot_path: Path,
base_url: str,
visitor_person_id: str,
window_days: int,
zone_name_regex: str,
skip_secondary_mismatch: bool,
max_employees_per_department: int,
register_only: bool,
) -> tuple[list[str], int]:
lines: list[str] = []
snap = json.loads(snapshot_path.read_text(encoding="utf-8"))
header_biz = os.environ.get("ELEVATOR_HEADER_BUSINESSID", "").strip()
if not header_biz:
return [
"错误: 未设置 **ELEVATOR_HEADER_BUSINESSID**HTTP 头 `businessid`,与目标租户一致)。",
"请在运行环境中 export,或在 `tools/visitor_floor_verification/.env.visitor_verify` 中填写该变量后重新执行一键脚本。",
], 2
use_global_visitor = bool(visitor_person_id.strip())
beg, end = _ts_ms_range(window_days)
session = requests.Session()
session.headers.update(default_headers())
lines.append("# 访客楼层联机验收报告")
lines.append("")
lines.append(f"- BASE_URL: `{base_url}`")
lines.append(f"- Header businessId: `{header_biz}`")
if register_only:
lines.append("- **模式**: `register-only` — 仅调用 `/elevator/person/add/visitor`**不**回读楼层明细")
else:
lines.append(
"- **模式**: 注册 + `passRule/image` 回读(add/visitor 响应无楼层字段,回读用于校验策略落地)"
)
if use_global_visitor:
lines.append(f"- **全局固定访客** visitorId: `{visitor_person_id}`(覆盖每名员工上的 visitor_for_api")
else:
lines.append("- 访客:使用 **host_employees[].visitor_for_api**(导出时全局轮询)")
lines.append(f"- 每部门最多执行员工数: **{max_employees_per_department}**`--max-employees-per-department`")
lines.append(f"- 访客有效期(ms): {beg} ~ {end} (窗口 {window_days} 天)")
lines.append("")
try:
cre = re.compile(zone_name_regex)
except re.error as e:
return [f"zone-name-regex 无效: {e}"], 2
fail_fast_76260521 = os.environ.get("VISITOR_FAIL_FAST_ON_76260521", "1").strip().lower() not in (
"0",
"false",
"no",
)
streak_76260521 = 0
skip_pf = os.environ.get("VISITOR_SKIP_LIVE_PREFLIGHT", "").strip().lower() in (
"1",
"true",
"yes",
)
if not skip_pf:
ok_pf, err_pf = _preflight_base_url(session, base_url)
if not ok_pf:
lines.append("### 联机前置检查失败")
lines.append("")
for block in err_pf.split("\n"):
t = block.strip()
if t:
lines.append(f"- {t}")
lines.append("")
return lines, 1
exit_code = 0
for suite in snap.get("suites") or []:
sbiz = str(suite.get("business_id") or "")
if sbiz != header_biz:
if skip_secondary_mismatch or suite.get("id") == "tenant_secondary_placeholder":
lines.append(f"## {suite.get('id')} — SKIPbusiness_id `{sbiz}` ≠ 当前 Header")
lines.append("")
continue
allow_ids = allow_zone_ids_from_snapshot(snap, sbiz)
lines.append(f"## {suite.get('id')}{suite.get('name')}")
lines.append(f"- allow_zone_ids: `{allow_ids}`")
lines.append("")
for case in suite.get("cases") or []:
dept_label = f"{case.get('org_name')} | org={case.get('org_id')}"
employees = case.get("host_employees") or []
if not employees:
he1 = case.get("host_employee") or {}
if he1.get("person_id"):
employees = [
{
"person_id": he1.get("person_id"),
"person_name": he1.get("person_name"),
"visitor_for_api": case.get("visitor_for_api") or {},
}
]
if not employees:
lines.append(f"### {dept_label}")
lines.append("- SKIP:无员工(host_employees 空)")
lines.append("")
exit_code = 1
continue
for ei, hem in enumerate(employees[:max_employees_per_department]):
host = hem.get("person_id")
hname = hem.get("person_name") or ""
vf = hem.get("visitor_for_api") or {}
vis = (visitor_person_id.strip() if use_global_visitor else None) or vf.get("person_id")
vname = vf.get("person_name") or ""
sub = f"{dept_label} — 员工[{ei + 1}]"
if not host:
lines.append(f"#### {sub}")
lines.append("- SKIP:无 person_id")
lines.append("")
exit_code = 1
continue
if not vis:
lines.append(f"#### {sub}")
lines.append("- SKIP:无访客(访客池或 `--visitor-person-id`")
lines.append("")
exit_code = 1
continue
add_body = {
"visitorId": vis,
"personId": host,
"begVisitorTime": beg,
"endVisitorTime": end,
"floorIds": [],
}
try:
st, js, raw = _post_json(session, base_url, "/elevator/person/add/visitor", add_body)
except requests.RequestException as e:
lines.append(f"#### {sub}")
lines.append(f"- **HTTP 异常**: `{e}`")
lines.append("")
exit_code = 1
continue
code = _business_code(js)
lines.append(f"#### {sub}")
lines.append(
f"- **被访人** personId=`{host}` ({hname})**访客** visitorId=`{vis}` ({vname})"
)
lines.append(f"- add/visitor: http={st} code={code}")
if (
fail_fast_76260521
and st == 200
and code == "76260521"
and isinstance(raw, str)
and "\"data\":null" in raw
):
streak_76260521 += 1
else:
streak_76260521 = 0
if st != 200 or (code is not None and code not in CODE_OK):
lines.append(f"- 响应摘要: `{raw[:400]}` …")
if code == "76260532":
lines.append("- **判定**: 交集为空 — ACCEPTABLE_FAIL。")
else:
exit_code = 1
if streak_76260521 >= 3:
lines.append(
"- **快速失败**: 已连续 3 次命中 `76260521`(空 message/null data)。"
)
lines.append(
"- **疑似原因**: Cloudwalk 调用上下文缺失(常见于 `authorization/loginid/platformuserid/applicationid` 头缺失或无效、token 过期)。"
)
lines.append(
"- **建议**: 用与前端同源的完整请求头重试;先用 `--smoke`(每部门 1 人)确认首条能成功后再全量。可设 `VISITOR_FAIL_FAST_ON_76260521=0` 关闭快速失败。"
)
lines.append("")
return lines, 1
lines.append("")
continue
st2, js2, raw2 = _post_json(
session,
base_url,
"/elevator/passRule/image",
{"personId": vis, "businessId": header_biz},
)
code2 = _business_code(js2)
lines.append(f"- passRule/image: http={st2} code={code2}")
data = js2.get("data") if isinstance(js2, dict) else None
rows = [x for x in (data or []) if isinstance(x, dict)]
if st2 != 200 or (code2 is not None and code2 not in CODE_OK):
lines.append(f"- **失败** body: `{raw2[:500]}`")
exit_code = 1
lines.append("")
continue
bad_names = []
bad_ids = []
for z in rows:
zid = str(z.get("zoneId") or "")
zn = str(z.get("zoneName") or "")
if allow_ids and zid and zid not in allow_ids:
bad_ids.append(zid)
if zn and not cre.search(zn):
bad_names.append(zn)
lines.append(f"- 回读楼层数: **{len(rows)}**")
for z in rows:
lines.append(f" - zoneId=`{z.get('zoneId')}` zoneName=`{z.get('zoneName')}`")
if allow_ids and bad_ids:
lines.append(f"- **FAIL**: zoneId 不在 allowlist: {bad_ids}")
exit_code = 1
elif bad_names:
lines.append(f"- **FAIL**: zoneName 未匹配 `{zone_name_regex}`: {bad_names}")
exit_code = 1
else:
lines.append("- **PASS**")
lines.append("")
return lines, exit_code
def phase_all(
snapshot_path: Path,
base_url: str,
visitor_person_id: str,
window_days: int,
zone_name_regex: str,
skip_secondary_mismatch: bool,
max_employees_per_department: int,
register_only: bool,
) -> tuple[list[str], int]:
lines_r, code_r = phase_report(snapshot_path)
lines_l, code_l = phase_live(
snapshot_path,
base_url,
visitor_person_id,
window_days,
zone_name_regex,
skip_secondary_mismatch,
max_employees_per_department,
register_only,
)
lines: list[str] = [
"# V2 访客注册默认楼层 — 完整验证报告(catalog 静态 + 联机)",
"",
f"- 快照: `{snapshot_path}`",
f"- BASE_URL: `{base_url}`",
"",
"## 第一部分:catalog_snapshot 静态校验",
"",
*lines_r,
"",
"---",
"",
"## 第二部分:联机验收(Maven V2 电梯应用)",
"",
*lines_l,
]
return lines, max(code_r, code_l)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--phase", choices=("report", "live", "all"), default="report")
ap.add_argument("--snapshot", type=Path, default=SNAP_DEFAULT)
ap.add_argument("--base-url", default=os.environ.get("ELEVATOR_VERIFY_BASE", "http://127.0.0.1:18081"))
ap.add_argument(
"--visitor-person-id",
default=os.environ.get("VISITOR_TEST_PERSON_ID", "").strip(),
help="若设置则所有用例固定使用该 visitorId;否则用快照中 visitor_for_api 逐条配对",
)
ap.add_argument("--window-days", type=int, default=int(os.environ.get("VISITOR_WINDOW_DAYS", "1")))
ap.add_argument(
"--zone-name-regex",
default=os.environ.get("VISITOR_ZONE_NAME_REGEX", r".*28.*"),
help="生效楼层 zoneName 验收(默认匹配含 28 的展示名)",
)
ap.add_argument(
"--skip-suite-on-business-mismatch",
action="store_true",
default=True,
help="Header businessId 与套件不一致时跳过(默认开启)",
)
ap.add_argument(
"--max-employees-per-department",
type=int,
default=int(os.environ.get("VISITOR_MAX_EMPLOYEES_PER_DEPT", "10")),
help="每部门最多跑多少名员工(联机;默认 10,冒烟可设 1)",
)
ap.add_argument(
"--register-only",
action="store_true",
help="仅 POST /elevator/person/add/visitor;不回读 passRule/image(无法校验楼层明细)",
)
mx = ap.add_mutually_exclusive_group()
mx.add_argument(
"--suite",
default="",
metavar="SUITE_ID",
help="使用 test_matrix.json 中该 suites[].id 的 business_id 作为 businessid 头;也可设环境变量 VISITOR_SUITE_ID",
)
mx.add_argument(
"--tenant",
choices=("primary", "secondary"),
default=None,
help="使用矩阵顶层 tenant_primary_business_id / tenant_secondary_business_id",
)
args = ap.parse_args()
suite_arg = (args.suite or "").strip() or os.environ.get("VISITOR_SUITE_ID", "").strip()
tenant_arg = (args.tenant or "").strip().lower() if args.tenant else ""
resolve_header_business_id(suite_arg, tenant_arg)
if not args.snapshot.is_file():
print(f"缺少快照: {args.snapshot} — 请先运行 export_catalog.py", file=sys.stderr)
return 2
if args.phase == "report":
lines, code = phase_report(args.snapshot)
elif args.phase == "all":
lines, code = phase_all(
args.snapshot,
args.base_url,
str(args.visitor_person_id or ""),
args.window_days,
args.zone_name_regex,
args.skip_suite_on_business_mismatch,
max(1, args.max_employees_per_department),
bool(args.register_only),
)
else:
lines, code = phase_live(
args.snapshot,
args.base_url,
str(args.visitor_person_id or ""),
args.window_days,
args.zone_name_regex,
args.skip_suite_on_business_mismatch,
max(1, args.max_employees_per_department),
bool(args.register_only),
)
REPORT_DIR.mkdir(parents=True, exist_ok=True)
rep = REPORT_DIR / f"visitor-floor-suite-{datetime.now().strftime('%Y%m%d-%H%M%S')}.md"
rep.write_text("\n".join(lines) + "\n", encoding="utf-8")
print("\n".join(lines))
print(f"\n报告已写入: {rep}")
return code
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
方案B:生产接口三步模拟验证脚本。
流程:
1) /component/person/detail 验证被访员工(蒙海文)关联信息与 floorList
2) /elevator/person/add/visitor 以 floorIds=[] 开通访客
3) /elevator/passRule/image 回读访客最终楼层权限
输出:
- 控制台摘要
- report/gf-visitor-sim-<timestamp>.json(完整执行记录)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict
import requests
ROOT = Path(__file__).resolve().parents[1]
PARITY_ROOT = ROOT.parent / "elevator_api_parity"
if str(PARITY_ROOT) not in sys.path:
sys.path.insert(0, str(PARITY_ROOT))
from parity.client import default_headers # noqa: E402
REPORT_DIR = ROOT / "report"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="广发访客楼层接口三步模拟验证(方案B")
parser.add_argument("--org-base-url", required=True, help="组织服务基地址,例如 http://10.0.22.102:17016")
parser.add_argument("--elevator-base-url", required=True, help="电梯服务基地址,例如 http://10.0.22.207:16112")
parser.add_argument("--meng-person-id", required=True, help="蒙海文的 personId")
parser.add_argument("--visitor-person-id", required=True, help="测试访客 personId")
parser.add_argument("--business-id", default="2524639890ba4f2cba9ba1a4eeaa4015", help="业务租户 businessId")
parser.add_argument("--window-hours", type=int, default=24, help="访客有效期小时数,默认24")
parser.add_argument("--timeout-seconds", type=int, default=30, help="接口超时秒数,默认30")
parser.add_argument(
"--strict-name-check",
action="store_true",
help="开启后要求人员详情返回 name 精确为“蒙海文”,否则判定失败",
)
return parser.parse_args()
def ensure_business_header(business_id: str) -> None:
os.environ["ELEVATOR_HEADER_BUSINESSID"] = business_id
def call_json(
session: requests.Session,
method: str,
url: str,
headers: Dict[str, str],
payload: Dict[str, Any],
timeout_seconds: int,
) -> Dict[str, Any]:
response = session.request(method=method, url=url, headers=headers, json=payload, timeout=timeout_seconds)
text = response.text or ""
record = {
"url": url,
"http_status": response.status_code,
"request": payload,
"response_text_head": text[:1200],
}
try:
record["response_json"] = response.json()
except Exception:
record["response_json"] = None
return record
def business_code(resp_json: Dict[str, Any] | None) -> str:
if not isinstance(resp_json, dict):
return ""
v = resp_json.get("code")
return "" if v is None else str(v)
def bool_success(resp_json: Dict[str, Any] | None) -> bool:
if not isinstance(resp_json, dict):
return False
code = business_code(resp_json)
if code in {"0", "200"}:
return True
success = resp_json.get("success")
return success is True
def ts_range_ms(window_hours: int) -> tuple[int, int]:
now = datetime.now(timezone.utc)
start = int(now.timestamp() * 1000)
end = int((now + timedelta(hours=window_hours)).timestamp() * 1000)
return start, end
def main() -> int:
args = parse_args()
ensure_business_header(args.business_id)
headers = default_headers()
if "businessid" not in headers:
headers["businessid"] = args.business_id
headers["Content-Type"] = "application/json"
session = requests.Session()
started_at = datetime.now(timezone.utc).isoformat()
detail_payload = {"id": args.meng_person_id, "businessId": args.business_id}
add_start_ms, add_end_ms = ts_range_ms(args.window_hours)
add_payload = {
"visitorId": args.visitor_person_id,
"personId": args.meng_person_id,
"begVisitorTime": add_start_ms,
"endVisitorTime": add_end_ms,
"floorIds": [],
}
image_payload = {
"personId": args.visitor_person_id,
"businessId": args.business_id,
"imageStoreIds": [],
"includeOrganizations": [],
"includeLabels": [],
}
detail_url = args.org_base_url.rstrip("/") + "/component/person/detail"
add_url = args.elevator_base_url.rstrip("/") + "/elevator/person/add/visitor"
image_url = args.elevator_base_url.rstrip("/") + "/elevator/passRule/image"
detail_result = call_json(session, "POST", detail_url, headers, detail_payload, args.timeout_seconds)
add_result = call_json(session, "POST", add_url, headers, add_payload, args.timeout_seconds)
image_result = call_json(session, "POST", image_url, headers, image_payload, args.timeout_seconds)
detail_json = detail_result.get("response_json")
add_json = add_result.get("response_json")
image_json = image_result.get("response_json")
detail_data = detail_json.get("data") if isinstance(detail_json, dict) else {}
detail_name = (detail_data or {}).get("name")
detail_floor_list = (detail_data or {}).get("floorList")
checks = {
"detail_http_ok": detail_result["http_status"] == 200,
"detail_business_ok": bool_success(detail_json),
"detail_has_floor_list": isinstance(detail_floor_list, list) and len(detail_floor_list) > 0,
"detail_name_ok": (detail_name == "蒙海文") if args.strict_name_check else True,
"add_http_ok": add_result["http_status"] == 200,
"add_business_ok_or_expected_empty_intersection": bool_success(add_json)
or business_code(add_json) == "76260532",
"image_http_ok": image_result["http_status"] == 200,
"image_business_ok": bool_success(image_json),
"image_has_data": isinstance((image_json or {}).get("data"), list),
}
all_pass = all(checks.values())
report = {
"started_at": started_at,
"args": {
"org_base_url": args.org_base_url,
"elevator_base_url": args.elevator_base_url,
"meng_person_id": args.meng_person_id,
"visitor_person_id": args.visitor_person_id,
"business_id": args.business_id,
"window_hours": args.window_hours,
"strict_name_check": args.strict_name_check,
},
"headers_used": {k: ("***" if k.lower() == "authorization" else v) for k, v in headers.items()},
"steps": {
"person_detail": detail_result,
"add_visitor": add_result,
"passrule_image": image_result,
},
"derived": {
"person_name": detail_name,
"person_floor_list": detail_floor_list,
"add_visitor_code": business_code(add_json),
"passrule_image_code": business_code(image_json),
"passrule_image_data_size": len((image_json or {}).get("data") or []),
},
"checks": checks,
"all_pass": all_pass,
}
REPORT_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
report_file = REPORT_DIR / f"gf-visitor-sim-{stamp}.json"
report_file.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print("=== 方案B模拟验证结果 ===")
print(f"report: {report_file}")
print(f"person_name: {detail_name}")
print(f"person_floor_list_size: {len(detail_floor_list or []) if isinstance(detail_floor_list, list) else 0}")
print(f"add_visitor_code: {business_code(add_json)}")
print(f"passrule_image_code: {business_code(image_json)}")
print(f"passrule_image_data_size: {len((image_json or {}).get('data') or [])}")
print(f"all_pass: {all_pass}")
if not all_pass:
print("某些检查未通过,请查看报告文件中的 checks 与 steps 详情。")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""org_id 策略修复 — 无鉴权验证脚本"""
import argparse
import json
import os
import sys
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
import pymysql
import requests
# ===== 配置常量 =====
DB_CONFIG = {
"host": "192.168.3.12",
"port": 3307,
"user": "root",
"password": "123456",
"db_org": "component-organization",
"db_elevator": "cw-elevator-application",
}
BUSINESS_ID = "2524639890ba4f2cba9ba1a4eeaa4015"
ORG_1403 = "72fb65ec5de94201b909a98b8bae1892"
ORG_1405 = "2095de3d541f44eba686c78fda68336f"
ORG_GUANGFA = "488b8ad049bb43408a6fbcc50bcb89ac"
HOST_CHEN = "1060601019894960128"
HOST_WANG = "1090779433129840640"
HOST_QIN = "1072908835884208128"
VISITOR_IDS = [
"9199000100000000001", "9199000100000000002", "9199000100000000003",
"9199000100000000004", "9199000100000000005", "9199000100000000006",
"9199000100000000007",
]
ZONE_28F = "605560545117995008"
ZONE_99F = "605560540000000000"
OK_CODES = {"0", "200"}
TEST_CASES = [
{"id":"T1","name":"有策略→allow替换floorList","host_id":HOST_CHEN,"visitor_id":VISITOR_IDS[0],"policy_id":"policy_t1_1403","expected_pass":True,"expected_floors":[ZONE_28F]},
{"id":"T2","name":"无策略→floorList","host_id":HOST_WANG,"visitor_id":VISITOR_IDS[1],"policy_id":None,"expected_pass":True,"expected_floors":None},
{"id":"T3","name":"allow含无效zone→拒绝","host_id":HOST_CHEN,"visitor_id":VISITOR_IDS[2],"policy_id":"policy_t3_invalid","expected_pass":False,"expected_code":"76260533"},
{"id":"T4","name":"多组织命中第一个策略","host_id":HOST_CHEN,"visitor_id":VISITOR_IDS[3],"policy_id":"policy_t1_1403","expected_pass":True,"expected_floors":[ZONE_28F]},
{"id":"T5","name":"enabled=0等同无策略","host_id":HOST_CHEN,"visitor_id":VISITOR_IDS[4],"policy_id":"policy_t5_disabled","expected_pass":True,"expected_floors":None},
{"id":"T6","name":"UC-02策略优先","host_id":HOST_CHEN,"visitor_id":VISITOR_IDS[5],"policy_id":"policy_t1_1403","expected_pass":True,"expected_floors":[ZONE_28F],"floor_ids_override":["605560541473144832"]},
{"id":"T7","name":"广发基金迁移验证","host_id":HOST_QIN,"visitor_id":VISITOR_IDS[6],"policy_id":"gf_vstr_policy_guangfa_fund_001x","expected_pass":True,"expected_floors":[ZONE_28F]},
]
def parse_args():
p = argparse.ArgumentParser(description="org_id 策略修复验证")
p.add_argument("--elevator-base-url", default="http://127.0.0.1:18081")
p.add_argument("--skip-db", action="store_true")
return p.parse_args()
def health_check(base_url):
try:
r = requests.get(f"{base_url}/health", timeout=10)
ok = r.status_code == 200
print(f"[HEALTH] {base_url} -> {r.status_code} {'OK' if ok else 'FAIL'}")
return ok
except Exception as e:
print(f"[HEALTH] {base_url} -> ERROR: {e}")
return False
def get_db_conn():
return pymysql.connect(
host=DB_CONFIG["host"], port=DB_CONFIG["port"],
user=DB_CONFIG["user"], password=DB_CONFIG["password"],
database=DB_CONFIG["db_elevator"],
charset="utf8mb4", autocommit=True,
)
def execute_sql(sql, params=None):
conn = get_db_conn()
try:
with conn.cursor() as cur:
cur.execute(sql, params)
finally:
conn.close()
def prepare_test_data():
policies = [
("policy_t1_1403", ORG_1403, f'["{ZONE_28F}"]', 1),
("policy_t3_invalid", ORG_1403, f'["{ZONE_28F}","{ZONE_99F}"]', 1),
("policy_t5_disabled", ORG_1403, f'["{ZONE_28F}"]', 0),
]
for pid, oid, zones_json, enabled in policies:
execute_sql("DELETE FROM tenant_visitor_floor_policy WHERE id=%s", (pid,))
execute_sql(
"INSERT INTO tenant_visitor_floor_policy "
"(id, org_id, business_id, policy_type, allow_zone_ids, building_id, enabled, policy_version, created_at, updated_at) "
"VALUES (%s, %s, NULL, 'INTERSECT_ALLOWLIST', %s, NULL, %s, 1, UNIX_TIMESTAMP(NOW())*1000, UNIX_TIMESTAMP(NOW())*1000)",
(pid, oid, zones_json, enabled),
)
print(f" INSERT policy {pid} org={oid} enabled={enabled}")
execute_sql("UPDATE tenant_visitor_floor_policy SET org_id=%s WHERE id='gf_vstr_policy_guangfa_fund_001x'", (ORG_GUANGFA,))
print(f" UPDATE 广发基金 org_id={ORG_GUANGFA}")
def cleanup_test_data():
for pid in ["policy_t1_1403", "policy_t3_invalid", "policy_t5_disabled"]:
execute_sql("DELETE FROM tenant_visitor_floor_policy WHERE id=%s", (pid,))
print(f" DELETE {pid}")
execute_sql("UPDATE tenant_visitor_floor_policy SET org_id=NULL WHERE id='gf_vstr_policy_guangfa_fund_001x'")
print(" UPDATE 广发基金 org_id=NULL (回滚)")
def build_noauth_headers():
return {"Content-Type": "application/json", "businessid": BUSINESS_ID}
def now_ms():
return int(time.time() * 1000)
def tomorrow_ms():
return int((time.time() + 86400) * 1000)
def call_add_visitor(base_url, person_id, visitor_id, floor_ids=None):
body = {
"personId": person_id, "visitorId": visitor_id,
"floorIds": floor_ids if floor_ids is not None else [],
"begVisitorTime": now_ms(), "endVisitorTime": tomorrow_ms(),
}
try:
r = requests.post(f"{base_url}/elevator/person/add/visitor", json=body, headers=build_noauth_headers(), timeout=30)
return {"http_status": r.status_code, "body": r.json() if r.headers.get("content-type","").startswith("application/json") else r.text}
except Exception as e:
return {"http_status": 0, "error": str(e)}
def call_passrule_image(base_url, visitor_id):
body = {"personId": visitor_id}
try:
r = requests.post(f"{base_url}/elevator/passRule/image", json=body, headers=build_noauth_headers(), timeout=30)
return {"http_status": r.status_code, "body": r.json() if r.headers.get("content-type","").startswith("application/json") else r.text}
except Exception as e:
return {"http_status": 0, "error": str(e)}
def extract_zone_ids(passrule_response):
try:
datas = passrule_response["body"]["data"]["datas"]
return [d["zoneId"] for d in datas if "zoneId" in d]
except (KeyError, TypeError):
return []
def run_case(base_url, case):
cid = case["id"]
print(f"\n[{cid}] {case['name']}")
floor_ids = case.get("floor_ids_override")
pid = case.get("policy_id")
if pid and cid == "T3":
execute_sql("DELETE FROM tenant_visitor_floor_policy WHERE id='policy_t1_1403'")
print(" [DB] 临时删除 policy_t1_1403")
result = {"id": cid, "name": case["name"]}
r = call_add_visitor(base_url, case["host_id"], case["visitor_id"], floor_ids)
body = r.get("body") if isinstance(r.get("body"), dict) else {}
result["add_visitor"] = {
"http_status": r.get("http_status"),
"success": body.get("success"),
"code": body.get("code"),
"message": body.get("message"),
"error": r.get("error"),
}
av = result["add_visitor"]
business_ok = av["http_status"] == 200 and str(av.get("code", "")) in OK_CODES
if case["expected_pass"]:
if business_ok:
pr = call_passrule_image(base_url, case["visitor_id"])
actual_zones = extract_zone_ids(pr)
result["passrule_image"] = {"zones": actual_zones}
expected = case.get("expected_floors")
if expected is not None:
match = set(actual_zones) == set(expected)
result["floor_match"] = match
result["passed"] = match
print(f" add/visitor OK, floors actual={actual_zones} expected={expected} match={match}")
else:
result["passed"] = True
print(f" add/visitor OK, floors={actual_zones}")
else:
result["passed"] = False
print(f" expected success but got code={av.get('code')} msg={av.get('message')}")
else:
expected_code = case.get("expected_code")
actual_code = str(av.get("code", ""))
result["passed"] = (not business_ok) and (actual_code == expected_code)
print(f" expected fail code={expected_code} actual={actual_code} passed={result['passed']}")
if cid == "T3":
execute_sql(
"INSERT INTO tenant_visitor_floor_policy "
"(id, org_id, business_id, policy_type, allow_zone_ids, building_id, enabled, policy_version, created_at, updated_at) "
"VALUES ('policy_t1_1403', %s, NULL, 'INTERSECT_ALLOWLIST', %s, NULL, 1, 1, UNIX_TIMESTAMP(NOW())*1000, UNIX_TIMESTAMP(NOW())*1000)",
(ORG_1403, f'["{ZONE_28F}"]'),
)
print(" [DB] 恢复 policy_t1_1403")
return result
def generate_report(results, base_url):
passed = sum(1 for r in results if r.get("passed"))
return {
"test": "org_id policy fix verification",
"timestamp": datetime.now().isoformat(),
"elevator_url": base_url,
"mode": "noauth-probe",
"business_id": BUSINESS_ID,
"summary": {"total": len(results), "passed": passed, "failed": len(results) - passed},
"results": results,
}
def main():
args = parse_args()
base = args.elevator_base_url.rstrip("/")
if not health_check(base):
print("FATAL: elevator not reachable")
sys.exit(1)
if not args.skip_db:
print("\n=== Phase 1: prepare ===")
prepare_test_data()
print(f"\n=== Phase 2: run {len(TEST_CASES)} cases ===")
results = [run_case(base, c) for c in TEST_CASES]
if not args.skip_db:
print("\n=== Phase 3: cleanup ===")
cleanup_test_data()
report = generate_report(results, base)
report_path = f"report/org-policy-fix-verify-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
os.makedirs("report", exist_ok=True)
with open(report_path, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"\n=== Report: {report_path} ===")
print(f"Passed: {report['summary']['passed']}/{report['summary']['total']}")
for r in results:
print(f" {'OK' if r.get('passed') else 'FAIL'} [{r['id']}] {r['name']}")
sys.exit(0 if report["summary"]["failed"] == 0 else 1)
if __name__ == "__main__":
main()