diff --git a/lps-admin/src/api/types.ts b/lps-admin/src/api/types.ts index df55156..e9cc0e4 100644 --- a/lps-admin/src/api/types.ts +++ b/lps-admin/src/api/types.ts @@ -64,6 +64,22 @@ export interface RequeueRes { requeued: boolean; } +/** 몰별 확인 상태 — LPS common.enums.SourceState 와 1:1. 정의는 lps/docs/result-states.md. */ +export type SourceState = + | "matched" // 수집·매칭 성공 + | "no_match" // 수집됐으나 같은 상품이 아님 + | "empty" // 검색 결과 자체가 0건 + | "blocked" // 안티봇 차단 — IP 회전으로 자동 회복 + | "env_blocked" // 회전 무효 — 사람이 환경/설정을 고쳐야 함 + | "unavailable" // 전송 실패·가용 IP 없음 등 일시적 + | "skipped"; // 그 소스를 쓰지 않음 + +export interface SourceInfo { + state: SourceState; + count?: number; // 수집 건수(성공 시) + error?: string; // 실패 사유 원문(운영 진단용) +} + export interface ProductItem { product_code: string; display_name?: string; @@ -74,6 +90,10 @@ export interface ProductItem { final_lowest?: number; final_source?: string; searches: number; + /** 몰별 확인 상태. 운영 화면은 원인까지 봐야 조치를 가른다(검색어 문제 vs IP·환경 문제). */ + sources?: Record; + /** 못 본 몰이 있어 결과가 최종이 아님 */ + partial?: boolean; } export interface ProductListRes { @@ -105,6 +125,9 @@ export interface PricePoint { coupang_name?: string; coupang_url?: string; by_mall?: MallEntry[]; + /** 몰별 확인 상태. by_mall 은 가격이 있는 몰만 담으므로 '못 본 몰'은 여기에만 있다. */ + sources?: Record; + partial?: boolean; } export interface PriceHistoryRes { diff --git a/lps-admin/src/lib/sourceState.ts b/lps-admin/src/lib/sourceState.ts new file mode 100644 index 0000000..2f3478a --- /dev/null +++ b/lps-admin/src/lib/sourceState.ts @@ -0,0 +1,62 @@ +/** + * 몰별 확인 상태의 표기 — **운영자용**(상세). 정의는 lps/docs/result-states.md. + * + * 실사용자 화면(negodata)은 이걸 3가지로 접어서 보여준다. 여기서 접지 않는 이유는 + * 운영자의 목적이 **진단**이기 때문이다: `blocked`(자동 회복)와 `env_blocked`(사람이 고쳐야 함)를 + * 뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치하게 된다. + * + * 색은 전부 index.css @theme 토큰을 참조한다(raw hex 금지 — 토큰을 바꾸면 여기도 함께 바뀌어야 함). + */ +import type { SourceState } from "../api/types"; + +type Meta = { + label: string; + color: string; + /** 한 줄 설명 — '이게 무슨 뜻이고 내가 뭘 해야 하나' */ + desc: string; + /** 그 몰을 실제로 확인했는가. false 면 '없다'고 말하면 안 된다 */ + confirmed: boolean; +}; + +export const SOURCE_STATES: Record = { + matched: { + label: "매칭", color: "var(--color-ok-600)", confirmed: true, + desc: "수집·매칭 성공 — 가격 확보", + }, + no_match: { + label: "같은 상품 없음", color: "var(--color-neutral-400)", confirmed: true, + desc: "수집은 됐으나 같은 상품이 아님 — 검색어·규격을 의심할 것", + }, + empty: { + label: "결과 0건", color: "var(--color-neutral-400)", confirmed: true, + desc: "그 몰의 검색 결과 자체가 0건 — 확인했고 정말 없다", + }, + blocked: { + label: "차단", color: "var(--color-warn-600)", confirmed: false, + desc: "안티봇 차단 — IP 회전으로 자동 회복된다. 반복되면 IP 풀·예산 확인", + }, + env_blocked: { + label: "환경 차단", color: "var(--color-dead-600)", confirmed: false, + desc: "회전해도 회복 불가 — 사람이 환경/게이트웨이 설정을 고쳐야 한다", + }, + unavailable: { + label: "확인 못함", color: "var(--color-warn-600)", confirmed: false, + desc: "전송 실패·가용 IP 없음 등 일시적 — 잠시 후 재시도로 회복", + }, + skipped: { + label: "미사용", color: "var(--color-neutral-300)", confirmed: true, + desc: "그 소스를 쓰지 않음(폴백 OFF 등)", + }, +}; + +/** 미지의 상태도 화면을 깨뜨리지 않는다 — 값 그대로 보여주고 '모름'으로 취급한다. */ +export const stateMeta = (s: string | undefined): Meta => + SOURCE_STATES[s as SourceState] ?? { + label: s || "-", color: "var(--color-ink-400)", desc: "알 수 없는 상태", confirmed: false, + }; + +/** 못 본 몰 목록 — 결과가 왜 최종이 아닌지 한 줄로 설명할 때 쓴다. */ +export const unconfirmedMalls = (sources?: Record): string[] => + Object.entries(sources ?? {}) + .filter(([, v]) => !stateMeta(v?.state).confirmed) + .map(([mall]) => mall); diff --git a/lps-admin/src/pages/Products.tsx b/lps-admin/src/pages/Products.tsx index 03cfc12..f2151c8 100644 --- a/lps-admin/src/pages/Products.tsx +++ b/lps-admin/src/pages/Products.tsx @@ -6,7 +6,8 @@ import { Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { get, post } from "../api/client"; -import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types"; +import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes, SourceInfo } from "../api/types"; +import { stateMeta, unconfirmedMalls } from "../lib/sourceState"; import { Button, Card, Empty, ErrorNote, Legend, Loading, PageHeader, ScrollBox, ScrollTable, SearchForm, Segmented } from "../components/ui"; import { gridProps, xAxisProps, yAxisProps } from "../lib/chart"; import { dateShort, timeAgo, won } from "../lib/format"; @@ -83,7 +84,17 @@ export default function Products() {
{p.product_code} · 검색 {p.searches}회 - {timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""} + + {/* 못 본 몰이 있으면 이 값은 최종이 아니다 — 가격 옆에서 바로 보여야 오해가 없다 */} + {p.partial && ( + + 일부 확인 못함 + + )} + {timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""} +
@@ -215,6 +226,39 @@ const MALL_COLS = [ const srcColor = (s?: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)"; const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타"); +/** + * 몰별 확인 상태 — 아래 가격표가 **왜 그렇게 생겼는지**를 설명한다. + * 가격표에 없는 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는 이 줄에만 있다. + * 운영 화면이라 상태를 접지 않는다: blocked(자동 회복)와 env_blocked(사람이 고쳐야 함)는 조치가 다르다. + */ +function SourceStates({ sources, partial }: { sources?: Record; partial?: boolean }) { + const entries = Object.entries(sources ?? {}); + if (entries.length === 0) return null; // 이 컬럼 추가 이전 이력 — 조용히 숨긴다 + return ( +
+
+ {entries.map(([mall, info]) => { + const m = stateMeta(info?.state); + return ( + + + {mall} + {m.label} + {info?.count != null && {info.count}건} + + ); + })} +
+ {partial && ( +

+ {unconfirmedMalls(sources).join("·")} 을(를) 확인하지 못했습니다 — 이 최저가는 최종이 아닙니다. +

+ )} +
+ ); +} + function MallCompare({ point }: { point: PricePoint }) { const [topN, setTopN] = useState(5); const malls = (point.by_mall ?? []) @@ -230,6 +274,7 @@ function MallCompare({ point }: { point: PricePoint }) {

{dateShort(point.triggered_at)} 기준 · 그래프의 시점을 클릭해 이동

+ {shown.length === 0 ? (
몰별 데이터 없음
) : ( diff --git a/lps/crud/price_history.py b/lps/crud/price_history.py index 09cd206..2bc542f 100644 --- a/lps/crud/price_history.py +++ b/lps/crud/price_history.py @@ -59,7 +59,7 @@ class PriceHistory: SELECT triggered_at, outcome, matched_count, naver_lowest, naver_name, naver_url, coupang_lowest, coupang_name, coupang_url, - final_lowest, final_source, by_mall + final_lowest, final_source, by_mall, sources, partial FROM price_history WHERE product_code = :pc ORDER BY triggered_at DESC @@ -80,7 +80,7 @@ class PriceHistory: sql = text(f""" SELECT * FROM ( SELECT DISTINCT ON (product_code) - product_code, triggered_at, outcome, + product_code, triggered_at, outcome, sources, partial, naver_lowest, coupang_lowest, final_lowest, final_source, COALESCE(naver_name, coupang_name) AS display_name, count(*) OVER (PARTITION BY product_code) AS searches diff --git a/lps/docs/result-states.md b/lps/docs/result-states.md index 07f52bb..4628667 100644 --- a/lps/docs/result-states.md +++ b/lps/docs/result-states.md @@ -187,11 +187,24 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata 검증(실 DB): 쿠팡 차단과 쿠팡 0건은 `by_mall` 이 둘 다 `['naver']` 로 같지만 `partial`(true/false)과 `sources.coupang.state`(blocked/empty)가 두 경우를 갈라낸다. 테스트 3건 추가. +**3단계 — lps-admin 상세 표시 ✅ 완료** (`feat/source-state`) + +운영자 목적은 **진단**이라 상태를 접지 않는다 — `blocked`(자동 회복)와 `env_blocked`(사람이 +고쳐야 함)를 뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치한다. + +- API: `/v1/lps/products` 와 `/v1/lps/products/{code}/history` 둘 다 `sources`·`partial` 을 싣는다. + **이력은 시점마다** 실린다 — 최신 상태를 과거 시점 옆에 붙이면 오해를 부르기 때문이다. +- `lps-admin/src/lib/sourceState.ts`: 상태별 라벨·색·설명·`confirmed` 를 한곳에 둔다. + 미지의 상태가 와도 화면이 깨지지 않는다(값 그대로 표시하고 '모름'으로 취급). +- 상품 목록: 못 본 몰이 있으면 `일부 확인 못함` 배지(툴팁에 어느 몰인지). +- 몰별 비교 카드 위: 몰별 상태 줄 + 수집 건수 + 실패 사유 원문(툴팁). 가격표에 없는 몰이 + **왜** 없는지를 여기서 답한다. + +검증: ASGI 직접 호출로 두 엔드포인트 모두 `sources`·`partial` 확인(한글 사유 포함). +`tsc` 오류 없음. 테스트 3건 추가(목록 노출 / 시점별 상태 / 옛 행 호환). + **남은 것** -3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만) -4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다 - -이제 읽을 데이터가 생겼으므로 3·4 는 **각자 다르게 접기만** 하면 된다 — 순서 없이 병행 가능하다. +4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다 — 사용자 화면은 3가지로 접는다(3-2절) > 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다. diff --git a/lps/router/v1/lps/admin_protocol.py b/lps/router/v1/lps/admin_protocol.py index c0033fc..206ff65 100644 --- a/lps/router/v1/lps/admin_protocol.py +++ b/lps/router/v1/lps/admin_protocol.py @@ -45,6 +45,10 @@ class ProductItem(BaseModel): final_lowest: Optional[int] = None final_source: Optional[str] = None searches: int = Field(0, description="누적 검색(이력) 수") + # 운영 화면은 **원인까지** 봐야 한다 — '그 몰에 없었다'와 '그 몰을 못 봤다'는 조치가 다르다 + # (전자는 검색어 문제, 후자는 IP·환경 문제). 사용자 화면은 이걸 접어서 보여준다. + sources: Optional[dict] = Field(None, description="몰별 확인 상태 — {몰: {state, count|error}}. state=SourceState") + partial: bool = Field(False, description="못 본 몰이 있어 결과가 최종이 아님") class Res_ProductList(Res_WebPacketProtocol): diff --git a/lps/router/v1/lps/protocol.py b/lps/router/v1/lps/protocol.py index 8deae0c..8113d7c 100644 --- a/lps/router/v1/lps/protocol.py +++ b/lps/router/v1/lps/protocol.py @@ -61,6 +61,9 @@ class PricePoint(BaseModel): coupang_name: Optional[str] = None coupang_url: Optional[str] = None by_mall: Optional[list[dict]] = Field(None, description="몰별 최저가 스냅샷(G마켓·옥션·11번가 등 포함)") + # by_mall 은 **가격이 있는 몰만** 담는다 — 빠진 몰이 '없었다'인지 '못 봤다'인지는 아래에만 있다. + sources: Optional[dict] = Field(None, description="몰별 확인 상태 — {몰: {state, count|error}}") + partial: bool = Field(False, description="못 본 몰이 있어 이 시점 결과가 최종이 아님") class Res_PriceHistory(Res_WebPacketProtocol): diff --git a/lps/services/admin_service.py b/lps/services/admin_service.py index 4f9e22b..9317939 100644 --- a/lps/services/admin_service.py +++ b/lps/services/admin_service.py @@ -87,6 +87,7 @@ class AdminService: 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 diff --git a/lps/services/lps_service.py b/lps/services/lps_service.py index 47d29f8..d1fedc8 100644 --- a/lps/services/lps_service.py +++ b/lps/services/lps_service.py @@ -95,6 +95,7 @@ class LpsService: naver_name=r["naver_name"], naver_url=r["naver_url"], coupang_name=r["coupang_name"], coupang_url=r["coupang_url"], by_mall=r.get("by_mall"), + sources=r.get("sources"), partial=bool(r.get("partial")), ) for r in rows ] diff --git a/lps/tests/test_admin_api.py b/lps/tests/test_admin_api.py index 9205377..3fb33c7 100644 --- a/lps/tests/test_admin_api.py +++ b/lps/tests/test_admin_api.py @@ -95,6 +95,52 @@ async def test_products_list_latest_snapshot(client, clean_all): assert len(r.json()["items"]) == 1 +# ---- 몰별 확인 상태 (2026-08-07, 3단계) ------------------------------------- +# 운영 화면은 '왜 그 몰 값이 없나'에 답할 수 있어야 한다 — by_mall 에 없는 몰이 +# '거기엔 없더라'인지 '거기를 못 봤다'인지는 sources 에만 있다. + +async def test_products_list_exposes_source_states(client, clean_all): + async with clean_all.begin() as conn: + await conn.execute(text(""" + INSERT INTO price_history (product_code, outcome, final_lowest, partial, sources, triggered_at) + VALUES ('S1', 'found', 9000, true, + '{"naver": {"state": "matched", "count": 40}, + "coupang": {"state": "env_blocked", "error": "사용권한이 제한된"}}'::jsonb, + now()) + """)) + item = (await client.get("/v1/lps/products")).json()["items"][0] + assert item["partial"] is True + assert item["sources"]["coupang"]["state"] == "env_blocked" + assert "사용권한이 제한된" in item["sources"]["coupang"]["error"] # 원인 원문이 운영자에게 간다 + + +async def test_history_points_carry_state_per_point(client, clean_all): + """상태는 시점마다 다르다 — 최신 상태를 과거 시점 옆에 붙이면 오해를 부른다.""" + async with clean_all.begin() as conn: + await conn.execute(text(""" + INSERT INTO price_history (product_code, outcome, final_lowest, partial, sources, triggered_at) + VALUES ('S2', 'found', 9000, true, + '{"coupang": {"state": "blocked"}}'::jsonb, now() - interval '1 hour'), + ('S2', 'found', 8500, false, + '{"coupang": {"state": "matched", "count": 60}}'::jsonb, now()) + """)) + pts = (await client.get("/v1/lps/products/S2/history")).json()["points"] + assert [p["partial"] for p in pts] == [True, False] + assert pts[0]["sources"]["coupang"]["state"] == "blocked" + assert pts[1]["sources"]["coupang"]["state"] == "matched" + + +async def test_old_rows_without_state_still_work(client, clean_all): + """이 컬럼 추가 이전 이력도 그대로 읽혀야 한다(마이그레이션 전 데이터).""" + async with clean_all.begin() as conn: + await conn.execute(text(""" + INSERT INTO price_history (product_code, outcome, final_lowest, triggered_at) + VALUES ('S3', 'found', 7000, now()) + """)) + item = (await client.get("/v1/lps/products")).json()["items"][0] + assert item["partial"] is False and item.get("sources") is None + + # ---- 통계 3종 --------------------------------------------------------------- async def test_ip_session_stats(client, clean_all):