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" WECHAT_SCENE_MAX_LENGTH = 32 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: scene = f"t={ticket}&s={session_id}" if len(scene) > WECHAT_SCENE_MAX_LENGTH: raise HTTPException(status_code=422, detail="ERP扫码登录二维码参数过长") return scene 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.session_id}", 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}, ) await _post_erp_json( f"/api/auth/qr-login/sessions/{session_id}/scanned", {"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)