JhHardwareWRS_BackPoint/app/routers/erp_login.py
2026-07-06 10:47:37 +08:00

192 lines
6.3 KiB
Python

import json
from typing import Any
from urllib.parse import urlsplit
from uuid import uuid4
import httpx
from fastapi import APIRouter, Header, HTTPException, Query, Request
from app.config import settings
from app.schemas import (
ErpLoginActionResponse,
ErpLoginConfirmRequest,
ErpLoginPreviewResponse,
ErpLoginQrcodeRequest,
ErpLoginQrcodeResponse,
)
from app.services.erp_login_security import (
SERVICE_NONCE_HEADER,
SERVICE_SIGNATURE_HEADER,
SERVICE_TIMESTAMP_HEADER,
signed_headers,
verify_service_signature,
)
from app.services.wechat import WechatConfigError, create_miniapp_qrcode, get_phone_number
from app.timezone import now
router = APIRouter(prefix="/api/erp-login", tags=["erp-login"])
ERP_LOGIN_PAGE = "pages/erpLoginConfirm/erpLoginConfirm"
def _json_body(payload: dict[str, Any]) -> bytes:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def _erp_url(path: str) -> str:
return f"{settings.erp_api_base_url.rstrip('/')}{path}"
def _require_session_id(session_id: int | None) -> int:
if session_id is None:
raise HTTPException(status_code=400, detail="缺少ERP扫码登录会话ID")
return session_id
def _scene(ticket: str, session_id: int) -> str:
return f"ticket={ticket}&session_id={session_id}"
def _action_response(data: dict[str, Any]) -> ErpLoginActionResponse:
return ErpLoginActionResponse(
status=str(data.get("status") or ""),
failure_reason=data.get("failure_reason"),
)
async def _read_erp_json(response: httpx.Response) -> dict[str, Any]:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail="ERP扫码登录服务暂时不可用") from exc
try:
data = response.json()
except ValueError as exc:
raise HTTPException(status_code=502, detail="ERP扫码登录响应无效") from exc
if not isinstance(data, dict):
raise HTTPException(status_code=502, detail="ERP扫码登录响应无效")
return data
async def _get_erp_json(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
url = _erp_url(path)
body = b""
headers = signed_headers(
"GET",
urlsplit(url).path,
body,
settings.erp_qr_login_shared_secret,
)
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(url, params=params, headers=headers)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail="ERP扫码登录服务暂时不可用") from exc
return await _read_erp_json(response)
async def _post_erp_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
url = _erp_url(path)
body = _json_body(payload)
headers = signed_headers(
"POST",
urlsplit(url).path,
body,
settings.erp_qr_login_shared_secret,
)
headers["content-type"] = "application/json"
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(url, content=body, headers=headers)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail="ERP扫码登录服务暂时不可用") from exc
return await _read_erp_json(response)
@router.post("/qrcode", response_model=ErpLoginQrcodeResponse)
async def create_erp_login_qrcode(
payload: ErpLoginQrcodeRequest,
request: Request,
timestamp: str | None = Header(default=None, alias=SERVICE_TIMESTAMP_HEADER),
nonce: str | None = Header(default=None, alias=SERVICE_NONCE_HEADER),
signature: str | None = Header(default=None, alias=SERVICE_SIGNATURE_HEADER),
) -> ErpLoginQrcodeResponse:
body = await request.body()
verify_service_signature(
"POST",
request.url.path,
timestamp,
nonce,
body,
signature,
settings.erp_qr_login_shared_secret,
)
scene = _scene(payload.ticket, payload.session_id)
try:
qr_url = await create_miniapp_qrcode(
key=f"erp-login-{payload.ticket}",
page=ERP_LOGIN_PAGE,
scene=scene,
public_base_url=settings.public_base_url,
directory=settings.erp_login_qrcode_dir,
label="ERP扫码登录",
)
except WechatConfigError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ErpLoginQrcodeResponse(qr_url=qr_url, page=ERP_LOGIN_PAGE, scene=scene)
@router.get("/sessions/{ticket}", response_model=ErpLoginPreviewResponse)
async def preview_erp_login_session(
ticket: str,
session_id: int | None = Query(default=None),
) -> ErpLoginPreviewResponse:
session_id = _require_session_id(session_id)
data = await _get_erp_json(
f"/api/auth/qr-login/sessions/{session_id}/preview",
params={"ticket": ticket},
)
return ErpLoginPreviewResponse(**data)
@router.post("/sessions/{ticket}/confirm", response_model=ErpLoginActionResponse)
async def confirm_erp_login_session(
ticket: str,
payload: ErpLoginConfirmRequest,
session_id: int | None = Query(default=None),
) -> ErpLoginActionResponse:
session_id = _require_session_id(session_id)
try:
phone = await get_phone_number(payload.phone_code)
except WechatConfigError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
data = await _post_erp_json(
f"/api/auth/qr-login/sessions/{session_id}/confirm",
{
"ticket": ticket,
"phone": phone,
"confirmed_at": now().isoformat(),
"nonce": uuid4().hex,
},
)
return _action_response(data)
@router.post("/sessions/{ticket}/cancel", response_model=ErpLoginActionResponse)
async def cancel_erp_login_session(
ticket: str,
session_id: int | None = Query(default=None),
) -> ErpLoginActionResponse:
session_id = _require_session_id(session_id)
data = await _post_erp_json(
f"/api/auth/qr-login/sessions/{session_id}/cancel",
{"ticket": ticket},
)
return _action_response(data)