"""可选 FastAPI 包装层,供其他项目通过 HTTP 调用。""" from __future__ import annotations import os import secrets import threading from typing import Annotated from fastapi import Depends, FastAPI, Header, HTTPException, Response, status from pydantic import BaseModel, Field from .client import ERPClient from .errors import ( ERPAPIError, ERPAuthenticationError, ERPNotFoundError, ERPProtocolError, ERPTransportError, ) from .normalizer import normalize_freight_result app = FastAPI( title="顺运宝 ERP 货运查询服务", version="0.1.0", description="单租户、受控会话的 ERP 查询适配层", ) _client: ERPClient | None = None _client_lock = threading.Lock() class LoginRequest(BaseModel): captcha_code: str = Field(min_length=1, max_length=16) class FreightQueryRequest(BaseModel): mode: str = Field(default="ORDER_NUMBER", max_length=32) order_number: str | None = Field(default=None, min_length=1, max_length=128) created_from: str | None = Field(default=None, min_length=10, max_length=10) created_to: str | None = Field(default=None, min_length=10, max_length=10) def get_client() -> ERPClient: global _client with _client_lock: if _client is None: _client = ERPClient( os.getenv( "SHUNYUNBAO_BASE_URL", "https://www.shunyunbaoerp.com", ) ) return _client def require_api_key( x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, ) -> None: expected = os.getenv("SHUNYUNBAO_SERVICE_API_KEY") if not expected or len(expected.encode("utf-8")) < 32: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="CONNECTOR_API_KEY_NOT_CONFIGURED", ) if not x_api_key or not secrets.compare_digest(x_api_key, expected): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="CONNECTOR_API_KEY_INVALID", ) APIKeyDependency = Annotated[None, Depends(require_api_key)] @app.get("/health") def health() -> dict[str, object]: client = get_client() expires = client.token_expires_at return { "status": "ok", "loggedIn": client.is_logged_in, "tokenExpiresAt": expires.isoformat() if expires else None, } @app.get("/v1/session/captcha") def captcha(_: APIKeyDependency) -> Response: try: client = get_client() content = client.fetch_captcha() return Response( content=content, media_type=client.last_captcha_content_type, headers={"Cache-Control": "no-store"}, ) except (ERPTransportError, ERPProtocolError) as exc: raise HTTPException( status_code=502, detail="ERP_CAPTCHA_UNAVAILABLE", ) from exc @app.post("/v1/session/login") def login(body: LoginRequest, _: APIKeyDependency) -> dict[str, object]: username = os.getenv("SHUNYUNBAO_USERNAME") password = os.getenv("SHUNYUNBAO_PASSWORD") if not username or not password: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="ERP_CREDENTIALS_NOT_CONFIGURED", ) try: get_client().login(username, password, body.captcha_code) return {"status": "ok"} except ERPAuthenticationError as exc: raise HTTPException(status_code=401, detail="ERP_LOGIN_FAILED") from exc except (ERPTransportError, ERPProtocolError) as exc: raise HTTPException( status_code=502, detail="ERP_UPSTREAM_UNAVAILABLE", ) from exc @app.post("/v1/freight/query") def query_freight( body: FreightQueryRequest, _: APIKeyDependency, ) -> dict[str, object]: try: if body.mode == "ORDER_NUMBER" and body.order_number: if body.created_from is not None or body.created_to is not None: raise ValueError("ORDER_NUMBER 不能包含日期范围") result = get_client().get_freight_details(body.order_number) elif ( body.mode == "CREATED_RANGE" and body.order_number is None and body.created_from and body.created_to ): result = get_client().get_freight_details_by_created_range( body.created_from, body.created_to, ) else: raise ValueError("查询模式与参数不匹配") return normalize_freight_result(result) except ERPAuthenticationError as exc: raise HTTPException( status_code=401, detail="ERP_SESSION_REQUIRED", ) from exc except ERPNotFoundError as exc: raise HTTPException( status_code=404, detail="ERP_FREIGHT_NOT_FOUND", ) from exc except ValueError as exc: raise HTTPException( status_code=422, detail="ERP_QUERY_INVALID", ) from exc except (ERPTransportError, ERPProtocolError, ERPAPIError) as exc: raise HTTPException( status_code=502, detail="ERP_UPSTREAM_UNAVAILABLE", ) from exc