279 lines
8.1 KiB
Python
279 lines
8.1 KiB
Python
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)
|