Files
QTImport/qtimport/transport.py
T
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

207 lines
7.9 KiB
Python
Raw 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.
"""传输层: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)