新增ERP扫码登录小程序后端代理
This commit is contained in:
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
|
||||
wechat_qr_env_version: str = "release"
|
||||
public_base_url: str = "http://127.0.0.1:8000"
|
||||
upload_dir: str = "uploads"
|
||||
erp_api_base_url: str = "http://127.0.0.1:8000"
|
||||
erp_qr_login_shared_secret: str = ""
|
||||
erp_login_qrcode_dir: str = "erp-login-qrcodes"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.routers import (
|
||||
dashboard,
|
||||
devices,
|
||||
equipment,
|
||||
erp_login,
|
||||
monitor,
|
||||
notices,
|
||||
people,
|
||||
@@ -92,6 +93,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(devices.router)
|
||||
app.include_router(equipment.router)
|
||||
app.include_router(erp_login.router)
|
||||
app.include_router(monitor.router)
|
||||
app.include_router(notices.router)
|
||||
app.include_router(reconciliation.router)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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 | None) -> str:
|
||||
scene = f"ticket={ticket}"
|
||||
if session_id is not None:
|
||||
scene = f"{scene}&session_id={session_id}"
|
||||
return scene
|
||||
|
||||
|
||||
def _erp_status_error_detail(response: httpx.Response) -> Any:
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
return data.get("detail")
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
detail = _erp_status_error_detail(exc.response) or "ERP扫码登录服务暂时不可用"
|
||||
raise HTTPException(status_code=502, detail=detail) 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)
|
||||
@@ -778,6 +778,35 @@ class ReconciliationEntryUpdate(BaseModel):
|
||||
return_qty: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class ErpLoginQrcodeRequest(BaseModel):
|
||||
ticket: str = Field(min_length=1, max_length=256)
|
||||
session_id: int | None = None
|
||||
|
||||
|
||||
class ErpLoginQrcodeResponse(BaseModel):
|
||||
qr_url: str | None = None
|
||||
page: str
|
||||
scene: str
|
||||
|
||||
|
||||
class ErpLoginPreviewResponse(BaseModel):
|
||||
session_id: int
|
||||
system_name: str
|
||||
started_at: datetime
|
||||
expires_at: datetime
|
||||
request_ip_hint: str | None = None
|
||||
user_agent_hint: str | None = None
|
||||
|
||||
|
||||
class ErpLoginConfirmRequest(BaseModel):
|
||||
phone_code: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class ErpLoginActionResponse(BaseModel):
|
||||
status: str
|
||||
failure_reason: str | None = None
|
||||
|
||||
|
||||
PersonnelOut.model_rebuild()
|
||||
TokenResponse.model_rebuild()
|
||||
LoginSelectionResponse.model_rebuild()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
SERVICE_TIMESTAMP_HEADER = "x-service-timestamp"
|
||||
SERVICE_NONCE_HEADER = "x-service-nonce"
|
||||
SERVICE_SIGNATURE_HEADER = "x-service-signature"
|
||||
|
||||
_seen_nonces: dict[str, int] = {}
|
||||
|
||||
|
||||
def build_service_signature(
|
||||
method: str,
|
||||
path: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
body: bytes,
|
||||
secret: str,
|
||||
) -> str:
|
||||
message = "\n".join([
|
||||
method.upper(),
|
||||
path,
|
||||
timestamp,
|
||||
nonce,
|
||||
body.decode("utf-8"),
|
||||
])
|
||||
return hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def signed_headers(method: str, path: str, body: bytes, secret: str) -> dict[str, str]:
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = uuid4().hex
|
||||
signature = build_service_signature(method, path, timestamp, nonce, body, secret)
|
||||
return {
|
||||
SERVICE_TIMESTAMP_HEADER: timestamp,
|
||||
SERVICE_NONCE_HEADER: nonce,
|
||||
SERVICE_SIGNATURE_HEADER: signature,
|
||||
}
|
||||
|
||||
|
||||
def _cleanup_seen_nonces(now_ts: int, ttl_seconds: int) -> None:
|
||||
expired = [
|
||||
nonce
|
||||
for nonce, seen_at in _seen_nonces.items()
|
||||
if now_ts - seen_at > ttl_seconds
|
||||
]
|
||||
for nonce in expired:
|
||||
_seen_nonces.pop(nonce, None)
|
||||
|
||||
|
||||
def verify_service_signature(
|
||||
method: str,
|
||||
path: str,
|
||||
timestamp: str | None,
|
||||
nonce: str | None,
|
||||
body: bytes,
|
||||
signature: str | None,
|
||||
secret: str,
|
||||
ttl_seconds: int = 300,
|
||||
) -> None:
|
||||
if not secret:
|
||||
raise HTTPException(status_code=500, detail="ERP扫码登录服务密钥未配置")
|
||||
|
||||
try:
|
||||
timestamp_value = int(str(timestamp))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=401, detail="ERP服务签名无效") from exc
|
||||
|
||||
now_ts = int(time.time())
|
||||
if abs(now_ts - timestamp_value) > ttl_seconds:
|
||||
raise HTTPException(status_code=401, detail="ERP服务签名已过期")
|
||||
|
||||
_cleanup_seen_nonces(now_ts, ttl_seconds)
|
||||
nonce_value = str(nonce or "")
|
||||
if not nonce_value or nonce_value in _seen_nonces:
|
||||
raise HTTPException(status_code=401, detail="ERP服务签名无效")
|
||||
|
||||
try:
|
||||
expected = build_service_signature(
|
||||
method,
|
||||
path,
|
||||
str(timestamp),
|
||||
nonce_value,
|
||||
body,
|
||||
secret,
|
||||
)
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HTTPException(status_code=401, detail="ERP服务签名无效") from exc
|
||||
|
||||
if not hmac.compare_digest(expected, str(signature or "")):
|
||||
raise HTTPException(status_code=401, detail="ERP服务签名无效")
|
||||
|
||||
# This in-process nonce store only protects one running process. Production
|
||||
# multi-worker deployments should move replay tracking to shared storage.
|
||||
_seen_nonces[nonce_value] = now_ts
|
||||
+26
-5
@@ -190,6 +190,24 @@ async def create_device_qrcode(
|
||||
scene: str,
|
||||
public_base_url: str,
|
||||
label: str | None = None,
|
||||
) -> str | None:
|
||||
return await create_miniapp_qrcode(
|
||||
key=device_no,
|
||||
page=page,
|
||||
scene=scene,
|
||||
public_base_url=public_base_url,
|
||||
directory="qrcodes",
|
||||
label=label,
|
||||
)
|
||||
|
||||
|
||||
async def create_miniapp_qrcode(
|
||||
key: str,
|
||||
page: str,
|
||||
scene: str,
|
||||
public_base_url: str,
|
||||
directory: str,
|
||||
label: str | None = None,
|
||||
) -> str | None:
|
||||
token = await get_access_token()
|
||||
url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={token}"
|
||||
@@ -205,12 +223,15 @@ async def create_device_qrcode(
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
data = response.json()
|
||||
raise RuntimeError(data.get("errmsg") or "生成设备小程序码失败")
|
||||
raise RuntimeError(data.get("errmsg") or "生成小程序码失败")
|
||||
|
||||
qr_dir = settings.upload_path / "qrcodes"
|
||||
qr_dir = settings.upload_path / directory
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
suffix = _qrcode_file_suffix(content_type)
|
||||
file_name = _qrcode_file_name(device_no, suffix)
|
||||
file_name = _qrcode_file_name(key, suffix)
|
||||
file_path = Path(qr_dir) / file_name
|
||||
file_path.write_bytes(_draw_qrcode_label(response.content, suffix, label or device_no))
|
||||
return f"{public_base_url.rstrip('/')}/uploads/qrcodes/{quote(file_name, safe='')}"
|
||||
label_text = label if label is not None else key
|
||||
file_path.write_bytes(_draw_qrcode_label(response.content, suffix, label_text))
|
||||
encoded_directory = quote(directory.strip("/"), safe="")
|
||||
encoded_file_name = quote(file_name, safe="")
|
||||
return f"{public_base_url.rstrip('/')}/uploads/{encoded_directory}/{encoded_file_name}"
|
||||
|
||||
Reference in New Issue
Block a user