初始提交: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:
@@ -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)
|
||||
Reference in New Issue
Block a user