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):
|
2026-07-29 00:26:05 +08:00
|
|
|
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)
|
2026-07-28 22:52:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-29 00:26:05 +08:00
|
|
|
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("查询模式与参数不匹配")
|
2026-07-28 22:52:52 +08:00
|
|
|
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
|