feat(lps-admin): 3단계 — 몰별 확인 상태를 운영 화면에 노출

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>
This commit is contained in:
민헌 2026-08-07 11:05:31 +09:00
parent 74db218410
commit d1d1ed6ec5
10 changed files with 206 additions and 8 deletions

View File

@ -64,6 +64,22 @@ export interface RequeueRes {
requeued: boolean; 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 { export interface ProductItem {
product_code: string; product_code: string;
display_name?: string; display_name?: string;
@ -74,6 +90,10 @@ export interface ProductItem {
final_lowest?: number; final_lowest?: number;
final_source?: string; final_source?: string;
searches: number; searches: number;
/** 몰별 확인 상태. 운영 화면은 원인까지 봐야 조치를 가른다(검색어 문제 vs IP·환경 문제). */
sources?: Record<string, SourceInfo>;
/** 못 본 몰이 있어 결과가 최종이 아님 */
partial?: boolean;
} }
export interface ProductListRes { export interface ProductListRes {
@ -105,6 +125,9 @@ export interface PricePoint {
coupang_name?: string; coupang_name?: string;
coupang_url?: string; coupang_url?: string;
by_mall?: MallEntry[]; by_mall?: MallEntry[];
/** 몰별 확인 상태. by_mall 은 가격이 있는 몰만 담으므로 '못 본 몰'은 여기에만 있다. */
sources?: Record<string, SourceInfo>;
partial?: boolean;
} }
export interface PriceHistoryRes { export interface PriceHistoryRes {

View File

@ -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<SourceState, Meta> = {
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, { state: string }>): string[] =>
Object.entries(sources ?? {})
.filter(([, v]) => !stateMeta(v?.state).confirmed)
.map(([mall]) => mall);

View File

@ -6,7 +6,8 @@ import {
Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts"; } from "recharts";
import { get, post } from "../api/client"; 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 { Button, Card, Empty, ErrorNote, Legend, Loading, PageHeader, ScrollBox, ScrollTable, SearchForm, Segmented } from "../components/ui";
import { gridProps, xAxisProps, yAxisProps } from "../lib/chart"; import { gridProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, timeAgo, won } from "../lib/format"; import { dateShort, timeAgo, won } from "../lib/format";
@ -83,7 +84,17 @@ export default function Products() {
</div> </div>
<div className="mt-0.5 flex justify-between text-[11px] text-ink-400"> <div className="mt-0.5 flex justify-between text-[11px] text-ink-400">
<span>{p.product_code} · {p.searches}</span> <span>{p.product_code} · {p.searches}</span>
<span>{timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""}</span> <span className="flex items-center gap-1">
{/* 못 본 몰이 있으면 이 값은 최종이 아니다 — 가격 옆에서 바로 보여야 오해가 없다 */}
{p.partial && (
<span className="rounded px-1 font-semibold text-warn-600"
style={{ background: "color-mix(in srgb, var(--color-warn-600) 12%, transparent)" }}
title={`확인 못한 몰: ${unconfirmedMalls(p.sources).join(", ")}`}>
</span>
)}
{timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""}
</span>
</div> </div>
</button> </button>
</li> </li>
@ -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 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 || "기타"); const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타");
/**
* ** ** .
* '거기엔 없더라' '거기를 못 봤다' .
* 않는다: blocked( ) env_blocked( ) .
*/
function SourceStates({ sources, partial }: { sources?: Record<string, SourceInfo>; partial?: boolean }) {
const entries = Object.entries(sources ?? {});
if (entries.length === 0) return null; // 이 컬럼 추가 이전 이력 — 조용히 숨긴다
return (
<div className="mb-2 shrink-0 space-y-1 rounded border border-line-100 bg-surface-2 px-2 py-1.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{entries.map(([mall, info]) => {
const m = stateMeta(info?.state);
return (
<span key={mall} className="inline-flex items-center gap-1 text-[11px]"
title={`${m.desc}${info?.error ? `\n\n${info.error}` : ""}`}>
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: m.color }} aria-hidden />
<span className="font-semibold text-ink-500">{mall}</span>
<span style={{ color: m.color }}>{m.label}</span>
{info?.count != null && <span className="tnum text-ink-400">{info.count}</span>}
</span>
);
})}
</div>
{partial && (
<p className="text-[11px] leading-snug text-warn-600">
{unconfirmedMalls(sources).join("·")} () .
</p>
)}
</div>
);
}
function MallCompare({ point }: { point: PricePoint }) { function MallCompare({ point }: { point: PricePoint }) {
const [topN, setTopN] = useState<number | "all">(5); const [topN, setTopN] = useState<number | "all">(5);
const malls = (point.by_mall ?? []) const malls = (point.by_mall ?? [])
@ -230,6 +274,7 @@ function MallCompare({ point }: { point: PricePoint }) {
<p className="mb-1.5 shrink-0 text-[11px] text-ink-400"> <p className="mb-1.5 shrink-0 text-[11px] text-ink-400">
<span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> · <span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> ·
</p> </p>
<SourceStates sources={point.sources} partial={point.partial} />
{shown.length === 0 ? ( {shown.length === 0 ? (
<div className="grid h-full place-items-center"><Empty> </Empty></div> <div className="grid h-full place-items-center"><Empty> </Empty></div>
) : ( ) : (

View File

@ -59,7 +59,7 @@ class PriceHistory:
SELECT triggered_at, outcome, matched_count, SELECT triggered_at, outcome, matched_count,
naver_lowest, naver_name, naver_url, naver_lowest, naver_name, naver_url,
coupang_lowest, coupang_name, coupang_url, coupang_lowest, coupang_name, coupang_url,
final_lowest, final_source, by_mall final_lowest, final_source, by_mall, sources, partial
FROM price_history FROM price_history
WHERE product_code = :pc WHERE product_code = :pc
ORDER BY triggered_at DESC ORDER BY triggered_at DESC
@ -80,7 +80,7 @@ class PriceHistory:
sql = text(f""" sql = text(f"""
SELECT * FROM ( SELECT * FROM (
SELECT DISTINCT ON (product_code) 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, naver_lowest, coupang_lowest, final_lowest, final_source,
COALESCE(naver_name, coupang_name) AS display_name, COALESCE(naver_name, coupang_name) AS display_name,
count(*) OVER (PARTITION BY product_code) AS searches count(*) OVER (PARTITION BY product_code) AS searches

View File

@ -187,11 +187,24 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata
검증(실 DB): 쿠팡 차단과 쿠팡 0건은 `by_mall` 이 둘 다 `['naver']` 로 같지만 검증(실 DB): 쿠팡 차단과 쿠팡 0건은 `by_mall` 이 둘 다 `['naver']` 로 같지만
`partial`(true/false)과 `sources.coupang.state`(blocked/empty)가 두 경우를 갈라낸다. 테스트 3건 추가. `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가지로 접는다(3-2절)
4. negodata 가 ``(없음)와 `확인 못함`(미확인)을 구분하지 못한다
이제 읽을 데이터가 생겼으므로 3·4 는 **각자 다르게 접기만** 하면 된다 — 순서 없이 병행 가능하다.
> 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다. > 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다.

View File

@ -45,6 +45,10 @@ class ProductItem(BaseModel):
final_lowest: Optional[int] = None final_lowest: Optional[int] = None
final_source: Optional[str] = None final_source: Optional[str] = None
searches: int = Field(0, description="누적 검색(이력) 수") 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): class Res_ProductList(Res_WebPacketProtocol):

View File

@ -61,6 +61,9 @@ class PricePoint(BaseModel):
coupang_name: Optional[str] = None coupang_name: Optional[str] = None
coupang_url: Optional[str] = None coupang_url: Optional[str] = None
by_mall: Optional[list[dict]] = Field(None, description="몰별 최저가 스냅샷(G마켓·옥션·11번가 등 포함)") 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): class Res_PriceHistory(Res_WebPacketProtocol):

View File

@ -87,6 +87,7 @@ class AdminService:
naver_lowest=r.get("naver_lowest"), coupang_lowest=r.get("coupang_lowest"), naver_lowest=r.get("naver_lowest"), coupang_lowest=r.get("coupang_lowest"),
final_lowest=r.get("final_lowest"), final_source=r.get("final_source"), final_lowest=r.get("final_lowest"), final_source=r.get("final_source"),
searches=int(r.get("searches") or 0), 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)] ) for r in await self.history.list_products(q, limit)]
return res return res

View File

@ -95,6 +95,7 @@ class LpsService:
naver_name=r["naver_name"], naver_url=r["naver_url"], naver_name=r["naver_name"], naver_url=r["naver_url"],
coupang_name=r["coupang_name"], coupang_url=r["coupang_url"], coupang_name=r["coupang_name"], coupang_url=r["coupang_url"],
by_mall=r.get("by_mall"), by_mall=r.get("by_mall"),
sources=r.get("sources"), partial=bool(r.get("partial")),
) )
for r in rows for r in rows
] ]

View File

@ -95,6 +95,52 @@ async def test_products_list_latest_snapshot(client, clean_all):
assert len(r.json()["items"]) == 1 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종 --------------------------------------------------------------- # ---- 통계 3종 ---------------------------------------------------------------
async def test_ip_session_stats(client, clean_all): async def test_ip_session_stats(client, clean_all):