- POST /v1/item/{id}/lowest-price: 상품 1건 LPS 즉시 검색요청(manual
우선순위). queued/duplicated/unavailable 상태 반환, 실패는
ErrorType.LPS_UNAVAILABLE(2000 블록 신설)
- GET /v1/item/{id}/lowest-price: 대표 최저가 + 최근 수집 이력(최신순,
LowestPriceEntry 타입화). 조회 전 lps_db 증분 동기화 1회 수행 —
5분 크론을 기다리지 않는 실시간 폴링 UX(멱등·저비용)
- 동시성 방어 2겹: 프로세스 내 asyncio.Lock(크론·온디맨드 직렬화) +
uq_iilp_item_crawl_time 유니크 인덱스(alters/2026-07-10, init.sql
멱등 반영·dev DB 적용) — 경합 진 쪽 tx 실패 후 다음 tick 흡수
- LpsSyncService 무인자 생성자(FastAPI Depends 호환)
- compose: negodata-backend 에 LPS_DB_HOST·LPS_BASE_URL env(미설정 시
연동 비활성으로 조용히 동작)
검증(도커 컨테이너 e2e): 인증→POST queued→LPS 크롤→GET 온디맨드
동기화로 이력 즉시 노출(not_found 정책: 대표값 미변경 확인),
company 스코프 차단(타사 상품 ITEM_NOT_FOUND) 확인
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
123 lines
5.5 KiB
Python
123 lines
5.5 KiB
Python
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
|
|
|
from common.enums import ErrorType
|
|
from common.models.gmodel import PageParams, UserInfo
|
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
|
from services.item_service import ItemService
|
|
from services.lps_sync_service import LpsSyncService
|
|
from .protocol import (
|
|
LowestPriceEntry,
|
|
Req_CheckCodes,
|
|
Req_CreateItem,
|
|
Req_UpdateItem,
|
|
Res_CheckCodes,
|
|
Res_DeleteItem,
|
|
Res_Item,
|
|
Res_ItemCategories,
|
|
Res_ItemImage,
|
|
Res_ItemList,
|
|
Res_LowestPriceResult,
|
|
Res_LowestPriceTrigger,
|
|
)
|
|
|
|
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 company_id 로 스코프.
|
|
router = APIRouter(prefix="/v1/item", tags=["Item"], responses={404: {"description": "Not found"}})
|
|
|
|
|
|
@router.get(path="/list", response_model=Res_ItemList, summary="상품 목록")
|
|
async def list_items(
|
|
service: ItemService = Depends(),
|
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
search: str | None = Query(None, description="상품명/상품코드 검색"),
|
|
category: str | None = Query(None, description="카테고리 필터"),
|
|
pg: PageParams = Depends(),
|
|
):
|
|
return RemoveNoneResponse(await service.list_items(user_info.company_id, search, category, pg))
|
|
|
|
|
|
@router.get(path="/categories", response_model=Res_ItemCategories, summary="상품 카테고리 목록(distinct)")
|
|
async def list_item_categories(service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
|
return RemoveNoneResponse(await service.list_categories(user_info.company_id))
|
|
|
|
|
|
@router.post(path="/create", response_model=Res_Item, summary="상품 등록")
|
|
async def create_item(req: Req_CreateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
|
return RemoveNoneResponse(
|
|
await service.create_item(user_info.company_id, user_info.user_id, req)
|
|
)
|
|
|
|
|
|
@router.post(path="/check-codes", response_model=Res_CheckCodes, summary="코드 중복 사전검사(업로드 즉시)")
|
|
async def check_item_codes(req: Req_CheckCodes, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
|
return RemoveNoneResponse(await service.check_codes(user_info.company_id, req.codes))
|
|
|
|
|
|
@router.post(path="/image", response_model=Res_ItemImage, summary="상품 이미지 업로드(Azure Blob)")
|
|
async def upload_item_image(
|
|
service: ItemService = Depends(),
|
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
file: UploadFile = File(...),
|
|
):
|
|
return RemoveNoneResponse(await service.upload_image(user_info.company_id, file))
|
|
|
|
|
|
@router.get(path="/{item_id}", response_model=Res_Item, summary="상품 조회")
|
|
async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
|
return RemoveNoneResponse(await service.get_item(user_info.company_id, str(item_id)))
|
|
|
|
|
|
@router.patch(path="/update/{item_id}", response_model=Res_Item, summary="상품 수정")
|
|
async def update_item(
|
|
item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
|
):
|
|
return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req, user_info.user_id, user_info.role))
|
|
|
|
|
|
@router.delete(path="/delete/{item_id}", response_model=Res_DeleteItem, summary="상품 삭제")
|
|
async def delete_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
|
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id), user_info.user_id, user_info.role))
|
|
|
|
|
|
@router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청")
|
|
async def trigger_lowest_price(
|
|
item_id: UUID,
|
|
service: ItemService = Depends(),
|
|
lps: LpsSyncService = Depends(),
|
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
):
|
|
"""상품 1건을 LPS 에 즉시 검색 요청(수동 트리거, manual 우선순위). 결과는 GET lowest-price 폴링."""
|
|
got = await service.get_item(user_info.company_id, str(item_id)) # 존재/소유(company 스코프) 확인
|
|
if got.item is None:
|
|
return RemoveNoneResponse(got)
|
|
status, message = await lps.request_search_for_item(got.item)
|
|
res = Res_LowestPriceTrigger(item_id=str(item_id), status=status, message=message)
|
|
if status == "unavailable":
|
|
res.result.SetResult(ErrorType.LPS_UNAVAILABLE)
|
|
return RemoveNoneResponse(res)
|
|
|
|
|
|
@router.get(path="/{item_id}/lowest-price", response_model=Res_LowestPriceResult, summary="최저가 수집 결과")
|
|
async def get_lowest_price(
|
|
item_id: UUID,
|
|
service: ItemService = Depends(),
|
|
lps: LpsSyncService = Depends(),
|
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
):
|
|
"""대표 최저가(items.internet_lowest_price) + 최근 수집 이력(최신순).
|
|
조회 전에 lps_db 증분 동기화를 한 번 수행해 5분 크론을 기다리지 않는다(멱등·저비용)."""
|
|
got = await service.get_item(user_info.company_id, str(item_id))
|
|
if got.item is None:
|
|
return RemoveNoneResponse(got)
|
|
err, history = await lps.lowest_price_view(item_id)
|
|
res = Res_LowestPriceResult(
|
|
item_id=str(item_id),
|
|
lowest_price=got.item.internet_lowest_price,
|
|
results=[LowestPriceEntry.model_validate(h) for h in history],
|
|
message="" if history else "수집 이력이 없습니다",
|
|
)
|
|
if err != ErrorType.SUCCESS:
|
|
res.result.SetResult(err)
|
|
return RemoveNoneResponse(res)
|