From c1e1d3e82db1bf177c068d181259610ac23da6af Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 5 Jul 2026 22:35:13 +0800 Subject: [PATCH] fix(public): T2-03 add order idempotency and rate limit --- admin/routes/web_public.py | 52 ++++++++++++++++++++++++++++++++-- admin/tests/conftest.py | 3 +- admin/tests/test_web_public.py | 44 ++++++++++++++++++++++++++++ data/orders/public_flow.py | 2 ++ 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/admin/routes/web_public.py b/admin/routes/web_public.py index 35ed767..ab63319 100644 --- a/admin/routes/web_public.py +++ b/admin/routes/web_public.py @@ -7,7 +7,9 @@ import logging import secrets from html import escape from pathlib import Path +from collections import deque from datetime import datetime, timedelta, timezone +import time from typing import Any, Literal from urllib.parse import parse_qsl @@ -22,7 +24,7 @@ from fastapi.responses import ( from pydantic import BaseModel, Field from admin.config import Settings, get_settings_dep -from admin.errors import DATA_NOT_FOUND, DATA_VALIDATION_FAILED +from admin.errors import BIZ_RATE_LIMITED, DATA_NOT_FOUND, DATA_VALIDATION_FAILED from admin.errors.exceptions import BusinessError from data.customer_portal.token import ( PortalTokenError, @@ -38,7 +40,7 @@ from data.llm import ( ) from data.notifications.email_service import DeliveryNotificationService from data.orders import crypto -from data.orders.dao import OrderNotFound, OrdersDAO +from data.orders.dao import DuplicateOrder, OrderNotFound, OrdersDAO from data.orders.deletion_service import RETENTION_GUARDED_STATUSES from data.orders.intake_schema import IntakePayload from data.orders.intake_store import IntakeStore @@ -74,6 +76,39 @@ _SERVICE_PRICES = { } _SIMULATED_PAYMENT_ROUTE_NOT_FOUND = "not found" +_PUBLIC_ORDER_RATE_LIMIT = 5 +_PUBLIC_ORDER_RATE_LIMIT_WINDOW_SECONDS = 60.0 +_PUBLIC_ORDER_RATE_LIMIT_BUCKETS: dict[str, deque[float]] = {} + + +def _public_order_rate_limit_key(request: Request, payload: PublicOrderCreate, settings: Settings) -> str: + client_host = request.client.host if request.client else "unknown" + contact = (payload.customer_phone or payload.customer_wechat or "unknown").strip().lower() + return f"{settings.orders_db_path}:{client_host}:{contact}" + + +def _assert_public_order_rate_limit(request: Request, payload: PublicOrderCreate, settings: Settings) -> None: + if payload.idempotency_key: + return + now = time.time() + key = _public_order_rate_limit_key(request, payload, settings) + bucket = _PUBLIC_ORDER_RATE_LIMIT_BUCKETS.setdefault(key, deque()) + cutoff = now - _PUBLIC_ORDER_RATE_LIMIT_WINDOW_SECONDS + while bucket and bucket[0] < cutoff: + bucket.popleft() + if len(bucket) >= _PUBLIC_ORDER_RATE_LIMIT: + raise BusinessError( + BIZ_RATE_LIMITED, + detail={"retry_after_seconds": int(_PUBLIC_ORDER_RATE_LIMIT_WINDOW_SECONDS)}, + http_status=429, + ) + bucket.append(now) + + +def reset_public_order_rate_limit_for_tests() -> None: + _PUBLIC_ORDER_RATE_LIMIT_BUCKETS.clear() + + _DELETION_SCOPE_VALUES = ("order_only", "order_and_attachments", "full_account") _DELETION_NEXT_STEP_OUTSIDE = ( "已超过 180 天保留期,可申请删除;提交成功后将由人工核验后处理" @@ -246,8 +281,11 @@ def payment_success_page( @router.post("/api/public/orders", response_model=PublicOrderCreated, status_code=201) def create_public_order_endpoint( payload: PublicOrderCreate, + request: Request, settings: Settings = Depends(get_settings_dep), ) -> PublicOrderCreated: + _assert_public_order_rate_limit(request, payload, settings) + try: payment_service = _payment_service(settings) except PaymentError as exc: @@ -261,7 +299,15 @@ def create_public_order_endpoint( try: with OrdersDAO.connect(settings.orders_db_path) as dao: - order = create_public_order(dao, payload) + try: + order = create_public_order(dao, payload) + except DuplicateOrder: + if not payload.idempotency_key: + raise + existing = dao.get_by_external_id("web", f"idempotency:{payload.idempotency_key}") + if existing is None: + raise + order = existing except MissingEncryptionKey as exc: crypto.get_fernet.cache_clear() logger.warning("public order create blocked by missing encryption key: %s", exc) diff --git a/admin/tests/conftest.py b/admin/tests/conftest.py index 0533aa2..79a5a27 100644 --- a/admin/tests/conftest.py +++ b/admin/tests/conftest.py @@ -287,7 +287,8 @@ class RouteClient: model = PublicOrderCreate.model_validate(payload or {}) except ValidationError as exc: return self._validation_error_response(exc) - created = create_public_order_endpoint(model, settings) + request = self._request(path, method="POST", headers=kwargs.get("headers")) + created = create_public_order_endpoint(model, request, settings) return self._json_response(created.model_dump(), status_code=201) if route_path.startswith("/pay/mock/") and route_path.endswith("/complete"): payment_id = route_path.split("/pay/mock/", 1)[1].rsplit( diff --git a/admin/tests/test_web_public.py b/admin/tests/test_web_public.py index cbf89c6..1b43284 100644 --- a/admin/tests/test_web_public.py +++ b/admin/tests/test_web_public.py @@ -285,6 +285,50 @@ def test_public_create_order_rejects_price_tampering(client): ) +def test_public_create_order_reuses_order_for_idempotency_key(client, app): + payload = { + "service_version": "standard", + "amount_cents": 9900, + "customer_name": "张家长", + "customer_phone": "13800138000", + "candidate_name": "张三", + "candidate_province": "湖南", + "idempotency_key": "same-checkout-intent", + } + + first = client.post("/api/public/orders", json=payload) + second = client.post("/api/public/orders", json=payload) + + assert first.status_code == 201, first.text + assert second.status_code == 201, second.text + assert first.json()["order_id"] == second.json()["order_id"] + assert first.json()["checkout_url"] == second.json()["checkout_url"] + with OrdersDAO.connect(app.state.settings.orders_db_path) as dao: + assert dao.count() == 1 + + +def test_public_create_order_rate_limits_repeated_non_idempotent_requests(client, app): + statuses: list[int] = [] + for index in range(6): + resp = client.post( + "/api/public/orders", + json={ + "service_version": "standard", + "amount_cents": 9900, + "customer_name": "张家长", + "customer_phone": "13800138000", + "candidate_name": f"张三{index}", + "candidate_province": "湖南", + }, + ) + statuses.append(resp.status_code) + + assert statuses[:5] == [201, 201, 201, 201, 201] + assert statuses[5] == 429 + with OrdersDAO.connect(app.state.settings.orders_db_path) as dao: + assert dao.count() == 5 + + def test_public_create_order_persists_candidate_province(client, app): resp = client.post( "/api/public/orders", diff --git a/data/orders/public_flow.py b/data/orders/public_flow.py index 460f5ff..670adb4 100644 --- a/data/orders/public_flow.py +++ b/data/orders/public_flow.py @@ -61,6 +61,7 @@ class PublicOrderCreate(BaseModel): customer_phone: Optional[str] = Field(default=None, min_length=1) customer_wechat: Optional[str] = None customer_email: Optional[str] = None + idempotency_key: Optional[str] = Field(default=None, min_length=1, max_length=128) candidate_name: str = Field(min_length=1) candidate_province: Optional[str] = None notes: Optional[str] = None @@ -91,6 +92,7 @@ def create_public_order(dao: OrdersDAO, request: PublicOrderCreate) -> Order: order = Order( id=generate_order_id(), source="web", + external_id=f"idempotency:{request.idempotency_key}" if request.idempotency_key else None, service_version=request.service_version, amount_cents=service_price_for(request.service_version), status="pending",