Skip to content

Commit 394eb25

Browse files
Fix place_bid
Feature/auction
2 parents d9de1d5 + f034c55 commit 394eb25

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

74 files changed

+27758
-447
lines changed

.github/workflows/publish-ghcr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ jobs:
105105
106106
# 서비스 갱신
107107
SERVICE_NAME="api_$TARGET"
108+
docker system prune -af
108109
docker compose pull $SERVICE_NAME
109110
docker compose run --rm $SERVICE_NAME alembic upgrade head
110111
docker compose up -d $SERVICE_NAME

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"makefile.configureOnOpen": false
3+
}

carrot/api.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
from carrot.app.product.router import product_router
77
from carrot.app.region.router import region_router
88
from carrot.app.pay.router import pay_router
9-
# from carrot.app.image.router import image_router
10-
# from carrot.app.chat.websocket import chat_ws_router
9+
from carrot.app.image.router import image_router
10+
from carrot.app.auction.router import auction_router
1111

1212
api_router = APIRouter()
1313

1414
api_router.include_router(auth_router, prefix="/auth", tags=["auth"])
1515
api_router.include_router(user_router, prefix="/user", tags=["user"])
1616
api_router.include_router(chat_router, prefix="/chat", tags=["chat"])
1717
api_router.include_router(product_router, prefix="/product", tags=["product"])
18-
# api_router.include_router(image_router, prefix="/image", tags=["image"])
18+
api_router.include_router(image_router, prefix="/image", tags=["image"])
1919
api_router.include_router(region_router, prefix="/region", tags=["region"])
2020
api_router.include_router(pay_router, prefix="/pay", tags=["pay"])
21-
# api_router.include_router(chat_ws_router, prefix="/ws", tags=["chat"])
21+
api_router.include_router(auction_router, prefix="/auction", tags=["auction"])

carrot/app/auction/exceptions.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from carrot.common.exceptions import CarrotException
2+
from fastapi import status
3+
4+
5+
class AuctionAlreadyExistsError(CarrotException):
6+
def __init__(self) -> None:
7+
super().__init__(
8+
status_code=status.HTTP_400_BAD_REQUEST,
9+
error_code="AUC_001",
10+
error_msg="Active auction already exists for this product.",
11+
)
12+
13+
class AuctionNotFoundError(CarrotException):
14+
def __init__(self) -> None:
15+
super().__init__(
16+
status_code=status.HTTP_404_NOT_FOUND,
17+
error_code="AUC_002",
18+
error_msg="Auction not found.",
19+
)
20+
21+
class NotAllowedActionError(CarrotException):
22+
def __init__(self) -> None:
23+
super().__init__(
24+
status_code=status.HTTP_403_FORBIDDEN,
25+
error_code="AUC_003",
26+
error_msg="Action not allowed on this auction.",
27+
)

carrot/app/auction/models.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import uuid
2+
from datetime import datetime
3+
from enum import Enum
4+
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime
5+
from sqlalchemy.orm import Mapped, mapped_column, relationship
6+
from sqlalchemy.sql import func
7+
from typing import TYPE_CHECKING
8+
9+
from carrot.db.common import Base
10+
11+
if TYPE_CHECKING:
12+
from carrot.app.product.models import Product
13+
from carrot.app.user.models import User
14+
15+
class AuctionStatus(str, Enum):
16+
ACTIVE = "active"
17+
FINISHED = "finished"
18+
FAILED = "failed"
19+
CANCELED = "canceled"
20+
21+
class Auction(Base):
22+
__tablename__ = "auction"
23+
24+
id: Mapped[str] = mapped_column(String(36), primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
25+
product_id: Mapped[str] = mapped_column(String(36), ForeignKey("product.id", ondelete="CASCADE"), nullable=False, index=True)
26+
27+
starting_price: Mapped[int] = mapped_column(Integer, nullable=False) # 시작가
28+
current_price: Mapped[int] = mapped_column(Integer, nullable=False) # 현재가
29+
# is_sold: Mapped[bool] = mapped_column(Boolean, default=False) # Product의 is_sold와 중복
30+
end_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) # 경매 종료 시간
31+
bid_count: Mapped[int] = mapped_column(Integer, default=0) # 입찰 횟수
32+
status: Mapped[AuctionStatus] = mapped_column(String(20), default=AuctionStatus.ACTIVE) # 경매 상태
33+
34+
product: Mapped["Product"] = relationship("Product", back_populates="auction")
35+
bids: Mapped[list["Bid"]] = relationship("Bid", back_populates="auction", cascade="all, delete-orphan")
36+
37+
@property
38+
def top_bid(self):
39+
if not self.bids:
40+
return None
41+
return max(self.bids, key=lambda bid: bid.bid_price)
42+
43+
@property
44+
def owner_id(self):
45+
return self.product.owner_id
46+
47+
class Bid(Base):
48+
__tablename__ = "bid"
49+
50+
id: Mapped[str] = mapped_column(String(36), primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
51+
auction_id: Mapped[str] = mapped_column(String(36), ForeignKey("auction.id", ondelete="CASCADE"), nullable=False, index=True)
52+
bidder_id: Mapped[str] = mapped_column(String(36), ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True)
53+
54+
bid_price: Mapped[int] = mapped_column(Integer, nullable=False) # 입찰가
55+
bid_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) # 입찰 시간
56+
57+
auction: Mapped["Auction"] = relationship("Auction", back_populates="bids")
58+
bidder: Mapped["User"] = relationship("User")

carrot/app/auction/repositories.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from typing import Annotated, List, Optional
2+
3+
from sqlalchemy.ext.asyncio import AsyncSession
4+
from sqlalchemy import select, update, func
5+
from sqlalchemy.orm import selectinload, joinedload, contains_eager
6+
from datetime import datetime
7+
from fastapi import Depends
8+
9+
from carrot.app.auction.models import Auction, Bid, AuctionStatus
10+
from carrot.app.product.models import Product
11+
from carrot.db.connection import get_db_session
12+
13+
from carrot.app.auction.exceptions import (
14+
AuctionAlreadyExistsError,
15+
AuctionNotFoundError,
16+
NotAllowedActionError
17+
)
18+
19+
class AuctionRepository:
20+
def __init__(self, session: AsyncSession) -> None:
21+
self.session = session
22+
23+
24+
async def create_auction(self, product: Product, auction: Auction) -> Auction:
25+
self.session.add(product)
26+
await self.session.flush() # Ensure product ID is generated
27+
28+
auction.product_id = product.id
29+
self.session.add(auction)
30+
31+
await self.session.commit()
32+
await self.session.refresh(auction, attribute_names=["product"])
33+
return auction
34+
35+
async def get_auction_by_id(self, auction_id: str) -> Optional[Auction]:
36+
# 1. 쿼리 작성 (bids 로딩 제거)
37+
stmt = (
38+
select(Auction)
39+
.where(Auction.id == auction_id)
40+
.options(joinedload(Auction.product)) # 상품 정보는 보통 같이 필요하니까요!
41+
)
42+
43+
result = await self.session.execute(stmt)
44+
45+
# 2. 결과 추출
46+
# 이제 1:N 조인이 없으므로 .unique()가 없어도 에러는 안 나지만,
47+
# 관습적으로 넣어두거나 scalar_one_or_none()으로 안전하게 받습니다.
48+
auction = result.scalar_one_or_none()
49+
50+
if not auction:
51+
raise AuctionNotFoundError()
52+
53+
return auction
54+
55+
async def get_active_auctions(
56+
self,
57+
category_id: Optional[str] = None,
58+
region_id: Optional[str] = None
59+
)-> List[Auction]:
60+
stmt = (
61+
select(Auction)
62+
.where(Auction.status == AuctionStatus.ACTIVE)
63+
.options(selectinload(Auction.product))
64+
)
65+
66+
if category_id:
67+
stmt = stmt.join(Auction.product).where(Product.category_id == category_id)
68+
69+
if region_id:
70+
stmt = stmt.join(Auction.product).where(Product.region_id == region_id)
71+
72+
stmt = stmt.order_by(Auction.end_at.asc())
73+
74+
result = await self.session.execute(stmt)
75+
return result.scalars().all()
76+
77+
async def delete_auction(self, auction: Auction) -> None:
78+
await self.session.delete(auction)
79+
await self.session.commit()
80+
81+
async def update_auction(self, auction: Auction) -> Auction:
82+
merged = await self.session.merge(auction)
83+
await self.session.commit()
84+
return merged
85+
86+
async def update_auction_without_commit(self, auction: Auction) -> Auction:
87+
merged = await self.session.merge(auction)
88+
return merged
89+
90+
async def add_bid_without_commit(self, bid: Bid) -> Bid:
91+
self.session.add(bid)
92+
return bid
93+
94+
async def place_bid(self, bid: Bid) -> Bid:
95+
self.session.add(bid)
96+
await self.session.commit()
97+
await self.session.refresh(bid)
98+
return bid
99+
100+
async def update_bid(self, bid: Bid) -> Bid:
101+
merged = await self.session.merge(bid)
102+
await self.session.commit()
103+
await self.session.refresh(merged)
104+
return merged
105+
106+
async def gets_user_bids(self, user_id: str) -> List[Bid]:
107+
stmt = (
108+
select(Bid)
109+
.where(Bid.bidder_id == user_id)
110+
.options(joinedload(Bid.auction).joinedload(Auction.product))
111+
)
112+
result = await self.session.execute(stmt)
113+
return result.scalars().all()
114+
115+

carrot/app/auction/router.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from fastapi import APIRouter, Depends, HTTPException, Query
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
from typing import List, Optional, Annotated
4+
5+
from carrot.db.connection import get_db_session
6+
from carrot.app.auth.utils import login_with_header
7+
8+
from carrot.app.auction.services import AuctionService
9+
from carrot.app.auction.schemas import AuctionCreate, AuctionResponse, BidCreate, BidResponse # Pydantic 모델 가정
10+
11+
from carrot.app.user.models import User
12+
13+
from carrot.app.product.schemas import ProductPostRequest
14+
15+
auction_router = APIRouter()
16+
17+
# 1. 경매 등록
18+
@auction_router.post("/", response_model=AuctionResponse)
19+
async def create_new_auction(
20+
owner: Annotated[User, Depends(login_with_header)],
21+
product_data: ProductPostRequest,
22+
auction_data: AuctionCreate,
23+
service: Annotated[AuctionService, Depends(AuctionService.create)],
24+
) -> AuctionResponse:
25+
auction = await service.create_auction_with_product(
26+
owner_id=owner.id,
27+
region_id=owner.region_id,
28+
product_data=product_data,
29+
auction_data=auction_data
30+
)
31+
return AuctionResponse.model_validate(auction)
32+
33+
# 2. 경매 목록 조회 (카테고리, 지역 필터링)
34+
@auction_router.get("/", response_model=List[AuctionResponse])
35+
async def get_auctions(
36+
service: Annotated[AuctionService, Depends(AuctionService.create)],
37+
category_id: Optional[str] = Query(None, description="카테고리 ID로 필터링"),
38+
region_id: Optional[str] = Query(None, description="지역 ID로 필터링"),
39+
) -> List[AuctionResponse]:
40+
41+
auctions = await service.list_auctions(category_id, region_id)
42+
return auctions
43+
44+
# 3. 경매 상세 조회
45+
@auction_router.get("/{auction_id}", response_model=AuctionResponse)
46+
async def get_auction_detail(
47+
auction_id: str,
48+
service: Annotated[AuctionService, Depends(AuctionService.create)],
49+
) -> AuctionResponse:
50+
auction = await service.get_auction_details(auction_id)
51+
return AuctionResponse.model_validate(auction)
52+
53+
# 4. 입찰하기
54+
@auction_router.post("/{auction_id}/bids", response_model=BidResponse)
55+
async def place_bid(
56+
auction_id: str,
57+
bid_data: BidCreate,
58+
bidder: Annotated[User, Depends(login_with_header)],
59+
service: Annotated[AuctionService, Depends(AuctionService.create)],
60+
) -> BidResponse:
61+
62+
bid = await service.place_bid(
63+
auction_id=auction_id,
64+
bidder_id=bidder.id,
65+
bid_price=bid_data.bid_price
66+
)
67+
68+
return BidResponse.model_validate(bid)

carrot/app/auction/schemas.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from pydantic import BaseModel, Field, field_validator
2+
from datetime import datetime, timezone
3+
from typing import List, Optional
4+
from enum import Enum
5+
6+
from carrot.app.product.schemas import ProductResponse
7+
8+
from sqlalchemy import func
9+
10+
# 경매 상태 Enum (모델과 맞춤)
11+
class AuctionStatus(str, Enum):
12+
ACTIVE = "active"
13+
FINISHED = "finished"
14+
FAILED = "failed"
15+
CANCELED = "canceled"
16+
17+
class AuctionCreate(BaseModel):
18+
starting_price: int = Field(..., gt=0, description="경매 시작 가격")
19+
end_at: datetime = Field(..., description="경매 종료 시간")
20+
21+
@field_validator("end_at")
22+
@classmethod # Pydantic V2에서는 classmethod로 정의하는 것이 정석입니다.
23+
def validate_end_at(cls, v: datetime) -> datetime:
24+
# v에 시간대 정보가 없다면 UTC로 가정
25+
if v.tzinfo is None:
26+
v = v.replace(tzinfo=timezone.utc)
27+
28+
# 현재 시간을 UTC 기준으로 가져옴
29+
current_time = datetime.now(timezone.utc)
30+
31+
if v <= current_time:
32+
raise ValueError("경매 종료 시간은 현재 시간보다 미래여야 합니다.")
33+
return v
34+
35+
class AuctionResponse(BaseModel):
36+
id: str
37+
product_id: str
38+
starting_price: int
39+
current_price: int
40+
end_at: datetime
41+
bid_count: int
42+
status: AuctionStatus
43+
product: ProductResponse
44+
45+
class Config:
46+
from_attributes = True
47+
48+
class BidCreate(BaseModel):
49+
bid_price: int = Field(..., gt=0)
50+
51+
class BidResponse(BaseModel):
52+
id: str
53+
auction_id: str
54+
bidder_id: str
55+
bid_price: int
56+
bid_at: datetime
57+
58+
class Config:
59+
from_attributes = True

0 commit comments

Comments
 (0)