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:
parent
74db218410
commit
d1d1ed6ec5
@ -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<string, SourceInfo>;
|
||||
/** 못 본 몰이 있어 결과가 최종이 아님 */
|
||||
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<string, SourceInfo>;
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
export interface PriceHistoryRes {
|
||||
|
||||
62
lps-admin/src/lib/sourceState.ts
Normal file
62
lps-admin/src/lib/sourceState.ts
Normal 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);
|
||||
@ -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() {
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-between text-[11px] text-ink-400">
|
||||
<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>
|
||||
</button>
|
||||
</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 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 }) {
|
||||
const [topN, setTopN] = useState<number | "all">(5);
|
||||
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">
|
||||
<span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> 기준 · 그래프의 시점을 클릭해 이동
|
||||
</p>
|
||||
<SourceStates sources={point.sources} partial={point.partial} />
|
||||
{shown.length === 0 ? (
|
||||
<div className="grid h-full place-items-center"><Empty>몰별 데이터 없음</Empty></div>
|
||||
) : (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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절이 소스다.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user