Files
chenshoulin 58850314cf 初始提交:QuickTeam 工单接口导入工具
用「WCF + 通用存储过程网关」接口替代 Selenium(D://Learning//Python//工单自动化)导入工单:
单 Sprint 12 个项目 175s 跑完(旧方案 25~35 分钟),失败可断点续跑且不重复建单。

- 分层:transport(HTTP/协议)/ api(存储过程语义)/ domain(纯规则)/
  workflow(阶段编排)/ store(SQLite 台账)/ report / CLI
- 幂等:台账驱动断点续跑;dry-run 与跳过行不污染进度;--rollback-on-fail 清理半成品
- 命令:plan / apply / report / status,真实写入需 --yes
- tools:只读探针、存储过程源码导出(sp_helptext)、导入诊断
- docs:调研与设计文档(含联调踩到的 5 个坑)+ 6 个关键存储过程源码

实测:Sprint 2026 Sprint 10 的 12 个项目全部完成建单→导入→确认→推送 TFS,
工单 225134–225145,35 个功能模块,重跑 plan 全部 skip-pushed。69 个单测通过。
2026-11-05 19:14:25 +08:00

366 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""QuickTeam 接口探针(只读)。
用途:在不动任何业务数据的前提下,验证「通过接口驱动 QuickTeam」的可行性,
并输出一份「本 Sprint 各项目工单现状」清单(plan 阶段的雏形)。
只调用以下只读接口:
usp_Authentication / usp_GetTeamsByUserId / usp_GetProjectsByTeamId
usp_GetWeeklyWorkSheetsByProjectId / usp_GetWorkSheetById / usp_GetProjectUsersByProjectId
可选(--upload-test)额外验证文件上传通道,会在服务器 WorkSheetImport 目录留一个临时文件,
不触发任何解析或写库动作。
用法:
python tools/api_probe.py --config config/config.json
python tools/api_probe.py --config config/config.json --sprint "2026 Sprint 10" --dir "D:/Work/QuickTeam/2026 Sprint 10"
python tools/api_probe.py --config config/config.json --upload-test
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------------------
# 传输层:WCF + 通用存储过程网关
# ---------------------------------------------------------------------------
class QtError(RuntimeError):
"""接口/业务错误。"""
@dataclass
class QtClient:
base_url: str
timeout: int = 60
retries: int = 2
# WCF 服务路径(来自前端 app.js 的 a078 配置模块)
common_url: str = "/Services/CommonService.svc/"
file_url: str = "/Services/FileOperationService.svc/"
# -- 底层 ---------------------------------------------------------------
def _post(self, path: str, payload: Any = None) -> str:
url = self.base_url.rstrip("/") + path
data = b"" if payload is None else json.dumps(payload).encode("utf-8")
last: Exception | None = None
for attempt in range(self.retries + 1):
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return resp.read().decode("utf-8-sig", "replace")
except urllib.error.HTTPError as exc:
raise QtError(self._extract_error(exc)) from None
except (urllib.error.URLError, TimeoutError) as exc: # 网络层可重试
last = exc
if attempt < self.retries:
time.sleep(1.5 * (attempt + 1))
continue
raise QtError(f"网络不可达:{exc}") from None
raise QtError(str(last))
@staticmethod
def _extract_error(exc: urllib.error.HTTPError) -> str:
body = exc.read().decode("utf-8-sig", "replace")
# WCF 返回 HTML 错误页,真正的错误在 "The exception message is '...'"
match = re.search(r"exception message is '(.*?)'.?\s*See server logs", body, re.S)
if match:
return f"HTTP {exc.code}: {match.group(1).strip()}"
plain = re.sub(r"<[^>]+>", " ", body)
plain = re.sub(r"\s+", " ", plain).strip()
return f"HTTP {exc.code}: {plain[-300:]}"
@staticmethod
def _decode(raw: str) -> Any:
"""WCF 返回值常是「JSON 字符串里再套一层 JSON」,最多解三层。"""
value: Any = raw.strip()
for _ in range(3):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
break
return value
def _exec(self, conn: str, sp: str, params: list[tuple[str, Any, str]]) -> Any:
"""调用存储过程。params: [(Name, Value, DBType), ...]"""
inner = {
"SPName": sp,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": conn,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
return self._decode(self._post(self.common_url + "ExecTable", payload))
def _exec_result(self, conn: str, sp: str, params: list[tuple[str, Any, str]]) -> Any:
"""同 _exec,但走 ExecResult(标量/返回值语义,如 Result、Column1)。"""
inner = {
"SPName": sp,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": conn,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
return self._decode(self._post(self.common_url + "ExecResult", payload))
# -- 业务语义 -----------------------------------------------------------
def login(self, username: str, password: str) -> dict:
digest = hashlib.md5(password.encode("utf-8")).hexdigest()
rows = self._exec(
"UserCenter",
"dbo.usp_Authentication",
[("UserName", username, "string"), ("Password", digest, "string")],
)
if not rows:
raise QtError("登录失败:账号或密码不匹配")
return rows[0]
def teams(self, user_id: str) -> list[dict]:
return self._exec("QuickTeam", "QuickTeam.usp_GetTeamsByUserId", [("UserId", user_id, "string")]) or []
def projects(self, team_id: str) -> list[dict]:
return self._exec(
"QuickTeam",
"QuickTeam.usp_GetProjectsByTeamId",
[("TeamId", team_id, "string"), ("IsTemplate", "1", "string")],
) or []
def worksheets(self, project_id: str, start: str, end: str) -> list[dict]:
return self._exec(
"QuickTeam",
"QuickTeam.usp_GetWeeklyWorkSheetsByProjectId",
[("ProjectId", project_id, "string"), ("Monday", start, "string"), ("Sunday", end, "string")],
) or []
def worksheet(self, worksheet_id: int) -> dict | None:
rows = self._exec("QuickTeam", "QuickTeam.usp_GetWorkSheetById", [("WorkSheetId", worksheet_id, "int")])
return rows[0] if rows else None
def project_users(self, project_id: str) -> list[dict]:
return self._exec(
"QuickTeam", "QuickTeam.usp_GetProjectUsersByProjectId", [("ProjectId", project_id, "string")]
) or []
# -- 文件通道 -----------------------------------------------------------
def upload(self, local_path: str, folder: str = "WorkSheetImport") -> str:
"""上传文件,返回服务器端绝对路径(BatchImportFeature 的 filePath)。"""
name = f"{int(time.time() * 1000)}{os.path.splitext(local_path)[1]}"
url = (
f"{self.base_url.rstrip('/')}{self.file_url}FileUpload"
f"?fileName={urllib.parse.quote(name)}&fileFolder={urllib.parse.quote(folder)}"
)
boundary = "----qtprobe" + hashlib.md5(name.encode()).hexdigest()
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
).encode("utf-8")
body += open(local_path, "rb").read()
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
req = urllib.request.Request(
url, data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
data = self._decode(resp.read().decode("utf-8-sig", "replace"))
except urllib.error.HTTPError as exc:
raise QtError(self._extract_error(exc)) from None
path = (data or {}).get("FileUploadResult")
if not path:
raise QtError(f"上传未返回 FileUploadResult:{data!r}")
return path
# ---------------------------------------------------------------------------
# 报表
# ---------------------------------------------------------------------------
def ms_to_date(value: Any) -> str:
"""QuickTeam 日期为 /Date(1785081600000)/ 形式。"""
if not value:
return ""
match = re.search(r"\((\d+)", str(value))
if not match:
return str(value)
return time.strftime("%Y-%m-%d", time.localtime(int(match.group(1)) / 1000))
def list_xlsx(folder: str) -> list[str]:
if not os.path.isdir(folder):
raise SystemExit(f"目录不存在:{folder}")
names = []
for entry in sorted(os.listdir(folder)):
path = os.path.join(folder, entry)
if os.path.isfile(path) and entry.lower().endswith(".xlsx") and not entry.startswith("~$"):
names.append(path)
return names
def normalize(name: str) -> str:
return re.sub(r"\s+", "", name).replace("-", "-").replace("—", "-")
@dataclass
class Row:
project: str
file: str
project_id: str = ""
existing: int = 0
has_sprint: bool = False
worksheet_ids: list[int] = field(default_factory=list)
note: str = ""
def main() -> int:
parser = argparse.ArgumentParser(description="QuickTeam 接口探针(只读)")
parser.add_argument("--config", default="config/config.json", help="配置文件路径")
parser.add_argument("--sprint", help="覆盖配置中的 sprint 名称(SystemName)")
parser.add_argument("--dir", dest="folder", help="覆盖配置中的 xlsx 目录")
parser.add_argument("--team", help="覆盖配置中的团队名称")
parser.add_argument("--upload-test", action="store_true", help="额外验证上传通道(会留一个临时文件)")
parser.add_argument("--json-out", help="把结果另存为 JSON")
args = parser.parse_args()
if not os.path.exists(args.config):
raise SystemExit(f"缺少配置文件:{args.config}(可从 config/config.example.json 复制)")
with open(args.config, encoding="utf-8-sig") as handle:
cfg = json.load(handle)
sprint = args.sprint or cfg["sprint"]
folder = args.folder or cfg["work_dir"]
team_name = args.team or cfg.get("team_name", "当前项目")
start, end = cfg.get("start_time", ""), cfg.get("end_time", "")
if not start or not end:
today = time.strftime("%Y-%m-%d")
start, end = "2020-01-01", today
client = QtClient(cfg["base_url"], timeout=int(cfg.get("timeout", 60)))
t0 = time.time()
user = client.login(cfg["username"], cfg["password"])
t_login = time.time() - t0
print(f"[登录] {user['RealName']} <{user['UserName']}> UserId={user['GuidId']} {t_login:.2f}s")
t1 = time.time()
teams = client.teams(user["GuidId"])
team = next((x for x in teams if x.get("TeamName") == team_name), None)
if not team:
raise SystemExit(f"未找到团队「{team_name}」,可选:{[x.get('TeamName') for x in teams]}")
t_teams = time.time() - t1
t2 = time.time()
projects = client.projects(team["TeamId"])
t_projects = time.time() - t2
print(f"[团队] {team['TeamName']} ({team['TeamId']}) {t_teams:.2f}s")
print(f"[项目] 共 {len(projects)} 个 {t_projects:.2f}s")
by_name = {normalize(p["ProjectName"]): p for p in projects}
rows: list[Row] = []
t3 = time.time()
for path in list_xlsx(folder):
stem = os.path.splitext(os.path.basename(path))[0]
row = Row(project=stem, file=os.path.basename(path))
hit = by_name.get(normalize(stem))
if not hit:
row.note = "项目名在 QuickTeam 中不存在"
rows.append(row)
continue
row.project_id = hit["ProjectId"]
sheets = client.worksheets(row.project_id, start, end)
row.existing = len(sheets)
for sheet in sheets:
detail = client.worksheet(sheet["WorkSheetId"]) or {}
row.worksheet_ids.append(sheet["WorkSheetId"])
if detail.get("SystemName") == sprint:
row.has_sprint = True
rows.append(row)
t_scan = time.time() - t3
width = max([len(r.project) for r in rows] + [8]) + 2
print()
print(f"{'项目'.ljust(width)} {'工单数':>5} {'本Sprint':>8} 状态")
print("-" * (width + 34))
for row in rows:
if row.note:
state = f"!! {row.note}"
elif row.has_sprint:
state = "已存在(应跳过或续跑)"
else:
state = "待创建"
print(f"{row.project.ljust(width)} {row.existing:>5} {'是' if row.has_sprint else '否':>8} {state}")
todo = sum(1 for r in rows if not r.note and not r.has_sprint)
missing = sum(1 for r in rows if r.note)
print("-" * (width + 34))
print(f"合计 {len(rows)} 个文件:待创建 {todo},已存在 {len(rows) - todo - missing},异常 {missing}")
print(f"[耗时] 扫描 {t_scan:.2f}s 平均 {t_scan / max(len(rows), 1):.2f}s/项目")
if args.upload_test:
sample = next((p for p in list_xlsx(folder)), None)
if sample:
t4 = time.time()
remote = client.upload(sample)
print(f"\n[上传] {os.path.basename(sample)} -> {remote} {time.time() - t4:.2f}s(已上传但不解析)")
if args.json_out:
os.makedirs(os.path.dirname(os.path.abspath(args.json_out)), exist_ok=True)
with open(args.json_out, "w", encoding="utf-8") as handle:
json.dump(
{
"sprint": sprint,
"team": team["TeamName"],
"team_id": team["TeamId"],
"user": {"RealName": user["RealName"], "GuidId": user["GuidId"]},
"rows": [row.__dict__ for row in rows],
"timing": {
"login": round(t_login, 3),
"teams": round(t_teams, 3),
"projects": round(t_projects, 3),
"scan": round(t_scan, 3),
},
},
handle,
ensure_ascii=False,
indent=2,
)
print(f"\n结果已写入 {args.json_out}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except QtError as error:
print(f"[ERROR] {error}", file=sys.stderr)
sys.exit(2)