用「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 个单测通过。
207 lines
7.9 KiB
Python
207 lines
7.9 KiB
Python
"""传输层: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)
|