o2o-negosium-original/negodata/backend/router/v1/item/item.py
민헌 0cefe315c4 [feat] negodata/backend: 몰별 최저가(by_mall) 노출 + 강제 재검색(force) 연동
몰별 성공/실패 표시
  lps_db.price_history 에는 몰별 정보가 다 있는데(naver_lowest·coupang_lowest·by_mall),
  동기화가 final_source(이긴 몰) 하나만 남기고 나머지를 버려서 API 로는
  "네이버는 어땠는지"를 알 수 없었다. 실제로 네이버는 빈손이고 쿠팡만 성공하는
  케이스가 기본값처럼 나오는 중이라 화면에 드러낼 필요가 있다.
  → price_history.by_mall(JSONB)을 partner.item_internet_lowest_prices 로 그대로
    미러링한다(열린 스키마 — 오픈마켓 폴백이 늘어도 스키마 변경 불필요).
  - crud: 읽기 계약에 by_mall 추가 + SELECT 포함
  - model: item_internet_lowest_prices.by_mall
  - service: 언팩·저장
  - protocol: LowestPriceEntry.by_mall
  - init.sql: 테이블 정의 + 하단 보정 ALTER(기존 DB 반영용)

강제 재검색
  POST /v1/item/{id}/lowest-price?force=true → LPS 네거티브 캐시 우회.
  사용자가 '다시 검색'을 누른 경우에만 true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:55:38 +09:00

124 lines
5.7 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),
force: bool = Query(False, description="LPS 네거티브 캐시(24h not_found)를 무시하고 재검색. 사용자가 '다시 검색'을 누른 경우만 true"),
):
"""상품 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, force=force)
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)