first commit
This commit is contained in:
278
bot/webhooks.py
Normal file
278
bot/webhooks.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
from time import perf_counter
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from database.orm import ORM
|
||||
from services.wata import WataClient
|
||||
from utils.logging_config import setup_logging
|
||||
|
||||
|
||||
setup_logging(service_name="webhooks")
|
||||
logger = logging.getLogger(__name__)
|
||||
app = FastAPI(title="WeechatPayBot Webhooks")
|
||||
orm = ORM()
|
||||
wata_client = WataClient()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def healthcheck():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
await orm.proceed_schemas()
|
||||
logger.info("Webhook service startup completed")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_http_requests(request: Request, call_next):
|
||||
started_at = perf_counter()
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = round((perf_counter() - started_at) * 1000, 2)
|
||||
logger.exception(
|
||||
"HTTP request failed with unhandled exception",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"client_ip": request.client.host if request.client else None,
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
duration_ms = round((perf_counter() - started_at) * 1000, 2)
|
||||
logger.info(
|
||||
"HTTP request handled",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"client_ip": request.client.host if request.client else None,
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/webhooks/wata")
|
||||
async def wata_webhook(request: Request, x_signature: str | None = Header(default=None)):
|
||||
raw_body = await request.body()
|
||||
|
||||
if not x_signature:
|
||||
logger.warning("Webhook rejected because X-Signature header is missing")
|
||||
raise HTTPException(status_code=400, detail="Missing X-Signature header")
|
||||
|
||||
is_valid_signature = await wata_client.verify_webhook_signature(raw_body, x_signature)
|
||||
if not is_valid_signature:
|
||||
logger.warning("Webhook rejected because signature verification failed")
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Webhook rejected because payload is not valid JSON")
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON payload") from exc
|
||||
|
||||
order_id = payload.get("orderId")
|
||||
transaction_status = payload.get("transactionStatus")
|
||||
|
||||
if not order_id or not transaction_status:
|
||||
logger.warning(
|
||||
"Webhook rejected because required fields are missing",
|
||||
extra={
|
||||
"has_order_id": bool(order_id),
|
||||
"has_transaction_status": bool(transaction_status),
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="orderId and transactionStatus are required")
|
||||
|
||||
paid_at = None
|
||||
if payload.get("paymentTime"):
|
||||
paid_at = datetime.fromisoformat(
|
||||
payload["paymentTime"].strip().replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
local_status = "pending"
|
||||
if transaction_status == "Paid":
|
||||
local_status = "paid"
|
||||
elif transaction_status == "Declined":
|
||||
local_status = "declined"
|
||||
|
||||
await orm.update_payment_status(
|
||||
order_id=order_id,
|
||||
status=local_status,
|
||||
transaction_status=transaction_status,
|
||||
transaction_id=payload.get("transactionId"),
|
||||
error_code=payload.get("errorCode"),
|
||||
error_description=payload.get("errorDescription"),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
paid_at=paid_at,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"WATA webhook processed",
|
||||
extra={
|
||||
"order_id": order_id,
|
||||
"transaction_status": transaction_status,
|
||||
"transaction_id": payload.get("transactionId"),
|
||||
"local_status": local_status,
|
||||
},
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/mock/wata/pay/{order_id}", response_class=HTMLResponse)
|
||||
async def mock_wata_payment_page(
|
||||
order_id: str,
|
||||
success_redirect_url: str = Query(...),
|
||||
fail_redirect_url: str = Query(...),
|
||||
):
|
||||
if not wata_client.is_mock_mode:
|
||||
raise HTTPException(status_code=404, detail="Mock mode is disabled")
|
||||
|
||||
payment = await orm.get_payment_by_order_id(order_id)
|
||||
if payment is None:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
|
||||
paid_url = "/mock/wata/complete/{order_id}?{query}".format(
|
||||
order_id=order_id,
|
||||
query=urlencode(
|
||||
{"status": "paid", "redirect_url": success_redirect_url},
|
||||
),
|
||||
)
|
||||
declined_url = "/mock/wata/complete/{order_id}?{query}".format(
|
||||
order_id=order_id,
|
||||
query=urlencode(
|
||||
{"status": "declined", "redirect_url": fail_redirect_url},
|
||||
),
|
||||
)
|
||||
|
||||
amount_text = html.escape(str(payment.amount))
|
||||
order_id_text = html.escape(order_id)
|
||||
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mock WATA Checkout</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f4f6f8;
|
||||
color: #17202a;
|
||||
margin: 0;
|
||||
padding: 32px 16px;
|
||||
}}
|
||||
.card {{
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
}}
|
||||
h1 {{
|
||||
margin-top: 0;
|
||||
font-size: 24px;
|
||||
}}
|
||||
p {{
|
||||
line-height: 1.5;
|
||||
}}
|
||||
.meta {{
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 16px 0 24px;
|
||||
}}
|
||||
.actions {{
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}}
|
||||
.button-success {{
|
||||
background: #198754;
|
||||
}}
|
||||
.button-declined {{
|
||||
background: #dc3545;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Mock-оплата WATA</h1>
|
||||
<p>Это тестовая страница. Она нужна, чтобы проверить сценарий оплаты без реальной WATA.</p>
|
||||
<div class="meta">
|
||||
<p><strong>Заказ:</strong> {order_id_text}</p>
|
||||
<p><strong>Сумма:</strong> {amount_text} RUB</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="button button-success" href="{html.escape(paid_url, quote=True)}">Успешная оплата</a>
|
||||
<a class="button button-declined" href="{html.escape(declined_url, quote=True)}">Отклонить оплату</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/mock/wata/complete/{order_id}")
|
||||
async def mock_wata_complete_payment(
|
||||
order_id: str,
|
||||
status: str = Query(...),
|
||||
redirect_url: str = Query(...),
|
||||
):
|
||||
if not wata_client.is_mock_mode:
|
||||
raise HTTPException(status_code=404, detail="Mock mode is disabled")
|
||||
|
||||
payment = await orm.get_payment_by_order_id(order_id)
|
||||
if payment is None:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
|
||||
if status not in {"paid", "declined"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid mock payment status")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
transaction_status = "Paid" if status == "paid" else "Declined"
|
||||
error_description = None if status == "paid" else "Mock declined payment"
|
||||
|
||||
await orm.update_payment_status(
|
||||
order_id=order_id,
|
||||
status=status,
|
||||
transaction_status=transaction_status,
|
||||
transaction_id=f"mock-{order_id}",
|
||||
error_description=error_description,
|
||||
updated_at=now,
|
||||
paid_at=now if status == "paid" else None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Mock payment completed",
|
||||
extra={
|
||||
"order_id": order_id,
|
||||
"transaction_status": transaction_status,
|
||||
},
|
||||
)
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
Reference in New Issue
Block a user