初始提交: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 个单测通过。
This commit is contained in:
chenshoulin
2026-11-05 19:14:25 +08:00
commit 58850314cf
32 changed files with 4590 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
"""传输层:WCF + 通用存储过程网关。
只负责 HTTP 与协议细节:
- 双层 JSON 解码(返回值常是「JSON 字符串里再套一层 JSON」)
- HTML 错误页 → 可读异常
- 网络层重试(仅幂等调用)
- multipart 上传
业务语义一律放在 api 层。
"""
from __future__ import annotations
import json
import mimetypes
import os
import socket
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable
from .errors import Category, QtError
CallHook = Callable[[str, int, bool, str], None]
# 服务路径(来自前端 app.js 的配置模块 a078)
COMMON_URL = "/Services/CommonService.svc/"
FILE_URL = "/Services/FileOperationService.svc/"
TFS_URL = "/Services/TFSOperationService.svc/"
def _content_type(path: str) -> str:
return mimetypes.guess_type(path)[0] or "application/octet-stream"
class Transport:
def __init__(
self,
base_url: str,
*,
timeout: int = 60,
retries: int = 2,
on_call: CallHook | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.retries = retries
self.on_call = on_call
# ------------------------------------------------------------------ 内部
def _record(self, op: str, started: float, ok: bool, detail: str = "") -> None:
if self.on_call:
self.on_call(op, int((time.time() - started) * 1000), ok, detail)
def _post(
self,
path: str,
payload: Any = None,
*,
op: str = "",
retry: bool = False,
raw_body: bytes | None = None,
headers: dict[str, str] | None = None,
) -> str:
url = self.base_url + path
if raw_body is not None:
body = raw_body
else:
body = b"" if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
hdrs = {"Accept": "application/json"}
if raw_body is None:
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
attempts = self.retries + 1 if retry else 1
started = time.time()
last_error: Exception | None = None
for attempt in range(attempts):
request = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
text = response.read().decode("utf-8-sig", "replace")
self._record(op or path, started, True)
return text
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8-sig", "replace")
error = QtError.from_http(exc.code, detail)
self._record(op or path, started, False, str(error))
# 只有网络/服务端 5xx 才值得重试,业务错误重试无意义
if error.retryable and attempt < attempts - 1:
time.sleep(1.5 * (attempt + 1))
continue
raise error from None
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as exc:
last_error = exc
self._record(op or path, started, False, f"网络错误:{exc}")
if attempt < attempts - 1:
time.sleep(1.5 * (attempt + 1))
continue
raise QtError(f"网络不可达:{exc}", Category.NET, retryable=True) from None
raise QtError(f"请求失败:{last_error}", Category.NET)
@staticmethod
def decode(text: str) -> Any:
"""WCF 返回值可能是「JSON 字符串里再套一层 JSON」,最多解三层。"""
value: Any = text.strip()
for _ in range(3):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
break
return value
# ------------------------------------------------------------------ 网关
def exec_table(
self, connection: str, procedure: str, params: list[tuple[str, Any, str]], *, op: str = "", write: bool = False
) -> Any:
"""ExecTable:结果集语义。"""
return self._exec("ExecTable", connection, procedure, params, op=op, write=write)
def exec_result(
self, connection: str, procedure: str, params: list[tuple[str, Any, str]], *, op: str = ""
) -> Any:
"""ExecResult:标量/返回值语义(Result、Column1 等)。"""
return self._exec("ExecResult", connection, procedure, params, op=op, write=True)
def _exec(
self,
method: str,
connection: str,
procedure: str,
params: list[tuple[str, Any, str]],
*,
op: str,
write: bool,
) -> Any:
inner = {
"SPName": procedure,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": connection,
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
}
text = self._post(f"{COMMON_URL}{method}", payload, op=op or procedure, retry=not write)
return self.decode(text)
# ------------------------------------------------------------------ 文件
def upload(self, local_path: str, folder: str, *, op: str = "FileUpload") -> str:
"""上传文件,返回服务器端绝对路径。
注意:必须发**裸字节**,不能套 multipart。前端把原生 File 对象直接交给
axios(`data: e`,e 是 upload-file,`send(File)` 走的是 file type),WCF 侧
按 `Stream` 接收后原样落盘。发 multipart 时服务器把整段 multipart 包体写进
.xlsx,文件损坏 → 服务端解析出空 DataTable → SqlBulkCopyToDB 抛
「Object reference not set to an instance of an object」。
"""
name = f"{int(time.time() * 1000)}{os.path.splitext(local_path)[1]}"
query = urllib.parse.urlencode({"fileName": name, "fileFolder": folder})
with open(local_path, "rb") as handle:
body = handle.read()
text = self._post(
f"{FILE_URL}FileUpload?{query}",
raw_body=body,
headers={"Content-Type": _content_type(local_path)},
op=op,
retry=True, # 同名覆盖无副作用,可重试
)
result = self.decode(text)
path = (result or {}).get("FileUploadResult") if isinstance(result, dict) else None
if not path:
raise QtError(f"上传未返回 FileUploadResult:{result!r}", Category.UNKNOWN)
return path
def batch_import(self, procedure: str, params: list[tuple[str, Any, str]], file_path: str) -> Any:
"""BatchImportFeature:服务器端解析 Excel 并写库。"""
inner = {
"SPName": procedure,
"SQLParams": [
{"Name": name, "Value": "" if value is None else str(value), "DBType": dbtype}
for name, value, dbtype in params
],
}
payload = {
"connectionName": "QuickTeam",
"param": urllib.parse.quote(json.dumps(inner, separators=(",", ":"))),
"filePath": file_path,
}
text = self._post(f"{FILE_URL}BatchImportFeature", payload, op="BatchImportFeature", retry=False)
return self.decode(text)
# ------------------------------------------------------------------ TFS
def tfs(self, operation: str, payload: Any = None, *, op: str = "", write: bool = False) -> Any:
text = self._post(f"{TFS_URL}{operation}", payload, op=op or f"TFS.{operation}", retry=not write)
return self.decode(text)