diff --git a/app/core/config.py b/app/core/config.py index bf6940c..31f9bfd 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -24,6 +24,8 @@ class Settings(BaseSettings): rate_limit_user_rps: int = Field(alias='RATE_LIMIT_USER_RPS') rate_limit_global_rps: int = Field(alias='RATE_LIMIT_GLOBAL_RPS') rate_limit_ttl_seconds: int = Field(alias='RATE_LIMIT_TTL_SECONDS') + idempotent_key_lifetime_sec: int = Field(alias='IDEMPOTENT_KEY_LIFETIME_SEC') + reserve_timeout_minutes: int = Field(alias='RESERVE_TIMEOUT_MINUTES') secret_key: str = Field(alias='SECRET_KEY') debug_mode: bool = Field(default=False, alias='DEBUG_MODE') diff --git a/app/core/exception_handlers.py b/app/core/exception_handlers.py index 5814784..62c1d5d 100644 --- a/app/core/exception_handlers.py +++ b/app/core/exception_handlers.py @@ -1,10 +1,19 @@ from fastapi import Request, status from fastapi.responses import JSONResponse -from .exceptions import CredentialsError, UserAlreadyExists +from .exceptions import ( + ConflictError, + CredentialsError, + InsufficientInventoryError, + NotFoundError, + UserAlreadyExists, +) EXISTING_USER_MESSAGE = 'User already exists' INVALID_CREDENTIALS_MESSAGE = 'Invalid credentials' +NOT_FOUND_MESSAGE = 'Resource not found' +INSUFFICIENT_INVENTORY_MESSAGE = 'Insufficient inventory' +CONFLICT_MESSAGE = 'Resource conflict' async def user_already_exists_handler( @@ -24,3 +33,26 @@ async def credentials_error_handler( content={'detail': str(exc) or INVALID_CREDENTIALS_MESSAGE}, headers=getattr(exc, 'headers', None), ) + + +async def not_found_error_handler(request: Request, exc: NotFoundError) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'detail': str(exc) or NOT_FOUND_MESSAGE}, + ) + + +async def insufficient_inventory_error_handler( + request: Request, exc: InsufficientInventoryError +) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={'detail': str(exc) or INSUFFICIENT_INVENTORY_MESSAGE}, + ) + + +async def conflict_error_handler(request: Request, exc: ConflictError) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={'detail': str(exc) or CONFLICT_MESSAGE}, + ) diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 88e2ec9..5d40866 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -1,5 +1,5 @@ class AppError(Exception): - """Базовый класс для всех ошибок приложения.""" + """Base class for all application errors.""" def __init__(self, message: str = '', headers: dict | None = None): self.message = message @@ -8,11 +8,32 @@ def __init__(self, message: str = '', headers: dict | None = None): class UserAlreadyExists(AppError): - """Пользователь c таким email уже существует.""" + """User with such email already exists.""" class CredentialsError(AppError): - """Неверные учетные данные.""" + """Invalid credentials.""" def __init__(self, message: str = 'Could not validate credentials'): super().__init__(message=message, headers={'WWW-Authenticate': 'Bearer'}) + + +class NotFoundError(AppError): + """Resource not found.""" + + def __init__(self, message: str = 'Resource not found'): + super().__init__(message=message) + + +class ConflictError(AppError): + """Resource conflict.""" + + def __init__(self, message: str = 'Resource conflict'): + super().__init__(message=message) + + +class InsufficientInventoryError(AppError): + """Insufficient inventory.""" + + def __init__(self, message: str = 'Insufficient inventory'): + super().__init__(message=message) diff --git a/app/core/setup.py b/app/core/setup.py index 21ea3eb..221646b 100644 --- a/app/core/setup.py +++ b/app/core/setup.py @@ -1,9 +1,27 @@ from fastapi import FastAPI -from .exception_handlers import credentials_error_handler, user_already_exists_handler -from .exceptions import CredentialsError, UserAlreadyExists +from .exception_handlers import ( + conflict_error_handler, + credentials_error_handler, + insufficient_inventory_error_handler, + not_found_error_handler, + user_already_exists_handler, +) +from .exceptions import ( + ConflictError, + CredentialsError, + InsufficientInventoryError, + NotFoundError, + UserAlreadyExists, +) def setup_exception_handlers(app: FastAPI) -> None: app.add_exception_handler(UserAlreadyExists, user_already_exists_handler) # type: ignore[arg-type] app.add_exception_handler(CredentialsError, credentials_error_handler) # type: ignore[arg-type] + app.add_exception_handler(ConflictError, conflict_error_handler) # type: ignore[arg-type] + app.add_exception_handler(NotFoundError, not_found_error_handler) # type: ignore[arg-type] + app.add_exception_handler( + InsufficientInventoryError, + insufficient_inventory_error_handler, # type: ignore[arg-type] + ) diff --git a/app/main.py b/app/main.py index e938134..a964cee 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,9 @@ from app.core.logging import setup_logging from app.core.lua_scripts import RATE_LIMIT_LUA_SCRIPT from app.core.setup import setup_exception_handlers -from app.services.user.routes import router_v1 +from app.services.inventory.routes import router_v1 as inventory_router_v1 +from app.services.orders.routes import router_v1 as order_router_v1 +from app.services.user.routes import router_v1 as user_router_v1 setup_logging() logger = structlog.get_logger(__name__) @@ -38,7 +40,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: setup_exception_handlers(app) -app.include_router(router_v1, prefix='/api/v1', tags=['Users']) +app.include_router(user_router_v1, prefix='/api/v1', tags=['Users']) +app.include_router(order_router_v1, prefix='/api/v1', tags=['Orders']) +app.include_router(inventory_router_v1, prefix='/api/v1', tags=['Inventory']) @app.get('/health') diff --git a/app/services/inventory/models.py b/app/services/inventory/models.py index 69e56de..3fe6919 100644 --- a/app/services/inventory/models.py +++ b/app/services/inventory/models.py @@ -19,9 +19,9 @@ class Product(Base): id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) name: Mapped[str] = mapped_column(String(), nullable=False) description: Mapped[str | None] = mapped_column(Text(), nullable=True) - price: Mapped[Decimal | None] = mapped_column( + price: Mapped[Decimal] = mapped_column( Numeric(DECIMAL_PRECISION, DECIMAL_SCALE), - nullable=True, + nullable=False, default=Decimal('0.10'), ) qty_available: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/app/services/inventory/routes.py b/app/services/inventory/routes.py new file mode 100644 index 0000000..78a0755 --- /dev/null +++ b/app/services/inventory/routes.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends, Header, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.services.inventory.models import Reservation +from app.services.inventory.rate_limit import check_rate_limit +from app.services.inventory.schemas import ReservationCreate, ReservationResponse +from app.services.inventory.service import reserve_items +from app.services.user.models import User +from app.shared.decorators import idempotent +from app.shared.deps import get_current_user + +router_v1 = APIRouter(prefix='/inventory', tags=['Inventory']) + + +@router_v1.post('/reserve', response_model=ReservationResponse) +@idempotent() +async def reservation_data( + request: Request, + reservation_data: ReservationCreate, + x_idempotency_key: str = Header(...), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> Reservation: + await check_rate_limit( + rate_limit_script=request.app.state.rate_limit_script, + user_id=str(current_user.id), + item_id=str(reservation_data.product_id), + ) + return await reserve_items( + session=session, + user_id=current_user.id, + idempotency_key=x_idempotency_key, + reservation_data=reservation_data, + ) diff --git a/app/services/inventory/schemas.py b/app/services/inventory/schemas.py new file mode 100644 index 0000000..a85efd4 --- /dev/null +++ b/app/services/inventory/schemas.py @@ -0,0 +1,19 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class ReservationCreate(BaseModel): + product_id: UUID + quantity: int = Field(gt=0, description='Quantity must be greater than 0') + + +class ReservationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + product_id: UUID + user_id: UUID + quantity: int + status: str + expires_at: datetime diff --git a/app/services/inventory/service.py b/app/services/inventory/service.py new file mode 100644 index 0000000..3d9a5bb --- /dev/null +++ b/app/services/inventory/service.py @@ -0,0 +1,49 @@ +import datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.exceptions import ConflictError, InsufficientInventoryError, NotFoundError +from app.services.inventory.models import Product, Reservation +from app.services.inventory.schemas import ReservationCreate + + +async def reserve_items( + session: AsyncSession, + user_id: UUID, + idempotency_key: str, + reservation_data: ReservationCreate, +) -> Reservation: + result = await session.execute( + select(Product) + .with_for_update() + .where(Product.id == reservation_data.product_id) + ) + product = result.scalar_one_or_none() + if not product: + raise NotFoundError + if product.qty_available < reservation_data.quantity: + raise InsufficientInventoryError + product.qty_available -= reservation_data.quantity + expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta( + minutes=settings.reserve_timeout_minutes + ) + new_reservation = Reservation( + qty_reserved=reservation_data.quantity, + user_id=user_id, + product_id=reservation_data.product_id, + status='pending', + idempotency_key=idempotency_key, + expires_at=expires_at, + ) + session.add(new_reservation) + try: + await session.commit() + await session.refresh(new_reservation) + return new_reservation + except IntegrityError: + await session.rollback() + raise ConflictError diff --git a/app/services/orders/models.py b/app/services/orders/models.py index 16dbe58..3405696 100644 --- a/app/services/orders/models.py +++ b/app/services/orders/models.py @@ -20,6 +20,7 @@ class OrderStatus(StrEnum): SHIPPED = 'shipped' CANCELLED = 'cancelled' FAILED = 'failed' + COMPLETED = 'completed' class Order(Base): diff --git a/app/services/orders/routes.py b/app/services/orders/routes.py new file mode 100644 index 0000000..618fb8e --- /dev/null +++ b/app/services/orders/routes.py @@ -0,0 +1,66 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.services.orders.models import Order +from app.services.orders.schemas import OrderCreate, OrderResponse +from app.services.orders.service import ( + cancel_order, + confirm_order_payment, + create_order_from_reservation, +) +from app.services.user.models import User +from app.shared.decorators import idempotent +from app.shared.deps import get_current_user + +router_v1 = APIRouter(prefix='/orders', tags=['Orders']) + + +@router_v1.post('/', response_model=OrderResponse) +@idempotent() +async def create_order_endpoint( + request: Request, + order_data: OrderCreate, + x_idempotency_key: str = Header(...), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> Order: + return await create_order_from_reservation( + session=session, + user_id=current_user.id, + order_data=order_data, + ) + + +@router_v1.post('/{order_id}/pay', response_model=OrderResponse) +@idempotent() +async def confirm_order_payment_endpoint( + request: Request, + order_id: UUID, + x_idempotency_key: str = Header(...), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> Order: + return await confirm_order_payment( + session=session, + order_id=order_id, + user_id=current_user.id, + ) + + +@router_v1.post('/{order_id}/cancel', response_model=OrderResponse) +@idempotent() +async def cancel_order_endpoint( + request: Request, + order_id: UUID, + x_idempotency_key: str = Header(...), + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> Order: + return await cancel_order( + session=session, + order_id=order_id, + user_id=current_user.id, + ) diff --git a/app/services/orders/schemas.py b/app/services/orders/schemas.py new file mode 100644 index 0000000..433d931 --- /dev/null +++ b/app/services/orders/schemas.py @@ -0,0 +1,30 @@ +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class OrderCreate(BaseModel): + reservation_id: UUID + shipping_address: str | None = None + + +class OrderItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + product_id: UUID + product_name: str + quantity: int + price: Decimal + + +class OrderResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + user_id: UUID + status: str + total_amount: Decimal + shipping_address: str | None = None + created_at: datetime + items: list[OrderItemResponse] diff --git a/app/services/orders/service.py b/app/services/orders/service.py new file mode 100644 index 0000000..674e238 --- /dev/null +++ b/app/services/orders/service.py @@ -0,0 +1,118 @@ +import datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ConflictError, NotFoundError +from app.services.inventory.models import Product, Reservation +from app.services.orders.models import Order, OrderItem, OrderStatus +from app.services.orders.schemas import OrderCreate + + +async def create_order_from_reservation( + session: AsyncSession, + user_id: UUID, + order_data: OrderCreate, +) -> Order: + reservation_result = await session.execute( + select(Reservation) + .with_for_update() + .where( + Reservation.id == order_data.reservation_id, + Reservation.user_id == user_id, + Reservation.status == OrderStatus.PENDING, + ) + ) + reservation = reservation_result.scalar_one_or_none() + if not reservation: + raise NotFoundError + if reservation.expires_at < datetime.datetime.now(datetime.UTC): + raise ConflictError + if reservation.order_id is not None: + raise ConflictError + product = ( + await session.execute( + select(Product).where(Product.id == reservation.product_id) + ) + ).scalar_one_or_none() + if not product: + raise NotFoundError + create_order = Order( + user_id=user_id, + total_amount=product.price * reservation.qty_reserved, + status=OrderStatus.PENDING, + shipping_address=order_data.shipping_address, + ) + session.add(create_order) + await session.flush() + create_order_item = OrderItem( + order_id=create_order.id, + product_id=reservation.product_id, + product_name=product.name, + quantity=reservation.qty_reserved, + price=product.price, + ) + session.add(create_order_item) + reservation.order_id = create_order.id + await session.commit() + return create_order + + +async def _get_locked_order_and_reservation( + session: AsyncSession, order_id: UUID, user_id: UUID +) -> tuple[Order, Reservation]: + order_result = await session.execute( + select(Order) + .with_for_update() + .where( + Order.id == order_id, + Order.user_id == user_id, + ) + ) + order = order_result.scalar_one_or_none() + if not order: + raise NotFoundError + if order.status != OrderStatus.PENDING: + raise ConflictError + res_result = await session.execute( + select(Reservation).with_for_update().where(Reservation.order_id == order_id) + ) + reservation = res_result.scalar_one_or_none() + if not reservation: + raise NotFoundError + return order, reservation + + +async def confirm_order_payment( + session: AsyncSession, + order_id: UUID, + user_id: UUID, +) -> Order: + order, reservation = await _get_locked_order_and_reservation( + session, order_id, user_id + ) + order.status = OrderStatus.PAID + reservation.status = OrderStatus.COMPLETED + await session.commit() + return order + + +async def cancel_order( + session: AsyncSession, + order_id: UUID, + user_id: UUID, +) -> Order: + order, reservation = await _get_locked_order_and_reservation( + session, order_id, user_id + ) + product_rollback = await session.execute( + select(Product).with_for_update().where(Product.id == reservation.product_id) + ) + product = product_rollback.scalar_one_or_none() + if product: + product.qty_available += reservation.qty_reserved + order.status = OrderStatus.CANCELLED + reservation.status = OrderStatus.CANCELLED + await session.commit() + return order diff --git a/app/shared/decorators.py b/app/shared/decorators.py index 9f56dd7..769ae79 100644 --- a/app/shared/decorators.py +++ b/app/shared/decorators.py @@ -7,8 +7,12 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel +from app.core.config import settings -def idempotent(ttl_seconds: int = 86400) -> Callable[[Callable], Callable]: + +def idempotent( + ttl_seconds: int = settings.idempotent_key_lifetime_sec, +) -> Callable[[Callable], Callable]: def decorator(func: Callable) -> Callable: @wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Response | Any: diff --git a/migrations/versions/61a11ad734d2_make_price_non_nullable.py b/migrations/versions/61a11ad734d2_make_price_non_nullable.py new file mode 100644 index 0000000..2a8e104 --- /dev/null +++ b/migrations/versions/61a11ad734d2_make_price_non_nullable.py @@ -0,0 +1,42 @@ +"""make_price_non_nullable + +Revision ID: 61a11ad734d2 +Revises: 71e5495a30cd +Create Date: 2026-02-21 20:57:01.779744 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '61a11ad734d2' +down_revision: str | Sequence[str] | None = '71e5495a30cd' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + 'products', + 'price', + existing_type=sa.NUMERIC(precision=10, scale=2), + nullable=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + 'products', + 'price', + existing_type=sa.NUMERIC(precision=10, scale=2), + nullable=True, + ) + # ### end Alembic commands ### diff --git a/migrations/versions/6a1398dbe560_.py b/migrations/versions/6a1398dbe560_.py new file mode 100644 index 0000000..d677292 --- /dev/null +++ b/migrations/versions/6a1398dbe560_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: 6a1398dbe560 +Revises: e25c5de9bfe2 +Create Date: 2026-02-21 22:13:07.532166 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = '6a1398dbe560' +down_revision: str | Sequence[str] | None = 'e25c5de9bfe2' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/e25c5de9bfe2_add_orderstatus_param.py b/migrations/versions/e25c5de9bfe2_add_orderstatus_param.py new file mode 100644 index 0000000..55bb565 --- /dev/null +++ b/migrations/versions/e25c5de9bfe2_add_orderstatus_param.py @@ -0,0 +1,29 @@ +"""add OrderStatus param + +Revision ID: e25c5de9bfe2 +Revises: 61a11ad734d2 +Create Date: 2026-02-21 21:52:47.386696 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = 'e25c5de9bfe2' +down_revision: str | Sequence[str] | None = '61a11ad734d2' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ###