"""本机 HTTP API;不可直接作为公网生产服务使用。""" import hashlib import json import os import re import tempfile from pathlib import Path from urllib.parse import parse_qs from zipfile import BadZipFile from . import VERSION from .core import build from .workbooks import leases, mileage, export def encoded(value): return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") class Store: def __init__(self, directory): self.directory = Path(directory) self.directory.mkdir(parents=True, exist_ok=True) def save(self, kind, value): content = encoded(value) identifier = hashlib.sha256(content).hexdigest() folder = self.directory / kind folder.mkdir(exist_ok=True) target = folder / (identifier + ".json") # 同一输入重复提交得到同一标识;所有记录以内容寻址,旧快照不被逻辑更新。 if not target.exists(): fd, temporary = tempfile.mkstemp(dir=folder) try: with os.fdopen(fd, "wb") as f: f.write(content) os.replace(temporary, target) finally: if os.path.exists(temporary): os.unlink(temporary) return identifier def load(self, kind, identifier): if not re.fullmatch(r"[0-9a-f]{64}", identifier): raise ValueError("非法快照标识") p = self.directory / kind / (identifier + ".json") if not p.is_file(): raise FileNotFoundError(identifier) return dict(json.loads(p.read_text()), id=identifier) class App: def __init__(self, directory): self.store = Store(directory) def __call__(self, env, respond): code = 200 mime = "application/json; charset=utf-8" try: path, method = env.get("PATH_INFO", ""), env["REQUEST_METHOD"] query = parse_qs(env.get("QUERY_STRING", "")) if method == "GET" and path == "/health": payload = dict(status="ok", version=VERSION) elif method == "POST" and path in ("/imports/leases", "/imports/mileage"): blob = self.body(env) if path.endswith("/leases"): payload = leases(blob) else: payload = mileage(blob, query.get("source", [""])[0], query.get("provider", [""])[0]) payload["sha256"] = hashlib.sha256(blob).hexdigest() payload["parser_version"] = VERSION identifier = self.store.save("imports", payload) payload = dict(id=identifier, kind=payload["kind"], raw_rows=payload["raw_rows"], accepted_rows=len(payload["rows"]), issues=payload["issues"]) code = 201 elif method == "GET" and path.startswith("/imports/"): payload = self.store.load("imports", path.removeprefix("/imports/")) elif method == "POST" and path == "/assessments": request = json.loads(self.body(env)) if not isinstance(request, dict) or not isinstance(request.get("config"), dict): raise ValueError("请求必须为对象,且包含 config 对象") lease = self.store.load("imports", request["lease_import_id"]) if lease["kind"] != "leases": raise ValueError("lease_import_id 类型错误") mileages = [self.store.load("imports", i) for i in request["mileage_import_ids"]] if any(i["kind"] != "mileage" for i in mileages): raise ValueError("mileage_import_ids 类型错误") result = build(lease, mileages, request["config"]) result.update(engine_version=VERSION, inputs=request) identifier = self.store.save("assessments", result) payload = dict(id=identifier, status=result["status"], summary=result["summary"], issues=result["issues"]) code = 201 elif method == "GET" and path.startswith("/assessments/"): tail = path.removeprefix("/assessments/") wants_excel = tail.endswith("/export.xlsx") identifier = tail.removesuffix("/export.xlsx") if wants_excel else tail result = self.store.load("assessments", identifier) if wants_excel: if result["status"] != "ready": code, payload = 409, dict(error="仍有待核对问题", issues=result["issues"]) else: payload = export(result) mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" else: payload = result else: code, payload = 404, dict(error="接口不存在") except FileNotFoundError: code, payload = 404, dict(error="快照不存在") except (ValueError, KeyError, TypeError, BadZipFile) as exc: code, payload = 400, dict(error=str(exc)) data = payload if isinstance(payload, bytes) else encoded(payload) labels = {200: "OK", 201: "Created", 400: "Bad Request", 404: "Not Found", 409: "Conflict"} respond(f"{code} {labels[code]}", [("Content-Type", mime), ("Content-Length", str(len(data))), ("Cache-Control", "no-store")]) return [data] @staticmethod def body(env): size = int(env.get("CONTENT_LENGTH") or 0) if not 0 < size <= 20 * 1024 * 1024: raise ValueError("请求体需大于 0 且不超过 20 MB") content = env["wsgi.input"].read(size) if len(content) != size: raise ValueError("请求体不完整") return content