"""传输层: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)