2단계에서 저장한 sources/partial 을 운영자가 볼 수 있게 한다. 운영자 목적은 **진단**이라
상태를 접지 않는다 — blocked(IP 회전으로 자동 회복)와 env_blocked(사람이 환경·설정을 고쳐야 함)를
뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치하게 된다.
API
- /v1/lps/products, /v1/lps/products/{code}/history 둘 다 sources·partial 을 싣는다.
- 이력은 **시점마다** 싣는다. 최신 상태를 과거 시점의 몰별 표 옆에 붙이면 '그때도 막혔던 것처럼'
보여 오해를 부른다 — 그래서 ProductItem 이 아니라 PricePoint 에 담았다.
화면
- lib/sourceState.ts: 상태별 라벨·색·설명·confirmed 를 한곳에. 미지의 상태가 와도 화면이 깨지지
않는다(값 그대로 표시 + '모름' 취급). 색은 전부 @theme 토큰 참조(raw hex 금지).
- 상품 목록: partial 이면 '일부 확인 못함' 배지 + 툴팁에 어느 몰인지.
- 몰별 비교 카드 위: 몰별 상태·수집 건수·실패 사유 원문(툴팁). 가격표에 없는 몰이 **왜** 없는지를
여기서 답한다 — by_mall 은 가격이 있는 몰만 담으므로 그 답이 여기밖에 없다.
검증: ASGI 직접 호출로 두 엔드포인트 응답 확인(한글 사유 포함), tsc 오류 없음.
테스트 3건 추가(목록 노출 / 시점별 상태가 각각 다르게 / 컬럼 추가 이전 옛 행 호환).
전체 292 passed. 진행 상황은 docs/result-states.md 4절.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
4.9 KiB
Python
118 lines
4.9 KiB
Python
"""관리자 FE 도메인 로직 — 조회 중심 + 필수 액션(DEAD 재큐)만.
|
|
|
|
설정 변경(예산·임계 등)은 FE 에 두지 않는다 — 설정은 toml 단일 소스(재시작 반영) 원칙.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from fastapi import Depends
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from common.enums import ErrorType, JobStatus
|
|
from crud.bot_detection import BotDetectionLog
|
|
from crud.ip_session import IpSessionLog
|
|
from crud.job_crud import JobQueue
|
|
from crud.price_history import PriceHistory
|
|
from router.v1.lps.admin_protocol import (
|
|
BotRow,
|
|
CostBucket,
|
|
HistogramBin,
|
|
HourlyCount,
|
|
IpSessionRow,
|
|
JobListItem,
|
|
ProductItem,
|
|
Res_BotStats,
|
|
Res_CostStats,
|
|
Res_IpSessionStats,
|
|
Res_JobList,
|
|
Res_ProductList,
|
|
Res_Requeue,
|
|
)
|
|
|
|
_STATUS_BY_NAME = {js.name: js.value for js in JobStatus}
|
|
|
|
|
|
class AdminService:
|
|
def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory),
|
|
ip_log: IpSessionLog = Depends(IpSessionLog), bot_log: BotDetectionLog = Depends(BotDetectionLog)):
|
|
self.queue = queue
|
|
self.history = history
|
|
self.ip_log = ip_log
|
|
self.bot_log = bot_log
|
|
|
|
async def list_jobs(self, status: str | None, q: str | None, limit: int, offset: int) -> Res_JobList:
|
|
res = Res_JobList()
|
|
status_code = _STATUS_BY_NAME.get(status.upper()) if status else None
|
|
if status and status_code is None:
|
|
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND) # 알 수 없는 상태 이름
|
|
return res
|
|
rows, total = await self.queue.list_jobs(status_code, q, limit, offset)
|
|
res.total = total
|
|
res.items = [JobListItem(
|
|
job_id=r["job_id"], job_type=r["job_type"], status=JobStatus(r["status"]).name,
|
|
priority=r["priority"], attempts=r["attempts"], max_attempts=r["max_attempts"],
|
|
product_code=r.get("product_code"), product_name=r.get("product_name"),
|
|
outcome=r.get("outcome"), final_lowest=r.get("final_lowest"), cost_usd=r.get("cost_usd"),
|
|
last_error=r.get("last_error"),
|
|
created_at=r["created_at"].isoformat(timespec="seconds"),
|
|
run_started_at=r["run_started_at"].isoformat(timespec="seconds") if r.get("run_started_at") else None,
|
|
updated_at=r["updated_at"].isoformat(timespec="seconds"),
|
|
) for r in rows]
|
|
return res
|
|
|
|
async def requeue(self, job_id: str) -> Res_Requeue:
|
|
res = Res_Requeue(job_id=job_id)
|
|
try:
|
|
uuid.UUID(job_id)
|
|
except (ValueError, TypeError):
|
|
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND)
|
|
return res
|
|
try:
|
|
requeued = await self.queue.requeue(job_id)
|
|
except IntegrityError:
|
|
# 같은 상품의 활성 잡(PENDING/RUNNING)이 이미 있음 — 부분 유니크(dedupe) 위반
|
|
res.result.SetResult(ErrorType.DB_ALREADY_SAME_KEY)
|
|
return res
|
|
if requeued is None: # 없거나 DEAD 가 아님
|
|
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND)
|
|
return res
|
|
res.requeued = True
|
|
return res
|
|
|
|
async def list_products(self, q: str | None, limit: int) -> Res_ProductList:
|
|
res = Res_ProductList()
|
|
res.items = [ProductItem(
|
|
product_code=r["product_code"], display_name=r.get("display_name"),
|
|
triggered_at=r["triggered_at"].isoformat(timespec="seconds"), outcome=r["outcome"],
|
|
naver_lowest=r.get("naver_lowest"), coupang_lowest=r.get("coupang_lowest"),
|
|
final_lowest=r.get("final_lowest"), final_source=r.get("final_source"),
|
|
searches=int(r.get("searches") or 0),
|
|
sources=r.get("sources"), partial=bool(r.get("partial")),
|
|
) for r in await self.history.list_products(q, limit)]
|
|
return res
|
|
|
|
async def ip_session_stats(self, hours: int) -> Res_IpSessionStats:
|
|
res = Res_IpSessionStats()
|
|
st = await self.ip_log.admin_stats(hours)
|
|
res.by_reason = st["by_reason"]
|
|
res.histogram = [HistogramBin(**b) for b in st["histogram"]]
|
|
res.block_min_requests = st["block_min_requests"]
|
|
res.sessions = [IpSessionRow(
|
|
**{**s, "created_at": s["created_at"].isoformat(timespec="seconds")}
|
|
) for s in st["sessions"]]
|
|
return res
|
|
|
|
async def bot_stats(self, hours: int) -> Res_BotStats:
|
|
res = Res_BotStats()
|
|
st = await self.bot_log.admin_stats(hours)
|
|
res.hourly = [HourlyCount(bucket=h["bucket"], count=h["count"]) for h in st["hourly"]]
|
|
res.items = [BotRow(
|
|
**{**b, "created_at": b["created_at"].isoformat(timespec="seconds")}
|
|
) for b in st["items"]]
|
|
return res
|
|
|
|
async def cost_stats(self, hours: int) -> Res_CostStats:
|
|
res = Res_CostStats()
|
|
res.buckets = [CostBucket(**b) for b in await self.queue.cost_buckets(hours)]
|
|
return res
|