Files
cmroubao/erp-connector/src/shunyunbaoerp/api.py
T

150 lines
4.3 KiB
Python
Raw Normal View History

2026-07-28 22:52:52 +08:00
"""可选 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):
order_number: str = Field(min_length=1, max_length=128)
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:
result = get_client().get_freight_details(body.order_number)
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