Merge commit 'dc8c288f' — lps-admin 신설·LPS 운영기능(ip_session/alerts/admin API)·최저가 출처링크 반영
This commit is contained in:
commit
b539e073cb
21
.env.example
21
.env.example
@ -1,21 +0,0 @@
|
||||
# docker compose 용 환경변수 템플릿 — 복사해서 사용: cp .env.example .env
|
||||
# 실제 값(.env)은 커밋하지 않는다(.gitignore). 이미지에는 시크릿이 없으므로(lps 는 example
|
||||
# config 로 빌드) 아래 값이 없으면 해당 기능이 꺼진 채 뜬다(주석 참고).
|
||||
|
||||
# ── LPS DB (미설정 시 postgres/postgres) ──
|
||||
LPS_DB_USER=postgres
|
||||
LPS_DB_PASSWORD=postgres
|
||||
|
||||
# ── LPS API 스케일 (미설정 시 1 / 40 — 단일 프로세스로도 ~1,100 RPS) ──
|
||||
LPS_API_PROCESS_COUNT=1 # uvicorn 프로세스 수(=사용 코어 수). 커넥션 풀은 예산에서 자동 역산
|
||||
LPS_DB_CONNECTION_BUDGET=40 # lps API 커넥션 총예산. 공유 PG=40, 전용 PG(max_conn≈100)=90
|
||||
|
||||
# ── LPS 워커 시크릿 ──
|
||||
OPENAI_API_KEY= # 비면 AI 유사도 판정 OFF
|
||||
NAVER_KEYS= # "id1:secret1,id2:secret2" — 네이버 쇼핑 오픈API 키(여러 개면 로테이션)
|
||||
DECODO_HOST= # 예: gate.decodo.com — DECODO 4종이 비면 프록시 미사용(직접 연결)
|
||||
DECODO_USERNAME=
|
||||
DECODO_PASSWORD=
|
||||
DECODO_PORT_START=0 # 예: 10001
|
||||
DECODO_PORT_END=0 # 예: 10010
|
||||
DECODO_COST_PER_GB=0 # 요금($/GB) — 검색 원가 계측용(예: 3.0)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,3 +30,4 @@ CLAUDE.md
|
||||
/new.md
|
||||
|
||||
/mobile.mov
|
||||
.gstack/
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
# 솔루션 랜딩: http://localhost:3100
|
||||
# agent 서버: http://localhost:9500/docs
|
||||
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
|
||||
# lps API: http://localhost:9600/docs
|
||||
# lps admin: http://localhost:3400 (nginx → /v1·/healthz 는 lps-api 로 프록시)
|
||||
#
|
||||
# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다.
|
||||
# psql -h <host> -p <port> -U <user> -f postgres-init/00-init.sql (스키마 전체: negosium_db + 도메인·learning·anchoring schema)
|
||||
@ -42,6 +44,7 @@ services:
|
||||
# ── LPS(인터넷 최저가) 연동 — 미설정이면 연동 비활성으로 조용히 동작 ──
|
||||
LPS_DB_HOST: host.docker.internal # lps_db 읽기전용(수집 배치·조회 API)
|
||||
LPS_BASE_URL: http://host.docker.internal:9600 # 검색요청 enqueue. lps-api 컨테이너 사용 시 http://lps-api:9600
|
||||
# LPS API guard 키는 negodata 의 config.local.toml [WebServerConfig].lps_api_key 로 관리(개발은 빈값=개방)
|
||||
volumes:
|
||||
- ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨
|
||||
ports:
|
||||
@ -129,10 +132,7 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
# ── LPS (인터넷 최저가 검색) ──────────────────────────────────
|
||||
# API(요청 접수, lean) + 워커(크롤, 헤드풀 Chromium+Xvfb). DB 는 외부(host.docker.internal).
|
||||
# 이미지엔 시크릿이 없다(example config 로 빌드) — 실값은 아래 env 로 주입.
|
||||
# 시크릿 값은 리포 루트 .env 파일에 채운다(.env.example 참고, .env 는 미커밋).
|
||||
# LPS API (인터넷 최저가 검색 — 요청 접수/조회, lean). 설정·시크릿은 config.local.toml 하나(미커밋, 마운트).
|
||||
lps-api:
|
||||
build:
|
||||
context: ./lps
|
||||
@ -140,18 +140,12 @@ services:
|
||||
container_name: lps-api
|
||||
environment:
|
||||
APP_ENV: local
|
||||
DB_HOST: host.docker.internal # 컨테이너→호스트 DB (example toml 의 127.0.0.1 override)
|
||||
DB_USER: ${LPS_DB_USER:-postgres}
|
||||
DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres}
|
||||
DB_HOST: ${LPS_DB_HOST-host.docker.internal} # 컨테이너→호스트 DB (config.local.toml 의 127.0.0.1 override)
|
||||
PYTHONUNBUFFERED: "1"
|
||||
# 멀티코어: PROCESS_COUNT(uvicorn 워커=코어수)를 올리면 커넥션 풀은 자동 산정된다.
|
||||
# (pool+overflow)×2엔진×PROCESS_COUNT ≤ DB_CONNECTION_BUDGET 를 config 가 스스로 보장.
|
||||
# 값은 .env 에서 서버별로 조정(compose 수정 불필요). API 병목은 드묾 — 기본 1이면 충분,
|
||||
# 부하테스트/대량 폴링 대비 시에만 코어 수만큼 상향(예: 4).
|
||||
PROCESS_COUNT: ${LPS_API_PROCESS_COUNT:-1}
|
||||
DB_CONNECTION_BUDGET: ${LPS_DB_CONNECTION_BUDGET:-40} # 전용 PG(max_connections≈100)면 90 근처로 상향
|
||||
volumes:
|
||||
- ./lps/config/config.local.toml:/app/config/config.local.toml:ro
|
||||
ports:
|
||||
- "9600:9600"
|
||||
- "${LPS_API_BIND:-0.0.0.0}:9600:9600" # prod 는 LPS_API_BIND=127.0.0.1 로 내부만 개방(리버스프록시 뒤)
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
labels:
|
||||
@ -161,38 +155,40 @@ services:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "5" }
|
||||
|
||||
# LPS 워커 (크롤 — 헤드풀 Chromium+Xvfb, headless 는 안티봇에 탐지됨). config.local.toml 공유.
|
||||
lps-worker:
|
||||
build:
|
||||
context: ./lps
|
||||
dockerfile: Dockerfile.worker # Chromium + Xvfb (headless 는 안티봇에 탐지됨)
|
||||
dockerfile: Dockerfile.worker
|
||||
container_name: lps-worker
|
||||
environment:
|
||||
APP_ENV: local
|
||||
DB_HOST: host.docker.internal
|
||||
DB_USER: ${LPS_DB_USER:-postgres}
|
||||
DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres}
|
||||
DB_HOST: ${LPS_DB_HOST-host.docker.internal} # 컨테이너→호스트 DB (config.local.toml 의 127.0.0.1 override)
|
||||
PYTHONUNBUFFERED: "1"
|
||||
WORKER_CONCURRENCY: "1" # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 4×N)
|
||||
LPS_PROFILE_DIR: /profiles # Chrome 프로필을 영속 볼륨에 → 재시작해도 cf_clearance 유지(재웜업 회피)
|
||||
# LPS_FALLBACKS: "gmarket,auction,st11" # 오픈마켓 폴백(기본 OFF — 켜기 전 라이브 스모크로 셀렉터 점검)
|
||||
# LPS_JOB_DEADLINE_SEC: "300" # 잡 1건 처리 상한(행 방어) — 기본 300s
|
||||
# ── 시크릿 주입(이미지엔 없음 — 필수). 리포 루트 .env 에 값 채움(.env.example 참고) ──
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-} # 비면 AI 판정 OFF
|
||||
NAVER_KEYS: ${NAVER_KEYS:-} # "id1:secret1,id2:secret2" — 비면 네이버 검색 실패
|
||||
DECODO_HOST: ${DECODO_HOST:-} # DECODO 4종 비면 프록시 미사용(직접 연결)
|
||||
DECODO_USERNAME: ${DECODO_USERNAME:-}
|
||||
DECODO_PASSWORD: ${DECODO_PASSWORD:-}
|
||||
DECODO_PORT_START: ${DECODO_PORT_START:-0}
|
||||
DECODO_PORT_END: ${DECODO_PORT_END:-0}
|
||||
DECODO_COST_PER_GB: ${DECODO_COST_PER_GB:-0}
|
||||
volumes:
|
||||
- ./lps/config/config.local.toml:/app/config/config.local.toml:ro
|
||||
- lps-profiles:/profiles # Chrome 프로필(쿠키) 영속
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시
|
||||
stop_grace_period: 75s # graceful 종료 유예(LPS_SHUTDOWN_GRACE_SEC=60 + 정리 여유) — 기본 10s 면 하던 잡 마무리 전에 SIGKILL
|
||||
stop_grace_period: 75s # graceful 종료 유예(worker shutdown_grace_sec=60 + 여유)
|
||||
labels:
|
||||
autoheal: "true" # 하트비트 HEALTHCHECK 실패(행/좀비) 시 autoheal 이 재시작
|
||||
autoheal: "true" # 하트비트 실패(행/좀비) 시 autoheal 재시작
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "5" }
|
||||
|
||||
# LPS 관리자 UI (정적 React → nginx). /v1·/healthz·/readyz 는 nginx 가 lps-api:9600 으로 프록시(앱은 상대경로 호출).
|
||||
lps-admin:
|
||||
build:
|
||||
context: ./lps-admin
|
||||
dockerfile: Dockerfile
|
||||
container_name: lps-admin
|
||||
ports:
|
||||
- "3400:80"
|
||||
depends_on:
|
||||
- lps-api
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
5
lps-admin/.dockerignore
Normal file
5
lps-admin/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.local
|
||||
tsconfig.tsbuildinfo
|
||||
3
lps-admin/.gitignore
vendored
Normal file
3
lps-admin/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
26
lps-admin/Dockerfile
Normal file
26
lps-admin/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
# LPS 관리자 UI 이미지 — Vite(React) 정적 빌드 → nginx 서빙.
|
||||
# nginx 가 /v1·/healthz·/readyz 를 lps-api:9600 으로 프록시(동일출처)하므로,
|
||||
# 앱은 상대경로로 API 를 호출한다(client.ts baseUrl 빈값) → 빌드 타임 API URL 주입 불필요.
|
||||
# 빌드는 slim(glibc) — alpine(musl)+esbuild 의 ETXTBSY 회피(negodata/front 선례).
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 의존성 먼저 (레이어 캐시). package-lock 고정 설치.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build # tsc -b && vite build → dist/
|
||||
|
||||
# ── 런타임: 정적 서빙 + API 프록시 ──
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# UI 자체 도달성만 확인(index.html) — API 상태는 앱 대시보드가 lps-api 를 직접 폴링한다.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1
|
||||
38
lps-admin/README.md
Normal file
38
lps-admin/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# LPS 관리자 페이지
|
||||
|
||||
LPS(인터넷 최저가 검색)의 운영 지표를 보고 필수 액션(수동 재검색·DEAD 재큐)을 수행하는
|
||||
React 관리자 페이지입니다. 서버 동작 설정은 여기서 바꾸지 않습니다 — 설정 소스는 서버의
|
||||
`config.<env>.toml` 하나(2026-07-13 협의).
|
||||
|
||||
## 실행 (개발)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5174 — /v1·/healthz 는 vite 프록시로 :9600 에 전달(CORS 불필요)
|
||||
```
|
||||
|
||||
LPS API 를 먼저 띄워두세요: `cd ../lps && ./run_local_server.sh`.
|
||||
다른 호스트의 API 를 보려면 `VITE_LPS_URL=http://<host>:9600 npm run dev` 또는
|
||||
앱의 **설정** 페이지에서 API 주소를 지정합니다(guard 켜진 prod 는 API 키도 입력 — X-API-Key 자동 첨부).
|
||||
|
||||
## 페이지
|
||||
|
||||
| 페이지 | 내용 | 데이터 |
|
||||
|---|---|---|
|
||||
| 대시보드 | 큐/차단/비용/DB풀 스탯 타일 + 임계 배너 + 큐 추이(5초 폴링) | `GET /v1/lps/ops` |
|
||||
| 작업 큐 | 목록(상태·검색 필터)·상세(결과/원가/오류)·**DEAD 재큐** | `/v1/lps/jobs`, `/jobs/{id}/requeue` |
|
||||
| 상품·가격 | 가격 추이 3선(네이버·쿠팡·최종)·몰별 최저가·검증 링크·**지금 다시 검색** | `/v1/lps/products`, `/products/{code}/history`, `POST /search` |
|
||||
| 크롤 상태 | IP 세션 종료사유 도넛·요청 수 분포(+차단 시작 기준선 = 예산 튜닝 뷰)·차단 이력 | `/v1/lps/stats/ip-sessions`, `/stats/bot` |
|
||||
| 비용 | 시간별 원가 스택(AI vs 프록시)·건당 평균·평균 소요 | `/v1/lps/stats/cost` |
|
||||
| 테스트 검색 | 임의 상품을 실제 파이프라인에 흘려 셀렉터·프록시·AI 매칭 점검 — 진행 스텝퍼·파이프라인 퍼널·소스별·검색 원가·매칭 상품 | `POST /search` + `GET /jobs/{id}` 폴링 |
|
||||
|
||||
> **테스트 검색은 실제 크롤이라 프록시 비용이 발생**하고 결과가 비용 통계에 집계됩니다. 워커가 떠 있어야
|
||||
> 동작합니다(없으면 대기에서 멈춤). 상품코드는 `TEST-<시각>` 자동 생성 — 작업 큐의 **"테스트만"** 필터로
|
||||
> 걸러볼 수 있고, negodata 는 UUID 코드만 매핑하므로 연동에 영향이 없습니다.
|
||||
|
||||
## 스택·규칙
|
||||
|
||||
- Vite + React + TS + Tailwind v4 + recharts + @tanstack/react-query + react-router (negodata 프론트와 동일 계열, 경량)
|
||||
- 색 토큰은 숫자 스케일(`src/index.css` @theme). 차트 시리즈 색은 **엔터티 고정** —
|
||||
네이버 `#008a43`(적록색약 분리 검증 통과 톤) · 쿠팡 `#f0455b` · 최종 `#4f46e5`. 순서·색 재배정 금지.
|
||||
- 빌드: `npm run build` (tsc 포함) → `dist/`
|
||||
12
lps-admin/index.html
Normal file
12
lps-admin/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LPS 관리자 — 인터넷 최저가 검색</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
lps-admin/nginx.conf
Normal file
51
lps-admin/nginx.conf
Normal file
@ -0,0 +1,51 @@
|
||||
# LPS 관리자 UI — 정적 SPA 서빙 + LPS API 동일출처 프록시.
|
||||
# 브라우저는 이 오리진(:3400)만 보고, /v1·/healthz·/readyz 는 여기서 lps-api:9600 으로 넘긴다.
|
||||
# (vite dev 서버의 proxy 설정과 동일한 대상·경로 — 개발/운영 동작 일치)
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip — 번들·JSON 응답 전송량 절감
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# ── API 프록시 (lps-api:9600) ──
|
||||
# 도커 임베디드 DNS(127.0.0.11)로 매 요청 재해석 → lps-api 재시작(새 IP)에도 502 없이 따라간다.
|
||||
# proxy_pass 에 경로를 두지 않아 원본 요청 URI 를 그대로 전달한다(/v1/lps/ops → 동일 경로).
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
|
||||
location /v1/ {
|
||||
set $lps_api http://lps-api:9600;
|
||||
proxy_pass $lps_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s; # 테스트 검색 등 긴 폴링 여유
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
set $lps_api http://lps-api:9600;
|
||||
proxy_pass $lps_api;
|
||||
}
|
||||
|
||||
location = /readyz {
|
||||
set $lps_api http://lps-api:9600;
|
||||
proxy_pass $lps_api;
|
||||
}
|
||||
|
||||
# ── SPA 라우팅 — 정적 파일 없으면 index.html 로 폴백 ──
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 정적 자산 캐시(해시 파일명이라 장기 캐시 안전). index.html 은 캐시 안 함.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
2795
lps-admin/package-lock.json
generated
Normal file
2795
lps-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
lps-admin/package.json
Normal file
27
lps-admin/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "lps-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router": "^7.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwindcss": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
46
lps-admin/src/api/client.ts
Normal file
46
lps-admin/src/api/client.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/** fetch 래퍼 — base URL·X-API-Key(설정 페이지, localStorage) 자동 적용 + 봉투(result) 검사. */
|
||||
|
||||
const BASE_KEY = "lps.baseUrl";
|
||||
const API_KEY = "lps.apiKey";
|
||||
|
||||
export const settings = {
|
||||
baseUrl: () => localStorage.getItem(BASE_KEY) ?? "",
|
||||
apiKey: () => localStorage.getItem(API_KEY) ?? "",
|
||||
save(baseUrl: string, apiKey: string) {
|
||||
baseUrl ? localStorage.setItem(BASE_KEY, baseUrl.replace(/\/$/, "")) : localStorage.removeItem(BASE_KEY);
|
||||
apiKey ? localStorage.setItem(API_KEY, apiKey) : localStorage.removeItem(API_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = { ...((init?.headers as Record<string, string>) ?? {}) };
|
||||
const key = settings.apiKey();
|
||||
if (key) headers["X-API-Key"] = key;
|
||||
const res = await fetch(settings.baseUrl() + path, { ...init, headers });
|
||||
if (res.status === 401) throw new Error("인증 실패(401) — 설정에서 API 키를 확인하세요");
|
||||
if (!res.ok) throw new Error(`서버 오류 (HTTP ${res.status})`);
|
||||
const body = (await res.json()) as T & { result?: { success: boolean; desc?: string } };
|
||||
if (body?.result && body.result.success === false) {
|
||||
throw new Error(body.result.desc || "요청이 거절됐습니다");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export const get = <T>(path: string) => request<T>(path);
|
||||
|
||||
export const post = <T>(path: string, payload?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload === undefined ? undefined : JSON.stringify(payload),
|
||||
});
|
||||
|
||||
/** healthz 는 JSON 문자열("시각")을 반환 — 도달 여부만 본다. */
|
||||
export async function healthz(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(settings.baseUrl() + "/healthz");
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
213
lps-admin/src/api/types.ts
Normal file
213
lps-admin/src/api/types.ts
Normal file
@ -0,0 +1,213 @@
|
||||
/** LPS API 응답 타입 — 백엔드 protocol/admin_protocol 미러링. */
|
||||
|
||||
export interface ResultEnvelope {
|
||||
success: boolean;
|
||||
code: number;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface OpsSnapshot {
|
||||
pending: number;
|
||||
running: number;
|
||||
done: number;
|
||||
dead: number;
|
||||
dead_1h: number;
|
||||
stuck_running: number;
|
||||
oldest_pending_sec: number;
|
||||
blocks_1h: number;
|
||||
deadline_1h: number;
|
||||
cost_1h_usd: number;
|
||||
pool_checked_out: number;
|
||||
pool_capacity: number;
|
||||
pool_pct: number;
|
||||
}
|
||||
|
||||
export type JobStatusName = "PENDING" | "RUNNING" | "DONE" | "DEAD";
|
||||
|
||||
export interface JobListItem {
|
||||
job_id: string;
|
||||
job_type: number;
|
||||
status: JobStatusName;
|
||||
priority: number;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
product_code?: string;
|
||||
product_name?: string;
|
||||
outcome?: string;
|
||||
final_lowest?: number;
|
||||
cost_usd?: number;
|
||||
last_error?: string;
|
||||
created_at: string;
|
||||
run_started_at?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface JobListRes {
|
||||
result: ResultEnvelope;
|
||||
items: JobListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface JobDetailRes {
|
||||
result: ResultEnvelope;
|
||||
job_id?: string;
|
||||
status?: JobStatusName;
|
||||
attempts?: number;
|
||||
max_attempts?: number;
|
||||
output?: JobOutput;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface RequeueRes {
|
||||
result: ResultEnvelope;
|
||||
job_id?: string;
|
||||
requeued: boolean;
|
||||
}
|
||||
|
||||
export interface ProductItem {
|
||||
product_code: string;
|
||||
display_name?: string;
|
||||
triggered_at: string;
|
||||
outcome: string;
|
||||
naver_lowest?: number;
|
||||
coupang_lowest?: number;
|
||||
final_lowest?: number;
|
||||
final_source?: string;
|
||||
searches: number;
|
||||
}
|
||||
|
||||
export interface ProductListRes {
|
||||
result: ResultEnvelope;
|
||||
items: ProductItem[];
|
||||
}
|
||||
|
||||
/** 몰별 최저가 스냅샷 원소(price_history.by_mall) — summarize_by_mall 출력과 정합. */
|
||||
export interface MallEntry {
|
||||
source?: string;
|
||||
mall_name?: string;
|
||||
price?: number;
|
||||
shipping_fee?: number | null;
|
||||
shipping_type?: string | null;
|
||||
name?: string;
|
||||
detail_url?: string;
|
||||
}
|
||||
|
||||
export interface PricePoint {
|
||||
triggered_at: string;
|
||||
outcome: string;
|
||||
matched_count?: number;
|
||||
naver?: number;
|
||||
coupang?: number;
|
||||
final?: number;
|
||||
final_source?: string;
|
||||
naver_name?: string;
|
||||
naver_url?: string;
|
||||
coupang_name?: string;
|
||||
coupang_url?: string;
|
||||
by_mall?: MallEntry[];
|
||||
}
|
||||
|
||||
export interface PriceHistoryRes {
|
||||
result: ResultEnvelope;
|
||||
product_code?: string;
|
||||
points: PricePoint[];
|
||||
}
|
||||
|
||||
export interface SearchRes {
|
||||
result: ResultEnvelope;
|
||||
accepted: number;
|
||||
items: { product_code: string; job_id?: string; duplicated: boolean }[];
|
||||
}
|
||||
|
||||
/** 정규화 상품(어댑터 출력) — 잡 결과의 lowest/top 원소. */
|
||||
export interface NormalizedProduct {
|
||||
source: string;
|
||||
name: string;
|
||||
price: number;
|
||||
mall_name?: string;
|
||||
detail_url?: string;
|
||||
shipping_fee?: number | null;
|
||||
shipping_type?: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineStage {
|
||||
stage: string;
|
||||
in: number;
|
||||
out: number;
|
||||
}
|
||||
|
||||
export interface SearchMetrics {
|
||||
duration_ms?: number;
|
||||
ai?: { calls: number; prompt_tokens: number; completion_tokens: number; est_cost_usd: number };
|
||||
crawl?: { fetches: number; html_bytes: number; proxy_bytes: number; malls_crawled: string[] };
|
||||
cost?: { ai_usd: number; proxy_usd: number; total_usd: number };
|
||||
source_ms?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 잡 결과(output) — 크롤 테스트가 소비하는 파이프라인 산출물. */
|
||||
export interface JobOutput {
|
||||
outcome?: "found" | "not_found";
|
||||
query?: string;
|
||||
round?: string;
|
||||
rounds_tried?: number;
|
||||
cached?: boolean;
|
||||
total_found?: number;
|
||||
kept?: number;
|
||||
lowest?: NormalizedProduct | null;
|
||||
top?: NormalizedProduct[];
|
||||
by_mall?: MallEntry[];
|
||||
stages?: PipelineStage[];
|
||||
// 소스별 수집 결과 — 정상이면 {count}, 기술적 실패(차단·예외)면 {error}.
|
||||
sources?: Record<string, { count?: number; error?: string }>;
|
||||
metrics?: SearchMetrics;
|
||||
}
|
||||
|
||||
export interface IpSessionRow {
|
||||
source: string;
|
||||
proxy_port?: number;
|
||||
requests: number;
|
||||
ok_count: number;
|
||||
blocked_count: number;
|
||||
elapsed_sec?: number;
|
||||
end_reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface IpSessionStatsRes {
|
||||
result: ResultEnvelope;
|
||||
by_reason: Record<string, number>;
|
||||
histogram: { requests: number; count: number }[];
|
||||
block_min_requests?: number | null;
|
||||
sessions: IpSessionRow[];
|
||||
}
|
||||
|
||||
export interface BotRow {
|
||||
source: string;
|
||||
query?: string;
|
||||
ip_request_no?: number;
|
||||
proxy_port?: number;
|
||||
elapsed_sec?: number;
|
||||
marker?: string;
|
||||
html_len?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BotStatsRes {
|
||||
result: ResultEnvelope;
|
||||
hourly: { bucket: string; count: number }[];
|
||||
items: BotRow[];
|
||||
}
|
||||
|
||||
export interface CostBucket {
|
||||
bucket: string;
|
||||
jobs: number;
|
||||
ai_usd: number;
|
||||
proxy_usd: number;
|
||||
total_usd: number;
|
||||
avg_ms: number;
|
||||
}
|
||||
|
||||
export interface CostStatsRes {
|
||||
result: ResultEnvelope;
|
||||
buckets: CostBucket[];
|
||||
}
|
||||
69
lps-admin/src/components/Layout.tsx
Normal file
69
lps-admin/src/components/Layout.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
/** 앱 레이아웃 — 좌측 네비 + 상단 헬스 배지. */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { healthz } from "../api/client";
|
||||
|
||||
// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 다른 메뉴와 배경색(앰버)만 다르게.
|
||||
const MENU = [
|
||||
{ to: "/", label: "대시보드", end: true },
|
||||
{ to: "/jobs", label: "작업 큐" },
|
||||
{ to: "/products", label: "상품·가격" },
|
||||
{ to: "/crawl", label: "크롤 상태" },
|
||||
{ to: "/costs", label: "비용" },
|
||||
{ to: "/test", label: "테스트 검색", action: true },
|
||||
{ to: "/settings", label: "설정" },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 });
|
||||
const up = health.data === true;
|
||||
return (
|
||||
<div className="mx-auto flex h-dvh w-full max-w-[1280px] flex-col overflow-hidden md:flex-row">
|
||||
{/* 모바일(<md): 상단 바 + 가로 스크롤 네비(터치 44px). md 이상: 좌측 고정 사이드바. */}
|
||||
<aside className="w-full min-w-0 shrink-0 border-b border-line-200 px-3 py-3 md:w-[196px] md:border-b-0 md:border-r md:py-5">
|
||||
<div className="flex items-center justify-between md:block">
|
||||
<div className="flex items-center gap-2.5 px-2 md:mb-3">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-400 text-[14px] font-extrabold text-white">L</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-bold leading-tight">LPS 관리자</div>
|
||||
<div className="text-[11px] font-medium text-ink-400">인터넷 최저가 검색</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* API 헬스 — 상단 배지(모바일: 우측, 데스크톱: 제목 아래) */}
|
||||
<div className="shrink-0 px-2 md:mb-5">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${up ? "bg-ok-50 text-ok-600" : "bg-dead-50 text-dead-600"}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`} aria-hidden />
|
||||
{up ? "API 연결됨" : "API 연결 안 됨"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="주 메뉴" className="mt-2 md:mt-0">
|
||||
<ul className="flex items-center gap-1 overflow-x-auto md:flex-col md:items-stretch md:gap-0 md:space-y-0.5">
|
||||
{MENU.map((m) => (
|
||||
<li key={m.to} className="flex shrink-0 items-center">
|
||||
<NavLink
|
||||
to={m.to}
|
||||
end={m.end}
|
||||
className={({ isActive }) =>
|
||||
`flex min-h-[44px] w-full items-center whitespace-nowrap rounded-lg px-3 text-[13px] font-semibold transition-colors md:min-h-0 md:py-2 ${
|
||||
m.action
|
||||
? isActive ? "bg-warn-600/20 text-warn-700" : "bg-warn-600/10 text-ink-700 hover:bg-warn-600/20"
|
||||
: isActive ? "bg-primary-50 text-primary-700" : "text-ink-700 hover:bg-surface-2"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{m.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
{/* content 영역만 스크롤 — 페이지가 h-full 로 채우면 스크롤 없음, 넘치면 여기서 스크롤. */}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 py-4 md:px-6 md:py-5">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
lps-admin/src/components/ui.tsx
Normal file
219
lps-admin/src/components/ui.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
/** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시 + 페이지 공통 컨트롤
|
||||
* (헤더·세그먼트 탭·버튼·검색폼·범례·고정높이 표/스크롤). 웹 표준 시맨틱 마크업 유지. */
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import type { JobStatusName } from "../api/types";
|
||||
|
||||
// fill: 카드가 부모 높이를 채우고 본문이 남는 높이를 flex 로 차지(그래프·표가 높이 채우기용).
|
||||
// compact: 패딩을 줄여 밀도 높은 레이아웃(한 화면에 많이 담기).
|
||||
export function Card({ title, hint, children, className = "", fill = false, compact = false }: {
|
||||
title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string; fill?: boolean; compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className={`rounded-xl border border-line-200 bg-surface shadow-[0_1px_2px_rgba(16,24,40,.04)] ${fill ? "flex min-h-0 flex-col" : ""} ${className}`}>
|
||||
{title !== undefined && (
|
||||
<header className={`flex shrink-0 items-center gap-2 border-b border-line-100 ${compact ? "px-3 py-2" : "px-4 py-2.5"}`}>
|
||||
<h2 className="text-[13px] font-bold tracking-tight">{title}</h2>
|
||||
{hint && <div className="ml-auto text-[11px] text-ink-400">{hint}</div>}
|
||||
</header>
|
||||
)}
|
||||
<div className={`${compact ? "p-3" : "p-4"} ${fill ? "flex min-h-0 flex-1 flex-col" : ""}`}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatTile({ label, value, sub, tone = "default" }: {
|
||||
label: string; value: ReactNode; sub?: ReactNode;
|
||||
tone?: "default" | "ok" | "warn" | "run" | "dead";
|
||||
}) {
|
||||
const toneCls = {
|
||||
default: "text-ink-900",
|
||||
ok: "text-ok-600",
|
||||
warn: "text-warn-600",
|
||||
run: "text-run-600",
|
||||
dead: "text-dead-600",
|
||||
}[tone];
|
||||
return (
|
||||
<div className="rounded-xl border border-line-200 bg-surface px-4 py-3">
|
||||
<div className="text-[12px] font-semibold text-ink-500">{label}</div>
|
||||
<div className={`mt-0.5 text-[22px] font-bold leading-tight ${toneCls}`}>{value}</div>
|
||||
{sub && <div className="mt-0.5 text-[11px] text-ink-400">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<JobStatusName, { dot: string; bg: string; text: string; label: string }> = {
|
||||
PENDING: { dot: "bg-warn-600", bg: "bg-warn-50", text: "text-warn-700", label: "대기" },
|
||||
RUNNING: { dot: "bg-run-600", bg: "bg-run-50", text: "text-run-600", label: "처리중" },
|
||||
DONE: { dot: "bg-ok-600", bg: "bg-ok-50", text: "text-ok-600", label: "완료" },
|
||||
DEAD: { dot: "bg-dead-600", bg: "bg-dead-50", text: "text-dead-600", label: "실패" },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: JobStatusName }) {
|
||||
const s = STATUS_STYLE[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${s.bg} ${s.text}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${s.dot}`} aria-hidden />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ children }: { children: ReactNode }) {
|
||||
return <p className="py-8 text-center text-[13px] text-ink-400">{children}</p>;
|
||||
}
|
||||
|
||||
export function ErrorNote({ error }: { error: unknown }) {
|
||||
return (
|
||||
<p role="alert" className="rounded-lg bg-dead-50 px-3 py-2 text-[13px] font-medium text-dead-600">
|
||||
{error instanceof Error ? error.message : "요청에 실패했습니다"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return <p className="py-8 text-center text-[13px] text-ink-400" aria-busy="true">불러오는 중…</p>;
|
||||
}
|
||||
|
||||
// ── 페이지 헤더 — h1 + 우측 컨트롤 슬롯(기간 탭 등). 모든 페이지 제목을 이걸로 통일. ──
|
||||
export function PageHeader({ title, children }: { title: ReactNode; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[17px] font-bold tracking-tight">{title}</h1>
|
||||
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 세그먼트 탭 — 상태/기간/상위N 필터 공용(기존 4곳의 동일 마크업 통일). ──
|
||||
export function Segmented<T extends string | number>({ options, value, onChange, ariaLabel, size = "md" }: {
|
||||
options: readonly { v: T; label: ReactNode }[];
|
||||
value: T; onChange: (v: T) => void; ariaLabel: string; size?: "sm" | "md";
|
||||
}) {
|
||||
const cell = size === "sm" ? "px-2 py-0.5 text-[11px]" : "px-3 py-1.5 text-[12px]";
|
||||
return (
|
||||
<div role="tablist" aria-label={ariaLabel} className="flex rounded-lg border border-line-200 bg-surface p-0.5">
|
||||
{options.map((o) => (
|
||||
<button key={String(o.v)} role="tab" aria-selected={value === o.v} onClick={() => onChange(o.v)}
|
||||
className={`rounded-md font-semibold ${cell} ${value === o.v ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 버튼 — primary(실행 CTA)·outline(보조). 크기 sm/md/lg. 반복되던 파랑 버튼 통일. ──
|
||||
export function Button({ variant = "primary", size = "md", className = "", ...props }: {
|
||||
variant?: "primary" | "outline"; size?: "sm" | "md" | "lg";
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const sizes = { sm: "px-2.5 py-1 text-[12px]", md: "px-3 py-1.5 text-[13px]", lg: "px-4 py-2 text-[13px]" };
|
||||
const variants = {
|
||||
primary: "bg-primary-600 text-white hover:bg-primary-700",
|
||||
outline: "border border-line-200 text-ink-700 hover:bg-line-200 hover:text-ink-900",
|
||||
};
|
||||
return <button className={`cursor-pointer rounded-lg font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${sizes[size]} ${variants[variant]} ${className}`} {...props} />;
|
||||
}
|
||||
|
||||
// ── 검색 폼 — input + 검색 버튼(Jobs·Products 동일 폼 통일). ──
|
||||
export function SearchForm({ value, onChange, onSubmit, placeholder, className = "", inputClassName = "w-64" }: {
|
||||
value: string; onChange: (v: string) => void; onSubmit: () => void;
|
||||
placeholder: string; className?: string; inputClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<form className={`flex gap-1.5 ${className}`} onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
|
||||
<input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} aria-label={placeholder}
|
||||
className={`rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600 ${inputClassName}`} />
|
||||
<Button type="submit">검색</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 범례 — 색 dot + 라벨(차트 아래, Dashboard·Costs·Products 통일). ──
|
||||
export function Legend({ items, note }: {
|
||||
items: readonly { label: ReactNode; color: string }[]; note?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<p className="mt-1 flex flex-wrap gap-4 text-[11px] font-semibold text-ink-500">
|
||||
{items.map((it, i) => (
|
||||
<span key={i}>
|
||||
<span className="mr-1 inline-block h-2 w-2 rounded-full" style={{ background: it.color }} aria-hidden />
|
||||
{it.label}
|
||||
</span>
|
||||
))}
|
||||
{note && <span className="ml-auto font-medium text-ink-400">{note}</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 스크롤 컨테이너 — height 숫자면 고정 높이, "fill" 이면 부모의 남는 높이를 채움. ──
|
||||
export function ScrollBox({ height = 420, className = "", children }: {
|
||||
height?: number | "fill"; className?: string; children: ReactNode;
|
||||
}) {
|
||||
const fill = height === "fill";
|
||||
return (
|
||||
<div className={`overflow-y-auto ${fill ? "min-h-0 flex-1" : ""} ${className}`}
|
||||
style={fill ? undefined : { height }}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 모달 — 배경 딤 + Esc/바깥클릭 닫기. actions 로 헤더 우측 버튼(복사 등) 배치. ──
|
||||
export function Modal({ title, onClose, actions, children }: {
|
||||
title: ReactNode; onClose: () => void; actions?: ReactNode; children: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-ink-900/30" onClick={onClose} aria-hidden />
|
||||
<div role="dialog" aria-modal="true"
|
||||
className="relative flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border border-line-200 bg-surface shadow-[0_10px_40px_rgba(16,24,40,.18)]">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-line-100 px-4 py-3">
|
||||
<h2 className="text-[14px] font-bold tracking-tight">{title}</h2>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{actions}
|
||||
<button onClick={onClose} aria-label="닫기"
|
||||
className="grid h-7 w-7 place-items-center rounded-md text-ink-400 hover:bg-surface-2 hover:text-ink-700">✕</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 표 — thead sticky. height 숫자=고정 높이, "fill"=남는 높이 채움, "auto"=내용 높이.
|
||||
// fixed=table-fixed(좁은 폭에서 열 넘침·줄바꿈 방지), 열별 width 지정 가능. ──
|
||||
export function ScrollTable({ height = 420, dense = false, fixed = false, columns, children }: {
|
||||
height?: number | "fill" | "auto"; dense?: boolean; fixed?: boolean;
|
||||
columns: readonly { key: string; label: ReactNode; align?: "right"; width?: string }[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pad = dense ? "pb-1.5 pr-2 pt-1.5" : "pb-2 pr-3 pt-2";
|
||||
const fill = height === "fill";
|
||||
const auto = height === "auto";
|
||||
// 행 좌우 여백: 첫/마지막 셀에 padding(헤더·본문 공통). 헤더는 bg-surface-2 밴드로 카드 제목바와 구분.
|
||||
const edge = dense ? "[&_th:first-child]:pl-3 [&_td:first-child]:pl-3 [&_th:last-child]:pr-3 [&_td:last-child]:pr-3"
|
||||
: "[&_th:first-child]:pl-4 [&_td:first-child]:pl-4 [&_th:last-child]:pr-4 [&_td:last-child]:pr-4";
|
||||
return (
|
||||
<div className={`overflow-auto ${fill ? "min-h-0 flex-1" : ""}`} style={fill || auto ? undefined : { height }}>
|
||||
<table className={`w-full text-left ${fixed ? "table-fixed" : ""} ${dense ? "text-[12px]" : "text-[13px]"} ${edge}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th key={c.key} style={c.width ? { width: c.width } : undefined}
|
||||
className={`sticky top-0 z-10 whitespace-nowrap border-b border-line-200 bg-surface-2 text-[11px] font-semibold text-ink-500 ${pad} ${c.align === "right" ? "text-right" : ""}`}>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
lps-admin/src/index.css
Normal file
75
lps-admin/src/index.css
Normal file
@ -0,0 +1,75 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 색 토큰 — 숫자 스케일(팀 관례) + dataviz 차트 크롬. 시리즈 색은 엔터티 고정:
|
||||
네이버 #008a43(CVD 검증 통과 톤다운) · 쿠팡 #f0455b · 최종 #4f46e5. */
|
||||
@theme {
|
||||
--color-page: #f7f7f5;
|
||||
--color-surface: #fcfcfb;
|
||||
--color-surface-2: #f4f4f1;
|
||||
--color-line-200: #e6e5df;
|
||||
--color-line-100: #efeee9;
|
||||
--color-grid: #e1e0d9;
|
||||
|
||||
--color-ink-900: #171a20;
|
||||
--color-ink-700: #3d434f;
|
||||
/* ink-500/400: 소형(11~13px) 캡션·라벨에 쓰이므로 WCAG AA 4.5:1 이상 필수
|
||||
(기존 ink-400 #9aa1ac 은 2.54:1 로 미달 — 위계는 크기·굵기가 담당) */
|
||||
--color-ink-500: #5d6470;
|
||||
--color-ink-400: #6d7480;
|
||||
|
||||
--color-primary-700: #4338ca;
|
||||
--color-primary-600: #4f46e5;
|
||||
--color-primary-400: #8b7bff;
|
||||
--color-primary-100: #e5e4fb;
|
||||
--color-primary-50: #eef0ff;
|
||||
|
||||
/* 상태(큐·알림) — status 팔레트, 시리즈로 재사용 금지 */
|
||||
--color-ok-600: #0f9d58;
|
||||
--color-ok-50: #e7f6ee;
|
||||
--color-warn-700: #a36a00;
|
||||
--color-warn-600: #c77800;
|
||||
--color-warn-50: #fdf3e3;
|
||||
--color-run-600: #2563eb;
|
||||
--color-run-50: #e8f0ff;
|
||||
--color-dead-600: #d03b3b;
|
||||
--color-dead-50: #fdeaea;
|
||||
|
||||
/* 중립 계열(차트 보조 — 유휴/종료 등 '무해한 상태') */
|
||||
--color-neutral-400: #898781;
|
||||
--color-neutral-300: #c3c2b7;
|
||||
|
||||
/* 차트 시리즈(엔터티 고정) */
|
||||
--color-naver: #008a43;
|
||||
--color-coupang: #f0455b;
|
||||
--color-final: #4f46e5;
|
||||
--color-cost-ai: #2a78d6;
|
||||
--color-cost-proxy: #eda100;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||
}
|
||||
|
||||
/* 앱을 뷰포트에 고정 — 페이지(body) 스크롤 없음. 스크롤은 content(main) 영역에서만. */
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-page text-ink-900 font-sans text-[14px] antialiased;
|
||||
}
|
||||
|
||||
/* Tailwind v4 는 버튼 기본 커서를 default 로 바꿈 — 활성 버튼은 pointer 로 복구. */
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 테이블 숫자 열 정렬 */
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* recharts 기본 폰트 */
|
||||
.recharts-wrapper,
|
||||
.recharts-tooltip-wrapper {
|
||||
font-size: 12px;
|
||||
}
|
||||
35
lps-admin/src/lib/chart.ts
Normal file
35
lps-admin/src/lib/chart.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/** recharts 공용 축·그리드 props — 반복되던 XAxis/YAxis/Grid 설정 단일화.
|
||||
* 색은 index.css @theme 토큰 참조(단일 소스). 사용: <XAxis dataKey="x" {...xAxisProps} minTickGap={40} /> */
|
||||
|
||||
export const gridProps = { stroke: "var(--color-grid)", vertical: false } as const;
|
||||
|
||||
// X축: 하단 눈금 + 옅은 축선(기준선 역할).
|
||||
export const xAxisProps = {
|
||||
tick: { fill: "var(--color-ink-400)" },
|
||||
tickLine: false,
|
||||
axisLine: { stroke: "var(--color-line-200)" },
|
||||
} as const;
|
||||
|
||||
// Y축: 눈금만(축선 없음 — 그리드가 대신함).
|
||||
export const yAxisProps = {
|
||||
tick: { fill: "var(--color-ink-400)" },
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
} as const;
|
||||
|
||||
// 툴팁 — 둥근 카드·부드러운 그림자·토큰 색(기본 recharts 툴팁을 앱 톤으로 통일).
|
||||
// 사용: <Tooltip {...tooltipProps} formatter={...} /> (막대엔 cursor={barCursor} 추가)
|
||||
export const tooltipProps = {
|
||||
contentStyle: {
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--color-line-200)",
|
||||
background: "var(--color-surface)",
|
||||
boxShadow: "0 8px 24px rgba(16,24,40,.14)",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
labelStyle: { color: "var(--color-ink-500)", fontWeight: 700, marginBottom: 4, fontSize: 12 },
|
||||
itemStyle: { padding: "1px 0", fontSize: 12 },
|
||||
} as const;
|
||||
|
||||
// 막대 hover 커서 — 옅은 배경 하이라이트(기본 회색 박스 대신).
|
||||
export const barCursor = { fill: "color-mix(in srgb, var(--color-ink-900) 4%, transparent)" } as const;
|
||||
29
lps-admin/src/lib/format.ts
Normal file
29
lps-admin/src/lib/format.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/** 표시 포맷 유틸 — 원화·달러·상대시각·소요시간. */
|
||||
|
||||
export const won = (v?: number | null) =>
|
||||
v === null || v === undefined ? "—" : `${v.toLocaleString("ko-KR")}원`;
|
||||
|
||||
export const usd = (v?: number | null, digits = 3) =>
|
||||
v === null || v === undefined ? "—" : `$${v.toFixed(digits)}`;
|
||||
|
||||
export function timeAgo(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const sec = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return `${Math.floor(sec)}초 전`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}분 전`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}시간 전`;
|
||||
return `${Math.floor(sec / 86400)}일 전`;
|
||||
}
|
||||
|
||||
export const hhmm = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
export const dateShort = (iso: string) =>
|
||||
new Date(iso).toLocaleString("ko-KR", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
export function durationSec(sec?: number | null): string {
|
||||
if (sec === null || sec === undefined) return "—";
|
||||
if (sec < 60) return `${sec}초`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}분 ${sec % 60}초`;
|
||||
return `${Math.floor(sec / 3600)}시간 ${Math.floor((sec % 3600) / 60)}분`;
|
||||
}
|
||||
41
lps-admin/src/main.tsx
Normal file
41
lps-admin/src/main.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
|
||||
import Layout from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Jobs from "./pages/Jobs";
|
||||
import Products from "./pages/Products";
|
||||
import Crawl from "./pages/Crawl";
|
||||
import Costs from "./pages/Costs";
|
||||
import TestSearch from "./pages/TestSearch";
|
||||
import Settings from "./pages/Settings";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ path: "/", element: <Dashboard /> },
|
||||
{ path: "/jobs", element: <Jobs /> },
|
||||
{ path: "/products", element: <Products /> },
|
||||
{ path: "/crawl", element: <Crawl /> },
|
||||
{ path: "/costs", element: <Costs /> },
|
||||
{ path: "/test", element: <TestSearch /> },
|
||||
{ path: "/settings", element: <Settings /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
100
lps-admin/src/pages/Costs.tsx
Normal file
100
lps-admin/src/pages/Costs.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
/** 비용 — 시간별 검색원가 스택(AI vs 프록시) + 평균 소요 시간(별도 축이므로 별도 차트). */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
import type { CostStatsRes } from "../api/types";
|
||||
import { Card, Empty, ErrorNote, Legend, Loading, PageHeader, Segmented, StatTile } from "../components/ui";
|
||||
import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
|
||||
import { dateShort, usd } from "../lib/format";
|
||||
|
||||
const RANGES = [
|
||||
{ v: 24, label: "24시간" },
|
||||
{ v: 48, label: "48시간" },
|
||||
{ v: 168, label: "7일" },
|
||||
];
|
||||
|
||||
export default function Costs() {
|
||||
const [hours, setHours] = useState(48);
|
||||
const cost = useQuery({
|
||||
queryKey: ["cost", hours],
|
||||
queryFn: () => get<CostStatsRes>(`/v1/lps/stats/cost?hours=${hours}`),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const buckets = (cost.data?.buckets ?? []).map((b) => ({ ...b, x: dateShort(b.bucket) }));
|
||||
const totals = buckets.reduce(
|
||||
(acc, b) => ({ jobs: acc.jobs + b.jobs, ai: acc.ai + b.ai_usd, proxy: acc.proxy + b.proxy_usd, total: acc.total + b.total_usd }),
|
||||
{ jobs: 0, ai: 0, proxy: 0, total: 0 },
|
||||
);
|
||||
const perSearch = totals.jobs > 0 ? totals.total / totals.jobs : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<PageHeader title="비용">
|
||||
<Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
|
||||
</PageHeader>
|
||||
|
||||
{cost.isError && <ErrorNote error={cost.error} />}
|
||||
|
||||
<div className="grid shrink-0 grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatTile label="완료 검색" value={totals.jobs} sub="기간 내" />
|
||||
<StatTile label="총 비용" value={usd(totals.total, 2)} sub={`AI ${usd(totals.ai, 2)} + 프록시 ${usd(totals.proxy, 2)}`} />
|
||||
<StatTile label="검색 1건당" value={perSearch === null ? "—" : usd(perSearch)} sub="평균 원가" />
|
||||
<StatTile label="프록시 비중" value={totals.total > 0 ? `${Math.round((totals.proxy / totals.total) * 100)}%` : "—"} sub="대역폭이 원가의 대부분" />
|
||||
</div>
|
||||
|
||||
<Card fill className="flex-[3]" title="시간별 검색원가" hint="AI vs 프록시 대역폭">
|
||||
{cost.isPending && <Loading />}
|
||||
{cost.data && buckets.length === 0 && (
|
||||
<div className="grid h-full place-items-center"><Empty>기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다</Empty></div>
|
||||
)}
|
||||
{buckets.length > 0 && (
|
||||
<>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
|
||||
<YAxis {...yAxisProps} tickFormatter={(v: number) => `$${v}`} />
|
||||
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number, name: string) => [usd(v), name]}
|
||||
labelFormatter={(l, payload) => {
|
||||
const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs;
|
||||
return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`;
|
||||
}} />
|
||||
<Bar dataKey="ai_usd" name="AI" stackId="c" fill="var(--color-cost-ai)" maxBarSize={28} isAnimationActive={false} />
|
||||
<Bar dataKey="proxy_usd" name="프록시" stackId="c" fill="var(--color-cost-proxy)" radius={[4, 4, 0, 0]} maxBarSize={28} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<Legend items={[
|
||||
{ label: "AI (판정·검색어)", color: "var(--color-cost-ai)" },
|
||||
{ label: "프록시 대역폭 (DECODO)", color: "var(--color-cost-proxy)" },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card fill className="flex-[2]" title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호">
|
||||
{buckets.length === 0 ? (
|
||||
<div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
|
||||
<YAxis {...yAxisProps} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}s`} />
|
||||
<Tooltip {...tooltipProps} formatter={(v: number) => [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} />
|
||||
<Line type="monotone" dataKey="avg_ms" name="평균 소요" stroke="var(--color-primary-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
lps-admin/src/pages/Crawl.tsx
Normal file
236
lps-admin/src/pages/Crawl.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
/** 크롤 상태 — IP 세션 종료 사유·요청 수 분포(예산 튜닝 뷰)·차단 이력. */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
import type { BotStatsRes, IpSessionStatsRes } from "../api/types";
|
||||
import { Card, Empty, ErrorNote, Loading, PageHeader, ScrollTable, Segmented } from "../components/ui";
|
||||
import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
|
||||
import { dateShort, durationSec, hhmm } from "../lib/format";
|
||||
|
||||
const BOT_COLS = [
|
||||
{ key: "time", label: "시각" },
|
||||
{ key: "query", label: "검색어" },
|
||||
{ key: "reqno", label: "요청#", align: "right" },
|
||||
{ key: "port", label: "포트", align: "right" },
|
||||
{ key: "marker", label: "감지 근거" },
|
||||
] as const;
|
||||
|
||||
const SESSION_COLS = [
|
||||
{ key: "end", label: "종료 시각" },
|
||||
{ key: "source", label: "소스" },
|
||||
{ key: "port", label: "포트", align: "right" },
|
||||
{ key: "req", label: "요청", align: "right" },
|
||||
{ key: "okblock", label: "성공/차단", align: "right" },
|
||||
{ key: "dur", label: "지속", align: "right" },
|
||||
{ key: "reason", label: "종료 사유" },
|
||||
] as const;
|
||||
|
||||
// 종료 사유 — 의미 기반 상태색. 색은 전부 index.css @theme 토큰 참조(단일 소스) —
|
||||
// raw hex 재정의 금지(토큰을 바꾸면 여기도 함께 바뀌어야 한다).
|
||||
const REASONS: Record<string, { label: string; color: string; desc: string }> = {
|
||||
budget: { label: "예산 선제 회전", color: "var(--color-ok-600)", desc: "정상 — 차단 전에 IP 교체(평판 보존)" },
|
||||
window: { label: "시간창 만료", color: "var(--color-run-600)", desc: "정상 — sticky 10분 주기 교체" },
|
||||
idle: { label: "유휴 정리", color: "var(--color-neutral-400)", desc: "정상 — 한동안 검색 없어 브라우저 회수" },
|
||||
shutdown: { label: "종료", color: "var(--color-neutral-300)", desc: "정상 — 워커 재시작/종료" },
|
||||
rotate: { label: "기타 회전", color: "var(--color-ink-400)", desc: "웜업 재시도 등" },
|
||||
proxy_error: { label: "포트 사망", color: "var(--color-warn-600)", desc: "주의 — 프록시 전송 실패로 교체" },
|
||||
block: { label: "차단됨", color: "var(--color-dead-600)", desc: "위험 — 예산 안에서도 차단(예산 하향 검토)" },
|
||||
};
|
||||
|
||||
const RANGES = [
|
||||
{ v: 24, label: "24시간" },
|
||||
{ v: 168, label: "7일" },
|
||||
{ v: 720, label: "30일" },
|
||||
];
|
||||
|
||||
export default function Crawl() {
|
||||
const [hours, setHours] = useState(168);
|
||||
const ip = useQuery({
|
||||
queryKey: ["ip-sessions", hours],
|
||||
queryFn: () => get<IpSessionStatsRes>(`/v1/lps/stats/ip-sessions?hours=${hours}`),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const bot = useQuery({
|
||||
queryKey: ["bot", hours],
|
||||
queryFn: () => get<BotStatsRes>(`/v1/lps/stats/bot?hours=${hours}`),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const reasons = Object.entries(ip.data?.by_reason ?? {})
|
||||
.map(([k, v]) => ({ key: k, ...(REASONS[k] ?? { label: k, color: "var(--color-ink-400)", desc: "" }), value: v }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const totalSessions = reasons.reduce((s, r) => s + r.value, 0);
|
||||
const blockCount = ip.data?.by_reason?.block ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="크롤 상태">
|
||||
<Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
|
||||
</PageHeader>
|
||||
|
||||
{ip.isError && <ErrorNote error={ip.error} />}
|
||||
|
||||
{ip.data && (
|
||||
blockCount === 0 ? (
|
||||
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[13px] font-semibold text-ok-600">
|
||||
<span aria-hidden>✓ </span>기간 내 차단된 IP 세션 0건 — 요청 예산(선제 회전)이 잘 작동하고 있습니다
|
||||
</p>
|
||||
) : (
|
||||
<p role="alert" className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[13px] font-semibold text-dead-600">
|
||||
<span aria-hidden>⚠ </span>예산 안에서도 차단된 세션 {blockCount}건 — 최소 {ip.data.block_min_requests}회 요청에서 차단됐습니다.
|
||||
요청 예산을 그보다 낮게 유지하세요(현재 서버 설정은 toml [DecodoConfig].ip_request_budget)
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card title="IP 세션 종료 사유" hint={`총 ${totalSessions}건`}>
|
||||
{ip.isPending && <Loading />}
|
||||
{ip.data && reasons.length === 0 && <Empty>기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다</Empty>}
|
||||
{reasons.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원). 중앙에 총계 오버레이. */}
|
||||
<div className="relative shrink-0">
|
||||
<PieChart width={150} height={150}>
|
||||
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={46} outerRadius={70}
|
||||
paddingAngle={2} cornerRadius={4} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
|
||||
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
|
||||
</Pie>
|
||||
{/* 툴팁 제거 — 오른쪽 범례가 사유별 건수를 모두 보여줘 중복이고, 중앙 총계와 겹침 방지 */}
|
||||
</PieChart>
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div className="text-center">
|
||||
<div className="tnum text-[20px] font-bold leading-none">{totalSessions}</div>
|
||||
<div className="mt-0.5 text-[10px] font-medium text-ink-400">세션</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-1.5 text-[12px]">
|
||||
{reasons.map((r) => (
|
||||
<li key={r.key} className="flex items-baseline gap-2">
|
||||
<span className="mt-1 h-2 w-2 shrink-0 rounded-full" style={{ background: r.color }} aria-hidden />
|
||||
<span className="font-semibold">{r.label}</span>
|
||||
<span className="tnum ml-auto shrink-0 font-bold">{r.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{reasons.length > 0 && (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-ink-400">
|
||||
초록(예산 선제)·파랑(시간창)이 대부분이면 건강한 상태입니다. 빨강(차단)이 보이면 예산 하향 신호.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card fill title="세션당 요청 수 분포" hint="예산 튜닝 근거">
|
||||
{ip.isPending && <Loading />}
|
||||
{ip.data && ip.data.histogram.length === 0 && <div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>}
|
||||
{ip.data && ip.data.histogram.length > 0 && (
|
||||
<>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
|
||||
<defs>
|
||||
<linearGradient id="histBar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-cost-ai)" stopOpacity={0.95} />
|
||||
<stop offset="100%" stopColor="var(--color-cost-ai)" stopOpacity={0.55} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="requests" {...xAxisProps} />
|
||||
<YAxis allowDecimals={false} {...yAxisProps} />
|
||||
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
|
||||
<Bar dataKey="count" fill="url(#histBar)" radius={[5, 5, 0, 0]} maxBarSize={36} isAnimationActive={false} />
|
||||
{ip.data.block_min_requests != null && (
|
||||
<ReferenceLine x={ip.data.block_min_requests} stroke="var(--color-dead-600)" strokeDasharray="4 3"
|
||||
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "var(--color-dead-600)", fontSize: 11, fontWeight: 600 }} />
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-ink-400">
|
||||
X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card fill title="차단 발생 추이" hint="시간대별">
|
||||
{bot.isPending && <Loading />}
|
||||
{bot.isError && <ErrorNote error={bot.error} />}
|
||||
{bot.data && bot.data.hourly.length === 0 && <div className="grid h-full place-items-center"><Empty>기간 내 차단 없음 <span aria-hidden>🎉</span></Empty></div>}
|
||||
{bot.data && bot.data.hourly.length > 0 && (
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
|
||||
<defs>
|
||||
<linearGradient id="blockBar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-dead-600)" stopOpacity={0.95} />
|
||||
<stop offset="100%" stopColor="var(--color-dead-600)" stopOpacity={0.55} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
|
||||
<YAxis allowDecimals={false} {...yAxisProps} />
|
||||
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "차단"]} />
|
||||
<Bar dataKey="count" fill="url(#blockBar)" radius={[5, 5, 0, 0]} maxBarSize={28} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="최근 차단 이력" hint="최근 50건">
|
||||
{bot.data && bot.data.items.length === 0 && <Empty>기간 내 차단 없음</Empty>}
|
||||
{bot.data && bot.data.items.length > 0 && (
|
||||
<ScrollTable height={240} dense columns={BOT_COLS}>
|
||||
{bot.data.items.map((b, i) => (
|
||||
<tr key={i} className="border-b border-line-100">
|
||||
<td className="tnum py-1.5 pr-2 text-ink-500">{hhmm(b.created_at)}</td>
|
||||
<td className="max-w-32 truncate py-1.5 pr-2">{b.query || "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right">{b.ip_request_no ?? "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{b.proxy_port ?? "—"}</td>
|
||||
<td className="max-w-40 truncate py-1.5 text-[11px] text-ink-500">{b.marker}</td>
|
||||
</tr>
|
||||
))}
|
||||
</ScrollTable>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="최근 IP 세션" hint="최근 50건">
|
||||
{ip.data && ip.data.sessions.length === 0 && <Empty>세션 없음</Empty>}
|
||||
{ip.data && ip.data.sessions.length > 0 && (
|
||||
<ScrollTable height={320} dense columns={SESSION_COLS}>
|
||||
{ip.data.sessions.map((s, i) => {
|
||||
const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" };
|
||||
return (
|
||||
<tr key={i} className="border-b border-line-100">
|
||||
<td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
|
||||
<td className="py-1.5 pr-2">{s.source}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
|
||||
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
|
||||
<td className="py-1.5">
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: r.color }}>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: r.color }} aria-hidden />
|
||||
{r.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</ScrollTable>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
lps-admin/src/pages/Dashboard.tsx
Normal file
106
lps-admin/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
/** 대시보드 — 지금 시스템이 건강한가. /ops 5초 폴링 + 임계 배너 + 큐 추이(세션 내 누적). */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { get } from "../api/client";
|
||||
import type { OpsSnapshot } from "../api/types";
|
||||
import { Card, ErrorNote, Legend, PageHeader, StatTile } from "../components/ui";
|
||||
import { gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
|
||||
import { durationSec, usd } from "../lib/format";
|
||||
|
||||
// 알림 임계 기본값 미러 — 서버 [AlertConfig] 와 동일 기본(서버가 조정했으면 다를 수 있음, 참고용 배너).
|
||||
const TH = { dead_1h: 20, blocks_1h: 80, queue_lag_sec: 300, pool_pct: 90, deadline_1h: 5, cost_1h_usd: 1.0 };
|
||||
|
||||
interface TrendPoint { t: string; pending: number; running: number }
|
||||
|
||||
export default function Dashboard() {
|
||||
const trend = useRef<TrendPoint[]>([]);
|
||||
const ops = useQuery({
|
||||
queryKey: ["ops"],
|
||||
queryFn: () => get<OpsSnapshot>("/v1/lps/ops"),
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
if (ops.data) {
|
||||
const now = new Date().toLocaleTimeString("ko-KR", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const last = trend.current[trend.current.length - 1];
|
||||
if (!last || last.t !== now) {
|
||||
trend.current = [...trend.current, { t: now, pending: ops.data.pending, running: ops.data.running }].slice(-120);
|
||||
}
|
||||
}
|
||||
|
||||
const d = ops.data;
|
||||
const alerts: { key: string; msg: string }[] = [];
|
||||
if (d) {
|
||||
if (d.dead_1h >= TH.dead_1h) alerts.push({ key: "dead", msg: `최근 1시간 실패(DEAD) ${d.dead_1h}건 — 검색 실패가 쌓이고 있습니다` });
|
||||
if (d.blocks_1h >= TH.blocks_1h) alerts.push({ key: "blocks", msg: `최근 1시간 차단 ${d.blocks_1h}건 — IP 평판 악화 신호` });
|
||||
if (d.oldest_pending_sec >= TH.queue_lag_sec) alerts.push({ key: "lag", msg: `큐 지연 ${durationSec(d.oldest_pending_sec)} — 워커가 처리를 못 따라갑니다` });
|
||||
if (d.stuck_running > 0) alerts.push({ key: "stuck", msg: `멈춘 작업 ${d.stuck_running}건 — 워커 사망/행 흔적` });
|
||||
if (d.pool_pct >= TH.pool_pct) alerts.push({ key: "pool", msg: `DB 커넥션 풀 ${d.pool_pct}% 포화` });
|
||||
if (d.deadline_1h >= TH.deadline_1h) alerts.push({ key: "deadline", msg: `최근 1시간 데드라인 강제종료 ${d.deadline_1h}건 — 크롤 행 반복 신호` });
|
||||
if (d.cost_1h_usd >= TH.cost_1h_usd) alerts.push({ key: "cost", msg: `최근 1시간 검색원가 ${usd(d.cost_1h_usd, 2)} — 비용 급증 점검` });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<PageHeader title="대시보드" />
|
||||
|
||||
{ops.isError && <ErrorNote error={ops.error} />}
|
||||
{alerts.map((a) => (
|
||||
<p key={a.key} role="alert"
|
||||
className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[13px] font-semibold text-dead-600">
|
||||
<span aria-hidden>⚠ </span>{a.msg}
|
||||
</p>
|
||||
))}
|
||||
{d && alerts.length === 0 && (
|
||||
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[13px] font-semibold text-ok-600">
|
||||
<span aria-hidden>✓ </span>모든 지표가 임계 안에 있습니다
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatTile label="대기" value={d?.pending ?? "—"} tone="warn" sub="검색 순서를 기다리는 작업" />
|
||||
<StatTile label="처리중" value={d?.running ?? "—"} tone="run" sub="워커가 검색 중" />
|
||||
<StatTile label="완료" value={d?.done ?? "—"} tone="ok" sub="누적" />
|
||||
<StatTile label="실패 (DEAD)" value={d?.dead ?? "—"} tone={d && d.dead > 0 ? "dead" : "default"} sub={`최근 1h ${d?.dead_1h ?? "—"}건`} />
|
||||
<StatTile label="큐 지연" value={d ? durationSec(d.oldest_pending_sec) : "—"} sub="가장 오래 기다린 작업" />
|
||||
<StatTile label="차단 (1h)" value={d?.blocks_1h ?? "—"} tone={d && d.blocks_1h > 0 ? "warn" : "default"} sub="쿠팡 봇 감지" />
|
||||
<StatTile label="검색원가 (1h)" value={d ? usd(d.cost_1h_usd, 2) : "—"} sub="AI + 프록시 대역폭" />
|
||||
<StatTile label="DB 풀" value={d ? `${d.pool_pct}%` : "—"} sub={d ? `${d.pool_checked_out}/${d.pool_capacity} 사용 중` : undefined} />
|
||||
</div>
|
||||
|
||||
<Card fill className="flex-1" title="큐 추이" hint="5초 간격 · 이 화면을 열어둔 동안 누적">
|
||||
<div className="min-h-0 flex-1">
|
||||
{trend.current.length < 2 ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
<p className="text-[12px] text-ink-400">수집 중… 잠시 후 그래프가 나타납니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trend.current} margin={{ top: 6, right: 12, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="t" {...xAxisProps} minTickGap={48} />
|
||||
<YAxis allowDecimals={false} {...yAxisProps} />
|
||||
<Tooltip {...tooltipProps} formatter={(v: number, name: string) => [v, name === "pending" ? "대기" : "처리중"]} />
|
||||
<Line type="monotone" dataKey="pending" name="대기" stroke="var(--color-warn-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="running" name="처리중" stroke="var(--color-run-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<Legend items={[
|
||||
{ label: "대기", color: "var(--color-warn-600)" },
|
||||
{ label: "처리중", color: "var(--color-run-600)" },
|
||||
]} />
|
||||
</Card>
|
||||
|
||||
<p className="shrink-0 text-[11px] text-ink-400">
|
||||
임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음).
|
||||
전체 룰은 lps/docs/operations.md 참고.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
lps-admin/src/pages/Jobs.tsx
Normal file
200
lps-admin/src/pages/Jobs.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) 표 + 행별 상세/JSON 모달 + DEAD 재큐. */
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { get, post } from "../api/client";
|
||||
import type { JobDetailRes, JobListItem, JobListRes, JobStatusName, RequeueRes } from "../api/types";
|
||||
import { Button, Card, Empty, ErrorNote, Loading, Modal, PageHeader, ScrollTable, SearchForm, Segmented, StatusBadge } from "../components/ui";
|
||||
import { dateShort, usd, won } from "../lib/format";
|
||||
|
||||
type DetailView = "detail" | "json";
|
||||
|
||||
const COLS = [
|
||||
{ key: "product", label: "상품" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "outcome", label: "결과" },
|
||||
{ key: "price", label: "최저가", align: "right" },
|
||||
{ key: "cost", label: "원가", align: "right" },
|
||||
{ key: "time", label: "요청 시각" },
|
||||
{ key: "actions", label: "", align: "right" },
|
||||
] as const;
|
||||
|
||||
const PAGE = 20;
|
||||
const TABS: { v: JobStatusName | ""; label: string }[] = [
|
||||
{ v: "", label: "전체" },
|
||||
{ v: "PENDING", label: "대기" },
|
||||
{ v: "RUNNING", label: "처리중" },
|
||||
{ v: "DONE", label: "완료" },
|
||||
{ v: "DEAD", label: "실패" },
|
||||
];
|
||||
|
||||
export default function Jobs() {
|
||||
const [status, setStatus] = useState<JobStatusName | "">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [modalJob, setModalJob] = useState<JobListItem | null>(null);
|
||||
const [modalView, setModalView] = useState<DetailView>("detail");
|
||||
|
||||
const open = (j: JobListItem, view: DetailView) => { setModalView(view); setModalJob(j); };
|
||||
|
||||
const params = new URLSearchParams({ limit: String(PAGE), offset: String(page * PAGE) });
|
||||
if (status) params.set("status", status);
|
||||
if (q) params.set("q", q);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ["jobs", status, q, page],
|
||||
queryFn: () => get<JobListRes>(`/v1/lps/jobs?${params}`),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const pages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE));
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<PageHeader title="작업 큐" />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Segmented ariaLabel="상태 필터" options={TABS} value={status}
|
||||
onChange={(v) => { setStatus(v); setPage(0); }} />
|
||||
<button onClick={() => { const v = q === "TEST-" ? "" : "TEST-"; setQ(v); setQInput(v); setPage(0); }}
|
||||
aria-pressed={q === "TEST-"}
|
||||
className={`rounded-lg border px-3 py-1.5 text-[12px] font-semibold ${
|
||||
q === "TEST-" ? "border-primary-600 bg-primary-50 text-primary-700" : "border-line-200 text-ink-500 hover:text-ink-700"
|
||||
}`}>
|
||||
테스트만
|
||||
</button>
|
||||
<SearchForm className="ml-auto" inputClassName="w-56" placeholder="상품코드·상품명 검색"
|
||||
value={qInput} onChange={setQInput} onSubmit={() => { setQ(qInput.trim()); setPage(0); }} />
|
||||
</div>
|
||||
|
||||
<Card fill className="min-h-0 flex-1" title={`작업 ${list.data?.total ?? "—"}건`} hint="15초마다 갱신">
|
||||
{list.isPending && <Loading />}
|
||||
{list.isError && <ErrorNote error={list.error} />}
|
||||
{list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>}
|
||||
{list.data && list.data.items.length > 0 && (
|
||||
<ScrollTable height="fill" columns={COLS}>
|
||||
{list.data.items.map((j) => (
|
||||
<tr key={j.job_id} className="border-b border-line-100 hover:bg-surface-2">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-semibold">{j.product_name || "—"}</div>
|
||||
<div className="text-[11px] text-ink-400">{j.product_code}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3"><StatusBadge status={j.status} /></td>
|
||||
<td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td>
|
||||
<td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td>
|
||||
<td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td>
|
||||
<td className="tnum py-2 pr-3 text-[12px] text-ink-500">{dateShort(j.created_at)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={() => open(j, "detail")}>상세</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => open(j, "json")}>JSON</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</ScrollTable>
|
||||
)}
|
||||
{pages > 1 && (
|
||||
<nav aria-label="페이지" className="mt-3 flex shrink-0 items-center justify-end gap-2 text-[12px]">
|
||||
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>이전</Button>
|
||||
<span className="tnum text-ink-500">{page + 1} / {pages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page + 1 >= pages} onClick={() => setPage((p) => p + 1)}>다음</Button>
|
||||
</nav>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{modalJob && (
|
||||
<JobModal job={modalJob} view={modalView} onView={setModalView} onClose={() => setModalJob(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 행별 "상세"/"JSON" 버튼이 여는 모달. 상단 토글로 두 뷰 전환, JSON 뷰엔 복사 버튼.
|
||||
function JobModal({ job, view, onView, onClose }: {
|
||||
job: JobListItem; view: DetailView; onView: (v: DetailView) => void; onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const detail = useQuery({
|
||||
queryKey: ["job", job.job_id],
|
||||
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job.job_id}`),
|
||||
});
|
||||
const requeue = useMutation({
|
||||
mutationFn: () => post<RequeueRes>(`/v1/lps/jobs/${job.job_id}/requeue`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const out = detail.data?.output as Record<string, any> | undefined;
|
||||
const jsonText = out ? JSON.stringify(out, null, 2) : "";
|
||||
const copyJson = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* 클립보드 권한 없음 — 무시 */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<>상세 <span className="font-medium text-ink-400">— {job.product_name || job.product_code}</span></>}
|
||||
onClose={onClose}
|
||||
actions={
|
||||
<>
|
||||
<Segmented ariaLabel="보기 방식" size="sm" value={view}
|
||||
options={[{ v: "detail", label: "상세" }, { v: "json", label: "JSON" }]} onChange={onView} />
|
||||
{view === "json" && (
|
||||
<Button variant="outline" size="sm" disabled={!out} onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{view === "detail" ? (
|
||||
<>
|
||||
<dl className="space-y-2 text-[13px]">
|
||||
<Row k="작업 ID"><span className="tnum break-all text-[12px]">{job.job_id}</span></Row>
|
||||
<Row k="상품">{job.product_name || "—"}{job.product_code && <span className="text-ink-400"> ({job.product_code})</span>}</Row>
|
||||
<Row k="상태"><StatusBadge status={job.status} /> <span className="text-ink-400">시도 {job.attempts}/{job.max_attempts}</span></Row>
|
||||
{job.last_error && <Row k="오류"><span className="break-all text-dead-600">{job.last_error}</span></Row>}
|
||||
{detail.isPending && <Row k="결과"><span className="text-ink-400">불러오는 중…</span></Row>}
|
||||
{out?.outcome && <Row k="결과">{out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}</Row>}
|
||||
{out?.lowest && <Row k="최저가"><b>{won(out.lowest.price)}</b> <span className="text-ink-400">({out.lowest.source} · {out.lowest.mall_name || "—"})</span></Row>}
|
||||
{out?.metrics?.cost && (
|
||||
<Row k="검색 원가">
|
||||
{usd(out.metrics.cost.total_usd)} <span className="text-ink-400">(AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)})</span>
|
||||
</Row>
|
||||
)}
|
||||
{out?.metrics?.duration_ms != null && <Row k="소요">{(out.metrics.duration_ms / 1000).toFixed(1)}초</Row>}
|
||||
</dl>
|
||||
|
||||
{job.status === "DEAD" && (
|
||||
<div className="mt-4 border-t border-line-100 pt-3">
|
||||
<Button size="lg" className="w-full" onClick={() => requeue.mutate()} disabled={requeue.isPending}>
|
||||
{requeue.isPending ? "재시도 요청 중…" : "다시 검색하기 (재큐)"}
|
||||
</Button>
|
||||
{requeue.isError && <p className="mt-2 text-[12px] font-medium text-dead-600">{(requeue.error as Error).message}</p>}
|
||||
{requeue.isSuccess && <p className="mt-2 text-[12px] font-medium text-ok-600">대기열에 다시 넣었습니다 — 워커가 곧 처리합니다</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : detail.isPending ? (
|
||||
<Loading />
|
||||
) : detail.isError ? (
|
||||
<ErrorNote error={detail.error} />
|
||||
) : out ? (
|
||||
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
|
||||
) : (
|
||||
<Empty>이 작업에는 원본 결과가 없습니다 (완료 전이거나 실패)</Empty>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, children }: { k: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-16 shrink-0 text-[12px] font-semibold text-ink-400">{k}</dt>
|
||||
<dd className="min-w-0 flex-1">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
lps-admin/src/pages/Products.tsx
Normal file
277
lps-admin/src/pages/Products.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
/** 상품·가격 — 이력 상품 목록 → 가격 추이 3선(네이버·쿠팡·최종) + 몰별 가격 비교 + 재검색. */
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
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 { 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";
|
||||
|
||||
// 시리즈 색은 엔터티 고정(네이버 초록은 CVD 분리 검증 통과 톤) — 순서·색 재배정 금지.
|
||||
const SERIES = [
|
||||
{ key: "naver", label: "네이버", color: "var(--color-naver)" },
|
||||
{ key: "coupang", label: "쿠팡", color: "var(--color-coupang)" },
|
||||
{ key: "final", label: "최종 최저가", color: "var(--color-final)" },
|
||||
] as const;
|
||||
|
||||
// 커스텀 툴팁 — 둥근 카드·부드러운 그림자·토큰 색. 값 없는(못 찾은) 소스는 생략, 시리즈 순서 고정.
|
||||
function ChartTooltip({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: { dataKey?: string | number; value?: number; name?: string }[];
|
||||
label?: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const byKey = new Map(payload.map((p) => [p.dataKey, p]));
|
||||
return (
|
||||
<div className="rounded-xl border border-line-200 bg-surface px-3 py-2 shadow-[0_8px_24px_rgba(16,24,40,.14)]">
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-ink-400">{label}</div>
|
||||
<ul className="space-y-1">
|
||||
{SERIES.map((s) => {
|
||||
const p = byKey.get(s.key);
|
||||
if (p?.value == null) return null;
|
||||
return (
|
||||
<li key={s.key} className="flex items-center gap-2 text-[12px]">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: s.color }} aria-hidden />
|
||||
<span className="text-ink-500">{s.label}</span>
|
||||
<span className="tnum ml-auto pl-6 font-bold text-ink-900">{won(p.value)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Products() {
|
||||
const [q, setQ] = useState("");
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [selected, setSelected] = useState<ProductItem | null>(null);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ["products", q],
|
||||
queryFn: () => get<ProductListRes>(`/v1/lps/products?limit=100${q ? `&q=${encodeURIComponent(q)}` : ""}`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<PageHeader title="상품·가격" />
|
||||
|
||||
<SearchForm placeholder="상품코드 검색" value={qInput} onChange={setQInput} onSubmit={() => setQ(qInput.trim())} />
|
||||
|
||||
<Card fill className="min-h-0 min-w-0 flex-1" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
|
||||
{list.isPending && <Loading />}
|
||||
{list.isError && <ErrorNote error={list.error} />}
|
||||
{list.data && list.data.items.length === 0 && <Empty>아직 검색된 상품이 없습니다</Empty>}
|
||||
{list.data && list.data.items.length > 0 && (
|
||||
<ScrollBox height="fill">
|
||||
<ul className="divide-y divide-line-100">
|
||||
{list.data.items.map((p) => (
|
||||
<li key={p.product_code}>
|
||||
<button onClick={() => setSelected(p)}
|
||||
className={`block w-full border-l-2 px-3 py-2.5 text-left transition-colors ${
|
||||
selected?.product_code === p.product_code
|
||||
? "border-primary-600 bg-primary-50/70"
|
||||
: "border-transparent hover:bg-surface-2"
|
||||
}`}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-[13px] font-semibold">{p.display_name || p.product_code}</span>
|
||||
<span className="tnum shrink-0 text-[13px] font-bold">{won(p.final_lowest)}</span>
|
||||
</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>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollBox>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ProductDetail product={selected} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductDetail({ product }: { product: ProductItem | null }) {
|
||||
const qc = useQueryClient();
|
||||
// 그래프에서 클릭한 시점(triggered_at). 없으면 최신 시점. 상품이 바뀌어 목록에 없으면 자동으로 최신으로 폴백.
|
||||
const [selectedTs, setSelectedTs] = useState<string | null>(null);
|
||||
const history = useQuery({
|
||||
queryKey: ["history", product?.product_code],
|
||||
queryFn: () => get<PriceHistoryRes>(`/v1/lps/products/${encodeURIComponent(product!.product_code)}/history?limit=200`),
|
||||
enabled: !!product,
|
||||
});
|
||||
const research = useMutation({
|
||||
mutationFn: () => post<SearchRes>("/v1/lps/search", {
|
||||
data: [{ product_code: product!.product_code, product_name: product!.display_name || product!.product_code, job_type: "manual" }],
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
|
||||
});
|
||||
|
||||
if (!product) return (
|
||||
<Card fill className="min-h-0 flex-[2]" title="가격 추이">
|
||||
<div className="grid h-full place-items-center"><Empty>목록에서 상품을 선택하세요</Empty></div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) }));
|
||||
const latest = history.data?.points.at(-1);
|
||||
// 클릭한 시점의 데이터 포인트(없으면 최신). 몰별 비교 표에 이 시점의 by_mall 을 보여준다.
|
||||
const activePoint = points.find((p) => p.triggered_at === selectedTs) ?? latest;
|
||||
|
||||
return (
|
||||
// xl 이상: 왼쪽 그래프 + 오른쪽 몰별 표 가로 배치. 그 이하: 세로 스택(스크롤).
|
||||
// min-w-0: 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지.
|
||||
<div className="flex min-h-0 min-w-0 flex-[2] flex-col gap-4 max-xl:overflow-y-auto xl:flex-row">
|
||||
<Card fill className="min-w-0 flex-1"
|
||||
title={<>가격 추이 — {product.display_name || product.product_code}</>}
|
||||
hint={
|
||||
<Button size="sm" onClick={() => research.mutate()} disabled={research.isPending}>
|
||||
{research.isPending ? "요청 중…" : "지금 다시 검색"}
|
||||
</Button>
|
||||
}>
|
||||
{research.isSuccess && (
|
||||
<p className="mb-2 shrink-0 rounded-lg bg-ok-50 px-3 py-1.5 text-[12px] font-medium text-ok-600">
|
||||
{research.data.accepted > 0 ? "검색을 접수했습니다 — 잠시 후 새 점이 추가됩니다" : "이미 같은 상품 검색이 진행 중입니다"}
|
||||
</p>
|
||||
)}
|
||||
{research.isError && <div className="mb-2 shrink-0"><ErrorNote error={research.error} /></div>}
|
||||
<div className="min-h-[240px] flex-1">
|
||||
{history.isPending ? (
|
||||
<div className="grid h-full place-items-center"><Loading /></div>
|
||||
) : history.isError ? (
|
||||
<ErrorNote error={history.error} />
|
||||
) : points.length === 0 ? (
|
||||
<div className="grid h-full place-items-center"><Empty>이력이 없습니다</Empty></div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={points} margin={{ top: 10, right: 16, bottom: 0, left: 0 }} style={{ cursor: "pointer" }}
|
||||
onClick={(state) => {
|
||||
const ts = (state as { activePayload?: { payload?: PricePoint }[] })?.activePayload?.[0]?.payload?.triggered_at;
|
||||
if (ts) setSelectedTs(ts);
|
||||
}}>
|
||||
<defs>
|
||||
<linearGradient id="finalArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-final)" stopOpacity={0.14} />
|
||||
<stop offset="100%" stopColor="var(--color-final)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid {...gridProps} />
|
||||
<XAxis dataKey="x" {...xAxisProps} minTickGap={56} tickMargin={8} />
|
||||
<YAxis {...yAxisProps} width={64} tickMargin={4} tickFormatter={(v: number) => v.toLocaleString("ko-KR")} domain={[0, "auto"]} />
|
||||
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "var(--color-line-200)", strokeWidth: 1 }} />
|
||||
{activePoint && (
|
||||
<ReferenceLine x={dateShort(activePoint.triggered_at)} stroke="var(--color-ink-400)" strokeDasharray="3 3" />
|
||||
)}
|
||||
{SERIES.filter((s) => s.key !== "final").map((s) => (
|
||||
<Line key={s.key} type="monotone" dataKey={s.key} name={s.label} stroke={s.color} strokeWidth={2.5}
|
||||
strokeLinecap="round" connectNulls isAnimationActive={false}
|
||||
dot={{ r: 3, fill: s.color, stroke: "var(--color-surface)", strokeWidth: 1.5 }}
|
||||
activeDot={{ r: 5, fill: s.color, stroke: "var(--color-surface)", strokeWidth: 2 }} />
|
||||
))}
|
||||
<Area type="monotone" dataKey="final" name="최종 최저가" stroke="var(--color-final)" strokeWidth={2.5}
|
||||
strokeLinecap="round" fill="url(#finalArea)" connectNulls isAnimationActive={false}
|
||||
dot={{ r: 3, fill: "var(--color-final)", stroke: "var(--color-surface)", strokeWidth: 1.5 }}
|
||||
activeDot={{ r: 5, fill: "var(--color-final)", stroke: "var(--color-surface)", strokeWidth: 2 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
{points.length > 0 && (
|
||||
<Legend items={SERIES.map((s) => ({ label: s.label, color: s.color }))}
|
||||
note="빈 구간 = 그 시점에 해당 소스에서 못 찾음" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{activePoint ? (
|
||||
<MallCompare point={activePoint} />
|
||||
) : (
|
||||
<Card fill className="min-w-0 xl:w-[320px] xl:shrink-0" title="몰별 가격 비교">
|
||||
<div className="grid h-full place-items-center"><Empty>최근 검색 데이터가 없습니다</Empty></div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 몰별 가격 비교(최근 검색) — 컴팩트 표. 막대 제거, 소스(발견 채널) 뱃지 추가, 상품명은 보조줄로 유지.
|
||||
const TOP_OPTIONS: { v: number | "all"; label: string }[] = [
|
||||
{ v: 3, label: "3" }, { v: 5, label: "5" }, { v: 10, label: "10" }, { v: "all", label: "전체" },
|
||||
];
|
||||
|
||||
const MALL_COLS = [
|
||||
{ key: "rank", label: "#", width: "2.25rem" },
|
||||
{ key: "mall", label: "쇼핑몰" },
|
||||
{ key: "price", label: "가격", align: "right", width: "5rem" },
|
||||
] as const;
|
||||
|
||||
// 소스(발견 채널) 색·라벨 — 시리즈 색 재사용(엔터티 고정).
|
||||
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 || "기타");
|
||||
|
||||
function MallCompare({ point }: { point: PricePoint }) {
|
||||
const [topN, setTopN] = useState<number | "all">(5);
|
||||
const malls = (point.by_mall ?? [])
|
||||
.filter((m) => m.price != null)
|
||||
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
|
||||
const shown = topN === "all" ? malls : malls.slice(0, topN);
|
||||
const cheapest = malls[0]?.price ?? 0;
|
||||
|
||||
return (
|
||||
<Card fill className="min-w-0 xl:w-[320px] xl:shrink-0"
|
||||
title="몰별 가격 비교"
|
||||
hint={<Segmented ariaLabel="상위 N개" size="sm" value={topN} options={TOP_OPTIONS} onChange={setTopN} />}>
|
||||
<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>
|
||||
{shown.length === 0 ? (
|
||||
<div className="grid h-full place-items-center"><Empty>몰별 데이터 없음</Empty></div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollTable height="fill" dense fixed columns={MALL_COLS}>
|
||||
{shown.map((m, i) => {
|
||||
const price = m.price ?? 0;
|
||||
const delta = price - cheapest;
|
||||
const pct = cheapest > 0 ? (delta / cheapest) * 100 : 0;
|
||||
const src = srcColor(m.source);
|
||||
return (
|
||||
<tr key={i} className="border-b border-line-100 hover:bg-surface-2">
|
||||
<td className="py-1.5 pr-1 align-top">
|
||||
<span className={`tnum grid h-4 w-4 place-items-center rounded-full text-[10px] font-bold ${i === 0 ? "bg-ok-50 text-ok-600" : "bg-surface-2 text-ink-500"}`}>{i + 1}</span>
|
||||
</td>
|
||||
<td className="min-w-0 py-1.5 pr-2 align-top">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 rounded px-1 text-[9px] font-bold leading-normal" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{srcLabel(m.source)}</span>
|
||||
<span className="truncate text-[11px] font-semibold">{m.mall_name || m.source}</span>
|
||||
{i === 0 && <span className="shrink-0 rounded bg-ok-50 px-1 text-[9px] font-bold text-ok-600">최저</span>}
|
||||
</div>
|
||||
<div className="truncate text-[10px] leading-tight text-ink-400">{m.name}</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-right align-top">
|
||||
<div className="tnum whitespace-nowrap text-[12px] font-bold leading-tight">{won(price)}</div>
|
||||
<div className="tnum mt-0.5 flex items-center justify-end gap-0.5 whitespace-nowrap text-[10px] text-ink-400">
|
||||
<span>{delta === 0 ? "최저" : `+${pct.toFixed(1)}%`}</span>
|
||||
{m.detail_url && (
|
||||
<a href={m.detail_url} target="_blank" rel="noreferrer"
|
||||
className="font-bold text-primary-600 hover:underline" aria-label="상품 페이지 열기 (새 탭)">↗</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</ScrollTable>
|
||||
{topN !== "all" && malls.length > shown.length && (
|
||||
<p className="mt-2 shrink-0 text-[11px] text-ink-400">몰 {malls.length}개 중 상위 {shown.length}개 — '전체'로 모두 보기</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
50
lps-admin/src/pages/Settings.tsx
Normal file
50
lps-admin/src/pages/Settings.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
/** 설정 — API base URL·guard 키(localStorage). prod 에서 X-API-Key 헤더로 자동 첨부. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { settings } from "../api/client";
|
||||
import { Button, Card, PageHeader } from "../components/ui";
|
||||
|
||||
export default function Settings() {
|
||||
const [baseUrl, setBaseUrl] = useState(settings.baseUrl());
|
||||
const [apiKey, setApiKey] = useState(settings.apiKey());
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-4">
|
||||
<PageHeader title="설정" />
|
||||
<Card title="API 연결">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
settings.save(baseUrl.trim(), apiKey.trim());
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="base" className="mb-1 block text-[12px] font-semibold text-ink-700">API 주소</label>
|
||||
<input id="base" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="비우면 현재 주소 기준 (개발: vite 프록시 → :9600)"
|
||||
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
|
||||
<p className="mt-1 text-[11px] text-ink-400">예: https://lps.example.com — dev 서버를 직접 보려면 해당 주소 입력</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="key" className="mb-1 block text-[12px] font-semibold text-ink-700">API 키 (X-API-Key)</label>
|
||||
<input id="key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="개발(개방 모드)은 비워둠 — prod 만 필요"
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
|
||||
<p className="mt-1 text-[11px] text-ink-400">서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.</p>
|
||||
</div>
|
||||
<Button type="submit" size="lg">저장</Button>
|
||||
{saved && <span className="ml-2 text-[12px] font-medium text-ok-600">저장했습니다</span>}
|
||||
</form>
|
||||
</Card>
|
||||
<p className="text-[11px] leading-relaxed text-ink-400">
|
||||
지표 임계·요청 예산 등 서버 동작 설정은 이 화면에서 바꾸지 않습니다 — 설정 소스는 서버의
|
||||
config.<env>.toml 하나이며(2026-07-13 협의), 변경은 toml 수정 + 재시작으로 합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
lps-admin/src/pages/TestSearch.tsx
Normal file
426
lps-admin/src/pages/TestSearch.tsx
Normal file
@ -0,0 +1,426 @@
|
||||
/** 테스트 검색 — 임의 상품을 실제 파이프라인(큐→워커→네이버·쿠팡→AI 매칭)에 흘려보내고
|
||||
* 단계별 퍼널·소스별 결과·매칭 상품·검색 원가를 시각화한다. 셀렉터·프록시·매칭 회귀 점검용.
|
||||
*
|
||||
* product_code 는 TEST-<시각> 으로 자동 생성 — 프로덕션 데이터(잡·이력·비용통계)와 필터로
|
||||
* 구분되고, negodata 는 UUID 만 매핑하므로 연동에 영향 없다. 자격증명은 서버에만 있으며
|
||||
* 여기 결과엔 집계된 프록시 사용량(바이트·$)만 표시된다. */
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { get, post } from "../api/client";
|
||||
import type { JobDetailRes, JobOutput, SearchRes } from "../api/types";
|
||||
import { Button, Card, Empty, ErrorNote, Modal, PageHeader, ScrollTable } from "../components/ui";
|
||||
import { usd, won } from "../lib/format";
|
||||
|
||||
const TOP_COLS = [
|
||||
{ key: "source", label: "소스" },
|
||||
{ key: "name", label: "상품명" },
|
||||
{ key: "price", label: "가격", align: "right" },
|
||||
{ key: "ship", label: "배송", align: "right" },
|
||||
{ key: "link", label: "" },
|
||||
] as const;
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" };
|
||||
const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s;
|
||||
// 소스(발견 채널) 색 — 시리즈 색 재사용(엔터티 고정). 상품·가격 몰별 비교와 동일 언어.
|
||||
const srcColor = (s: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
|
||||
// 배송 표기 — 쿠팡 로켓 계열(로켓배송·판매자로켓·로켓프레시…) / 무료 / 유료 / 미상.
|
||||
function shipInfo(fee?: number | null, type?: string | null): { text: string; tone: "rocket" | "free" | "paid" | "none" } {
|
||||
const t = (type ?? "").toLowerCase();
|
||||
if (t.startsWith("rocket")) {
|
||||
const label = t.includes("fresh") ? "로켓프레시" : t.includes("wow") ? "로켓와우"
|
||||
: t.includes("global") ? "로켓직구" : (t.includes("merchant") || t.includes("seller")) ? "판매자로켓" : "로켓배송";
|
||||
return { text: label, tone: "rocket" };
|
||||
}
|
||||
if (t === "free" || fee === 0) return { text: "무료", tone: "free" };
|
||||
if (fee != null && fee > 0) return { text: `+${fee.toLocaleString("ko-KR")}원`, tone: "paid" };
|
||||
return { text: "—", tone: "none" };
|
||||
}
|
||||
// 소스·몰명 중복 제거 — 몰명이 없거나 소스 라벨과 같으면 소스만.
|
||||
const sourceMall = (source: string, mall?: string) => {
|
||||
const sl = sourceLabel(source);
|
||||
return mall && mall !== sl ? `${sl} · ${mall}` : sl;
|
||||
};
|
||||
|
||||
// 파이프라인 단계 한글 라벨 — result.stages 의 stage 키 매핑.
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
filter: "가격/몰 필터",
|
||||
outlier: "이상치 제거",
|
||||
ai_match: "AI 같은상품 판정",
|
||||
top_n: "최저가 top-N",
|
||||
};
|
||||
|
||||
function makeTestCode() {
|
||||
// Date.now 로 사람이 읽을 수 있는 시각 코드(중복 방지 + 목록 필터 구분).
|
||||
const d = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `TEST-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
product_name: string;
|
||||
model: string;
|
||||
specification: string;
|
||||
company: string;
|
||||
price: string;
|
||||
}
|
||||
|
||||
export default function TestSearch() {
|
||||
const [form, setForm] = useState<FormState>({ product_name: "", model: "", specification: "", company: "", price: "" });
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [testCode, setTestCode] = useState<string | null>(null);
|
||||
const startedAt = useRef<number>(0);
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
const code = makeTestCode();
|
||||
const res = await post<SearchRes>("/v1/lps/search", {
|
||||
data: [{
|
||||
product_code: code,
|
||||
product_name: form.product_name.trim(),
|
||||
job_type: "manual",
|
||||
model: form.model.trim(),
|
||||
specification: form.specification.trim(),
|
||||
company: form.company.trim(),
|
||||
price: form.price.trim(),
|
||||
}],
|
||||
});
|
||||
return { res, code };
|
||||
},
|
||||
onSuccess: ({ res, code }) => {
|
||||
const entry = res.items[0];
|
||||
setTestCode(code);
|
||||
startedAt.current = Date.now();
|
||||
setJobId(entry?.job_id ?? null);
|
||||
},
|
||||
});
|
||||
|
||||
// 잡 완료까지 2초 폴링. DONE/DEAD 면 멈춘다.
|
||||
const job = useQuery({
|
||||
queryKey: ["test-job", jobId],
|
||||
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${jobId}`),
|
||||
enabled: !!jobId,
|
||||
refetchInterval: (q) => {
|
||||
const st = q.state.data?.status;
|
||||
return st === "DONE" || st === "DEAD" ? false : 2000;
|
||||
},
|
||||
});
|
||||
|
||||
const status = job.data?.status;
|
||||
const output = job.data?.output;
|
||||
const done = status === "DONE" || status === "DEAD";
|
||||
|
||||
// 진행 중엔 1초마다 경과 시간을 갱신(폴링 간격과 무관하게 매끄럽게), 완료되면 정지.
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!jobId || done) return;
|
||||
const tick = () => setElapsed(Math.floor((Date.now() - startedAt.current) / 1000));
|
||||
tick();
|
||||
const t = setInterval(tick, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [jobId, done]);
|
||||
|
||||
const pendingTooLong = status === "PENDING" && elapsed > 8;
|
||||
|
||||
const reset = () => {
|
||||
setJobId(null);
|
||||
setTestCode(null);
|
||||
submit.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<PageHeader title="테스트 검색" />
|
||||
<p className="text-[11px] text-ink-400">
|
||||
실제 크롤이라 <b className="font-semibold text-warn-700">프록시 비용 발생</b> · 비용 통계 집계 (상품코드 <code className="rounded bg-surface-2 px-1">TEST-</code>)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[360px_1fr]">
|
||||
{/* 왼쪽: 검색 조건 + 진행 상태(함께 sticky — 결과를 스크롤해도 폼·상태가 붙어 있음) */}
|
||||
<div className="space-y-3 lg:sticky lg:top-0 lg:self-start">
|
||||
<Card compact title="검색 조건">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim() && !jobId) submit.mutate(); }}
|
||||
>
|
||||
<Field id="pn" label="상품명" required value={form.product_name}
|
||||
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
|
||||
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
|
||||
</div>
|
||||
<Field id="spec" label="규격" value={form.specification}
|
||||
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
|
||||
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
|
||||
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션. 제출 후엔 초기화 전까지 재검색 차단(중복 비용 방지). */}
|
||||
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || !!jobId}
|
||||
className="flex-1 rounded-lg bg-warn-700 px-3 py-2 text-[13px] font-semibold text-white hover:bg-warn-600 disabled:opacity-50">
|
||||
{submit.isPending ? "제출 중…" : jobId ? "실행됨 — 초기화 후 재검색" : "검색 실행"}
|
||||
</button>
|
||||
{jobId && (
|
||||
<Button type="button" variant="outline" size="lg" onClick={reset}>초기화</Button>
|
||||
)}
|
||||
</div>
|
||||
{submit.isError && <ErrorNote error={submit.error} />}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{jobId && (
|
||||
<Card compact title="진행 상태" hint={testCode ?? undefined}>
|
||||
<Progress status={status} elapsed={elapsed} />
|
||||
{job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>}
|
||||
{pendingTooLong && (
|
||||
<p role="alert" className="mt-3 rounded-lg bg-warn-50 px-3 py-2 text-[12px] font-medium text-warn-700">
|
||||
{elapsed}초째 대기 중 — 워커가 실행 중인지 확인하세요 (<code>./run_local_worker.sh</code>). 워커가 없으면 크롤이 시작되지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
{status === "DEAD" && (
|
||||
<p role="alert" className="mt-3 rounded-lg bg-dead-50 px-3 py-2 text-[12px] font-medium text-dead-600">
|
||||
검색 실패(재시도 소진) — {job.data?.last_error || "차단·오류로 결과를 얻지 못했습니다"}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 결과 */}
|
||||
<div className="space-y-3">
|
||||
{status === "DONE" && output ? (
|
||||
<ResultView output={output} />
|
||||
) : (
|
||||
<Card compact title="결과">
|
||||
<Empty>
|
||||
{!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요"
|
||||
: status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요"
|
||||
: "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"}
|
||||
</Empty>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 진행 스텝퍼 ──────────────────────────────────────────────
|
||||
function Progress({ status, elapsed }: { status?: string; elapsed: number }) {
|
||||
const steps = [
|
||||
{ key: "PENDING", label: "대기" },
|
||||
{ key: "RUNNING", label: "크롤·판정 중" },
|
||||
{ key: "DONE", label: "완료" },
|
||||
];
|
||||
const order = ["PENDING", "RUNNING", "DONE"];
|
||||
const curIdx = status === "DEAD" ? 1 : Math.max(0, order.indexOf(status ?? "PENDING"));
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{steps.map((s, i) => {
|
||||
const done = i < curIdx || status === "DONE";
|
||||
const active = i === curIdx && status !== "DONE";
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-2">
|
||||
<span className={`flex h-6 items-center rounded-full px-2.5 text-[12px] font-semibold ${
|
||||
done ? "bg-ok-50 text-ok-600" : active ? "bg-run-50 text-run-600" : "bg-surface-2 text-ink-400"
|
||||
}`}>
|
||||
{active && <span className="mr-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-run-600" aria-hidden />}
|
||||
{s.label}
|
||||
</span>
|
||||
{i < steps.length - 1 && <span className="h-px w-4 bg-line-200" aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto tnum text-[12px] text-ink-400">{elapsed}초</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 결과 뷰: 판정 요약 + 퍼널 + 소스별 + 매칭 상품 + 비용 ──────
|
||||
function ResultView({ output }: { output: JobOutput }) {
|
||||
const found = output.outcome === "found";
|
||||
const m = output.metrics;
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const jsonText = JSON.stringify(output, null, 2);
|
||||
const copyJson = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* 클립보드 권한 없음 — 무시 */ }
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Card compact className={found ? "border-l-[3px] border-l-primary-500" : ""}
|
||||
title="판정" hint={output.round ? `${output.round} 라운드에서 매칭` : output.cached ? "네거티브 캐시" : undefined}>
|
||||
{found ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="rounded-full bg-ok-50 px-2 py-0.5 text-[12px] font-semibold text-ok-600">같은 상품 찾음</span>
|
||||
{output.lowest && (
|
||||
<span className="text-[15px] font-bold">
|
||||
최저가 {won(output.lowest.price)}
|
||||
<span className="ml-1.5 text-[12px] font-medium text-ink-400">
|
||||
({sourceMall(output.lowest.source, output.lowest.mall_name)})
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[12px] font-semibold text-ink-500">
|
||||
같은 상품 없음 {output.rounds_tried ? `· ${output.rounds_tried}개 검색어로 시도` : ""}
|
||||
</span>
|
||||
)}
|
||||
{output.query && <p className="mt-2 text-[12px] text-ink-500">최종 검색어: <b>{output.query}</b></p>}
|
||||
</Card>
|
||||
|
||||
{output.stages && output.stages.length > 0 && (
|
||||
<Card compact title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
|
||||
<FunnelChart stages={output.stages} totalFound={output.total_found} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{output.sources && Object.keys(output.sources).length > 0 && (
|
||||
<Card compact title="소스별 수집" hint="검색어 매칭 전 원본 건수">
|
||||
{output.query && (
|
||||
<p className="mb-2 rounded-md bg-surface-2 px-2 py-1 text-[11px] text-ink-500">
|
||||
각 사이트 검색어 <span className="font-semibold text-ink-700">"{output.query}"</span>
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-1 text-[12px]">
|
||||
{Object.entries(output.sources).map(([s, v]) => (
|
||||
<li key={s} className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-semibold">{sourceLabel(s)}</span>
|
||||
<span className="tnum text-right">
|
||||
{v.error ? (
|
||||
<span className="font-semibold text-dead-600">실패 · {v.error.split(":")[0]}</span>
|
||||
) : (
|
||||
<>{v.count ?? 0}건</>
|
||||
)}
|
||||
{m?.source_ms?.[s] != null && <span className="ml-1 text-ink-400">· {(m.source_ms[s] / 1000).toFixed(1)}초</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{m && (
|
||||
<Card compact title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
|
||||
<ul className="space-y-1 text-[12px]">
|
||||
<li className="flex justify-between"><span className="text-ink-500">총 비용</span><span className="tnum font-bold">{usd(m.cost?.total_usd)}</span></li>
|
||||
<li className="flex justify-between"><span className="text-ink-500">AI 판정·검색어</span><span className="tnum">{usd(m.cost?.ai_usd)}</span></li>
|
||||
<li className="flex justify-between"><span className="text-ink-500">프록시 대역폭</span><span className="tnum">{usd(m.cost?.proxy_usd)}</span></li>
|
||||
{m.crawl && (
|
||||
<li className="flex justify-between border-t border-line-100 pt-1.5">
|
||||
<span className="text-ink-500">프록시 전송량</span>
|
||||
<span className="tnum text-ink-400">{(m.crawl.proxy_bytes / 1024).toFixed(0)} KB · fetch {m.crawl.fetches}회</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{output.top && output.top.length > 0 && (
|
||||
<Card compact title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}>
|
||||
<ScrollTable height="auto" dense columns={TOP_COLS}>
|
||||
{output.top.map((p, i) => {
|
||||
const src = srcColor(p.source);
|
||||
const ship = shipInfo(p.shipping_fee, p.shipping_type);
|
||||
return (
|
||||
<tr key={i} className="border-b border-line-100 hover:bg-surface-2">
|
||||
<td className="py-1.5 pr-2 align-top">
|
||||
<span className="inline-block whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{sourceLabel(p.source)}</span>
|
||||
</td>
|
||||
<td className="max-w-md truncate py-1.5 pr-2 align-top">{p.name}</td>
|
||||
<td className="py-1.5 pr-2 text-right align-top whitespace-nowrap">
|
||||
<span className={`tnum font-bold ${i === 0 ? "text-ok-600" : ""}`}>{won(p.price)}</span>
|
||||
{i === 0 && <span className="ml-1 whitespace-nowrap rounded bg-ok-50 px-1 text-[10px] font-bold text-ok-600">최저</span>}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right align-top">
|
||||
{ship.tone === "rocket" ? (
|
||||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold text-run-600"
|
||||
style={{ background: "color-mix(in srgb, var(--color-run-600) 12%, transparent)" }}>🚀 {ship.text}</span>
|
||||
) : (
|
||||
<span className={`tnum whitespace-nowrap text-[11px] ${ship.tone === "free" ? "font-semibold text-ok-600" : "text-ink-400"}`}>{ship.text}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right align-top">
|
||||
{p.detail_url && (
|
||||
<a href={p.detail_url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-line-200 px-2 py-0.5 text-[11px] font-semibold text-ink-700 transition-colors hover:bg-line-200 hover:text-ink-900">사이트 →</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</ScrollTable>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowJson(true)}>원본 결과(JSON) 보기</Button>
|
||||
</div>
|
||||
|
||||
{showJson && (
|
||||
<Modal title="원본 결과 (JSON)" onClose={() => setShowJson(false)}
|
||||
actions={<Button variant="outline" size="sm" onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>}>
|
||||
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 퍼널: 단계별 남은 건수 가로 막대(수집→최종 매칭) ──────────
|
||||
function FunnelChart({ stages, totalFound }: { stages: { stage: string; in: number; out: number }[]; totalFound?: number }) {
|
||||
const rows = [
|
||||
...(totalFound != null ? [{ label: "수집", value: totalFound }] : []),
|
||||
...stages.map((s) => ({ label: STAGE_LABEL[s.stage] ?? s.stage, value: s.out })),
|
||||
];
|
||||
const max = Math.max(1, ...rows.map((r) => r.value));
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{rows.map((r, i) => {
|
||||
const prev = i > 0 ? rows[i - 1].value : null;
|
||||
const drop = prev != null ? r.value - prev : null; // 이전 단계 대비 증감(보통 감소)
|
||||
const dropPct = prev && prev > 0 && drop != null && drop < 0 ? Math.round((drop / prev) * 100) : null;
|
||||
return (
|
||||
// 막대는 고정 폭(14rem)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로.
|
||||
<li key={i} className="grid grid-cols-[6.5rem_14rem_2.25rem_1fr] items-center gap-x-2 text-[12px]">
|
||||
<span className="truncate text-ink-500">{r.label}</span>
|
||||
<div className="h-4 overflow-hidden rounded bg-surface-2">
|
||||
<div className="h-full rounded bg-gradient-to-r from-primary-400 to-primary-600"
|
||||
style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "3px" : 0 }} aria-hidden />
|
||||
</div>
|
||||
<span className="tnum text-right font-semibold">{r.value}</span>
|
||||
{/* 이전 단계 대비 감소량 — 어디서 확 걸러졌는지 한눈에 */}
|
||||
<span className={`tnum whitespace-nowrap text-right text-[11px] ${drop != null && drop < 0 ? "font-semibold text-dead-600" : "text-ink-400"}`}>
|
||||
{drop == null ? "" : drop < 0 ? `−${-drop}${dropPct != null ? ` (${dropPct}%)` : ""}` : "—"}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 폼 필드(라벨-인풋 연결, Settings 패턴 준수) ──────────────
|
||||
function Field({ id, label, value, onChange, placeholder, required, inputMode }: {
|
||||
id: string; label: string; value: string; onChange: (v: string) => void;
|
||||
placeholder?: string; required?: boolean; inputMode?: "numeric";
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="mb-1 block text-[12px] font-semibold text-ink-700">
|
||||
{label}{required && <span className="ml-0.5 text-dead-600">*</span>}
|
||||
</label>
|
||||
<input id={id} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
|
||||
inputMode={inputMode} autoComplete="off"
|
||||
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
lps-admin/tsconfig.json
Normal file
18
lps-admin/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
lps-admin/tsconfig.tsbuildinfo
Normal file
1
lps-admin/tsconfig.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./src/main.tsx","./src/api/client.ts","./src/api/types.ts","./src/components/layout.tsx","./src/components/ui.tsx","./src/lib/format.ts","./src/pages/costs.tsx","./src/pages/crawl.tsx","./src/pages/dashboard.tsx","./src/pages/jobs.tsx","./src/pages/products.tsx","./src/pages/settings.tsx"],"version":"5.6.3"}
|
||||
19
lps-admin/vite.config.ts
Normal file
19
lps-admin/vite.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// dev 서버는 /v1·/healthz·/readyz 를 LPS API(:9600)로 프록시한다 — CORS 설정 불필요.
|
||||
// 다른 API 호스트를 보려면 VITE_LPS_URL 로 대상 변경(또는 앱 내 설정에서 base URL 지정).
|
||||
const target = process.env.VITE_LPS_URL || "http://localhost:9600";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/v1": { target, changeOrigin: true },
|
||||
"/healthz": { target, changeOrigin: true },
|
||||
"/readyz": { target, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,672 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LPS · 최저가 검색 콘솔 (임시 FE)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f6f8; --surface:#ffffff; --surface-2:#fafbfc;
|
||||
--border:#e6e8ec; --border-2:#eef0f3;
|
||||
--text-900:#1a1d24; --text-700:#3d434f; --text-500:#6b7280; --text-400:#9aa1ac;
|
||||
--primary-600:#4f46e5; --primary-500:#635bff; --primary-50:#eef0ff;
|
||||
--naver:#03c75a; --coupang:#f0455b; --final:#4f46e5;
|
||||
--ok-600:#0f9d58; --ok-50:#e7f6ee;
|
||||
--warn-600:#c77800; --warn-50:#fdf3e3;
|
||||
--run-600:#2563eb; --run-50:#e8f0ff;
|
||||
--dead-600:#dc2626; --dead-50:#fdeaea;
|
||||
--radius:14px; --radius-sm:9px;
|
||||
--shadow:0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06);
|
||||
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{background:var(--bg);color:var(--text-900);font-family:var(--sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-600);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
|
||||
/* ---- layout ---- */
|
||||
header.top{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.82);backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
|
||||
.top-inner{max-width:1180px;margin:0 auto;padding:12px 22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16px}
|
||||
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--primary-500),#8b7bff);display:grid;place-items:center;color:#fff;font-size:15px;font-weight:800}
|
||||
.brand small{display:block;font-weight:500;font-size:11px;color:var(--text-400);letter-spacing:.02em}
|
||||
.top-spacer{flex:1}
|
||||
.stat-badges{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700)}
|
||||
.badge .dot{width:7px;height:7px;border-radius:50%;background:var(--text-400)}
|
||||
.badge b{font-variant-numeric:tabular-nums}
|
||||
.badge.pending .dot{background:var(--warn-600)} .badge.running .dot{background:var(--run-600)}
|
||||
.badge.done .dot{background:var(--ok-600)} .badge.dead .dot{background:var(--dead-600)}
|
||||
.health{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--text-500);font-weight:600}
|
||||
.pulse{width:8px;height:8px;border-radius:50%;background:var(--dead-600)}
|
||||
.pulse.up{background:var(--ok-600);box-shadow:0 0 0 0 rgba(15,157,88,.5);animation:pulse 2s infinite}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(15,157,88,.45)}70%{box-shadow:0 0 0 7px rgba(15,157,88,0)}100%{box-shadow:0 0 0 0 rgba(15,157,88,0)}}
|
||||
|
||||
main{max-width:1180px;margin:0 auto;padding:22px;display:grid;grid-template-columns:400px 1fr;gap:20px;align-items:start}
|
||||
@media (max-width:940px){main{grid-template-columns:1fr}}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
|
||||
.card + .card{margin-top:20px}
|
||||
.card-head{padding:15px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px}
|
||||
.card-head h2{margin:0;font-size:14px;font-weight:700;letter-spacing:-.01em}
|
||||
.card-head .hint{margin-left:auto;font-size:11.5px;color:var(--text-400);font-weight:500}
|
||||
.card-head .idx{width:22px;height:22px;border-radius:7px;background:var(--primary-50);color:var(--primary-600);display:grid;place-items:center;font-size:12px;font-weight:800}
|
||||
.card-body{padding:18px}
|
||||
|
||||
/* ---- form ---- */
|
||||
.field{margin-bottom:13px}
|
||||
.field:last-child{margin-bottom:0}
|
||||
.field label{display:block;font-size:12px;font-weight:600;color:var(--text-700);margin-bottom:5px}
|
||||
.field label .req{color:var(--coupang);margin-left:2px}
|
||||
.field .sub{font-weight:500;color:var(--text-400);font-size:11px}
|
||||
input,select,textarea{width:100%;padding:9px 11px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-family:inherit;color:var(--text-900);background:var(--surface);transition:border-color .12s,box-shadow .12s}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary-500);box-shadow:0 0 0 3px var(--primary-50)}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:11px}
|
||||
.btn{appearance:none;border:1px solid transparent;border-radius:var(--radius-sm);font-family:inherit;font-size:13px;font-weight:600;padding:10px 15px;cursor:pointer;transition:background .12s,border-color .12s,color .12s;display:inline-flex;align-items:center;justify-content:center;gap:7px}
|
||||
.btn-primary{background:var(--primary-600);color:#fff;width:100%}
|
||||
.btn-primary:hover{background:var(--primary-500)}
|
||||
.btn-primary:disabled{background:#c3c6cf;cursor:not-allowed}
|
||||
.btn-ghost{background:var(--surface);border-color:var(--border);color:var(--text-700)}
|
||||
.btn-ghost:hover{background:var(--surface-2)}
|
||||
.btn-sm{padding:7px 11px;font-size:12px}
|
||||
|
||||
.examples{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
|
||||
.chip{font-size:11.5px;padding:4px 9px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700);cursor:pointer}
|
||||
.chip:hover{border-color:var(--primary-500);color:var(--primary-600);background:var(--primary-50)}
|
||||
|
||||
/* ---- job tracking ---- */
|
||||
.empty{padding:38px 20px;text-align:center;color:var(--text-400)}
|
||||
.empty .big{font-size:30px;margin-bottom:8px;opacity:.5}
|
||||
.job-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.job-id{font-family:var(--mono);font-size:12px;background:var(--surface-2);border:1px solid var(--border-2);padding:4px 8px;border-radius:6px;color:var(--text-700)}
|
||||
.status-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:700}
|
||||
.status-pill.PENDING{background:var(--warn-50);color:var(--warn-600)}
|
||||
.status-pill.RUNNING{background:var(--run-50);color:var(--run-600)}
|
||||
.status-pill.DONE{background:var(--ok-50);color:var(--ok-600)}
|
||||
.status-pill.DEAD{background:var(--dead-50);color:var(--dead-600)}
|
||||
.status-pill .spin{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
.outcome-hero{display:flex;align-items:flex-end;gap:14px;padding:16px;border-radius:var(--radius-sm);background:linear-gradient(135deg,#f7f8ff,#f0f3ff);border:1px solid var(--border-2);margin-bottom:16px}
|
||||
.outcome-hero.nf{background:linear-gradient(135deg,#fbfbfc,#f4f5f7)}
|
||||
.price-big{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1;font-variant-numeric:tabular-nums}
|
||||
.price-big .won{font-size:18px;font-weight:700;color:var(--text-500);margin-left:2px}
|
||||
.src-tag{font-size:11px;font-weight:700;padding:3px 8px;border-radius:6px;color:#fff;letter-spacing:.02em;white-space:nowrap}
|
||||
.src-tag.naver{background:var(--naver)} .src-tag.coupang{background:var(--coupang)}
|
||||
.src-tag.gmarket{background:#00b25a} .src-tag.auction{background:#e60012} .src-tag.st11{background:#ff0038}
|
||||
.src-tag.fallback{position:relative}
|
||||
.src-tag.fallback::after{content:"크롤";position:absolute;top:-6px;right:-6px;font-size:8px;background:#1a1d24;color:#fff;padding:1px 3px;border-radius:4px;letter-spacing:0}
|
||||
.ship-tag{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:5px;border:1px solid transparent;white-space:nowrap}
|
||||
.ship-tag.rocket{background:#eef4ff;color:#2f6fed;border-color:#d5e3ff}
|
||||
.ship-tag.rocket_merchant{background:#f3eeff;color:#7a4fe0;border-color:#e3d8ff}
|
||||
.ship-tag.free{background:var(--ok-50);color:var(--ok-600)}
|
||||
.ship-tag.paid{background:#fbecec;color:var(--dead-600)}
|
||||
.ship-tag.unknown{background:var(--surface-2);color:var(--text-400);border-color:var(--border-2)}
|
||||
.outcome-label{font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px}
|
||||
|
||||
/* funnel / stages */
|
||||
.stages{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.stage{flex:1;min-width:82px;background:var(--surface-2);border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px;text-align:center;position:relative}
|
||||
.stage .st-name{font-size:11px;color:var(--text-500);font-weight:600;margin-bottom:4px;text-transform:capitalize}
|
||||
.stage .st-io{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.stage .st-io .arr{color:var(--text-400);font-weight:500;margin:0 3px}
|
||||
.stage .st-drop{font-size:10.5px;color:var(--coupang);font-weight:600;margin-top:2px}
|
||||
|
||||
.src-counts{display:flex;gap:8px;margin-bottom:16px}
|
||||
.src-count{flex:1;border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 12px;background:var(--surface-2)}
|
||||
.src-count .lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;margin-bottom:3px}
|
||||
.src-count .lbl.naver{color:var(--naver)} .src-count .lbl.coupang{color:var(--coupang)}
|
||||
.src-count .n{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.src-count .err{font-size:11px;color:var(--dead-600);font-weight:600}
|
||||
|
||||
/* 몰별 최저가 분해 */
|
||||
.mall-bars{display:flex;flex-direction:column;gap:7px;margin-bottom:16px}
|
||||
.mall-bar{display:flex;align-items:center;gap:10px}
|
||||
.mall-bar .mname{width:112px;flex-shrink:0;font-size:12px;font-weight:600;color:var(--text-700);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:5px}
|
||||
.mall-bar .mname .sdot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.mall-bar .track{flex:1;height:22px;background:var(--surface-2);border-radius:6px;position:relative;overflow:hidden}
|
||||
.mall-bar .fill{position:absolute;left:0;top:0;bottom:0;border-radius:6px;opacity:.16}
|
||||
.mall-bar .mprice{position:absolute;right:8px;top:0;bottom:0;display:flex;align-items:center;font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--text-900)}
|
||||
.mall-bar.best .mprice{color:var(--ok-600)}
|
||||
.mall-bar .badge-best{position:absolute;left:8px;top:0;bottom:0;display:flex;align-items:center;font-size:10px;font-weight:800;color:var(--ok-600)}
|
||||
|
||||
.sec-title{font-size:12px;font-weight:700;color:var(--text-700);margin:0 0 9px;display:flex;align-items:center;gap:7px}
|
||||
.sec-title .cnt{font-size:11px;color:var(--text-400);font-weight:600}
|
||||
|
||||
/* 검색 원가 계측 */
|
||||
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:16px}
|
||||
@media (max-width:520px){.metrics{grid-template-columns:repeat(2,1fr)}}
|
||||
.metric{border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 11px;background:var(--surface-2)}
|
||||
.metric .mk{font-size:10.5px;font-weight:700;color:var(--text-400);text-transform:uppercase;letter-spacing:.03em;margin-bottom:4px}
|
||||
.metric .mv{font-size:16px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
.metric .mv small{font-size:11px;font-weight:600;color:var(--text-500)}
|
||||
.metric .msub{font-size:10.5px;color:var(--text-400);font-weight:600;margin-top:2px}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.03em;padding:8px 10px;border-bottom:1px solid var(--border-2)}
|
||||
td{padding:9px 10px;border-bottom:1px solid var(--border-2);vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.price-cell{font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.name-cell{max-width:340px}
|
||||
.name-cell a{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rank{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;background:var(--surface-2);border:1px solid var(--border-2);font-size:11px;font-weight:800;color:var(--text-500)}
|
||||
.rank.top{background:var(--primary-50);color:var(--primary-600);border-color:transparent}
|
||||
|
||||
/* ---- chart ---- */
|
||||
.chart-controls{display:flex;gap:9px;align-items:flex-end;margin-bottom:16px}
|
||||
.chart-controls .field{flex:1;margin:0}
|
||||
.chart-wrap{position:relative;width:100%;overflow-x:auto}
|
||||
svg.chart{display:block;width:100%;height:auto;min-width:520px}
|
||||
.legend{display:flex;gap:16px;margin-top:12px;flex-wrap:wrap}
|
||||
.legend-item{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;color:var(--text-700);cursor:pointer;user-select:none}
|
||||
.legend-item .swatch{width:14px;height:3px;border-radius:2px}
|
||||
.legend-item.off{opacity:.35}
|
||||
.legend-item .swatch.dashed{background:none!important;border-top:2px dashed}
|
||||
|
||||
.chart-tip{position:absolute;pointer-events:none;background:#1a1d24;color:#fff;padding:8px 10px;border-radius:8px;font-size:11.5px;box-shadow:0 6px 24px rgba(0,0,0,.2);opacity:0;transition:opacity .1s;z-index:5;min-width:130px;transform:translate(-50%,-115%)}
|
||||
.chart-tip .tt-time{color:#9aa1ac;font-size:10.5px;margin-bottom:5px}
|
||||
.chart-tip .tt-row{display:flex;justify-content:space-between;gap:12px;font-variant-numeric:tabular-nums}
|
||||
.chart-tip .tt-row .k{display:flex;align-items:center;gap:6px}
|
||||
.chart-tip .tt-dot{width:7px;height:7px;border-radius:50%}
|
||||
|
||||
/* ---- misc ---- */
|
||||
.toast-wrap{position:fixed;bottom:22px;right:22px;z-index:100;display:flex;flex-direction:column;gap:9px}
|
||||
.toast{background:#1a1d24;color:#fff;padding:12px 16px;border-radius:10px;font-size:13px;box-shadow:0 8px 30px rgba(0,0,0,.24);display:flex;align-items:center;gap:9px;animation:slideIn .25s ease;max-width:340px}
|
||||
.toast.err{background:#b91c1c} .toast.ok{background:#0f9d58}
|
||||
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
.muted{color:var(--text-400)}
|
||||
.spin-inline{width:13px;height:13px;border:2px solid var(--border);border-right-color:var(--primary-500);border-radius:50%;display:inline-block;animation:spin .7s linear infinite;vertical-align:-2px}
|
||||
.cfg-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-500)}
|
||||
.cfg-row input{width:auto;flex:1;padding:6px 9px;font-size:12px;font-family:var(--mono)}
|
||||
code{font-family:var(--mono);font-size:12px;background:var(--surface-2);padding:1px 5px;border-radius:4px;border:1px solid var(--border-2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<div class="top-inner">
|
||||
<div class="brand">
|
||||
<div class="logo">₩</div>
|
||||
<div>LPS 최저가 검색 콘솔
|
||||
<small>임시 프론트엔드 · API 통신 확인용</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-spacer"></div>
|
||||
<div class="stat-badges" id="queueBadges">
|
||||
<span class="badge pending"><span class="dot"></span>PENDING <b>–</b></span>
|
||||
<span class="badge running"><span class="dot"></span>RUNNING <b>–</b></span>
|
||||
<span class="badge done"><span class="dot"></span>DONE <b>–</b></span>
|
||||
<span class="badge dead"><span class="dot"></span>DEAD <b>–</b></span>
|
||||
</div>
|
||||
<div class="health"><span class="pulse" id="healthDot"></span><span id="healthTxt">확인 중…</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ============ LEFT: 검색 요청 ============ -->
|
||||
<section>
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">1</span><h2>검색 요청</h2><span class="hint">POST /v1/lps/search</span></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>빠른 예시</label>
|
||||
<div class="examples" id="examples"></div>
|
||||
</div>
|
||||
<form id="searchForm">
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>상품 코드 <span class="req">*</span> <span class="sub">이력 키</span></label>
|
||||
<input name="product_code" required placeholder="T1" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>우선순위 <span class="sub">job_type</span></label>
|
||||
<select name="job_type">
|
||||
<option value="new_product">new_product · 1 (최우선)</option>
|
||||
<option value="manual" selected>manual · 2 (기본)</option>
|
||||
<option value="partner">partner · 3</option>
|
||||
<option value="batch">batch · 4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>상품명 <span class="req">*</span></label>
|
||||
<input name="product_name" required placeholder="맥심 모카골드 커피믹스" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>규격 <span class="sub">자유 서술 — AI가 해석</span></label>
|
||||
<input name="specification" placeholder="1박스, 160개입" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>모델명 <span class="sub">선택</span></label>
|
||||
<input name="model" placeholder="모카골드" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>제조사 <span class="sub">선택</span></label>
|
||||
<input name="company" placeholder="동서식품" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>현재가 <span class="sub">있으면 가격밴드 필터 기준</span></label>
|
||||
<input name="price" inputmode="numeric" placeholder="25000" autocomplete="off"/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">검색 요청 보내기</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>API 설정</h2><span class="hint">Base URL</span></div>
|
||||
<div class="card-body">
|
||||
<div class="cfg-row">
|
||||
<input id="baseUrl" value="http://localhost:9600"/>
|
||||
<button class="btn btn-ghost btn-sm" id="pingBtn">확인</button>
|
||||
</div>
|
||||
<p class="muted" style="margin:10px 0 0;font-size:11.5px">
|
||||
이 페이지는 <code>:5173</code>에서 서빙되어야 CORS가 통과합니다 (<code>serve.sh</code> 참고).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ RIGHT: 결과 + 그래프 ============ -->
|
||||
<section>
|
||||
<!-- 작업 추적 / 상품 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">2</span><h2>작업 추적 & 상품 정보</h2><span class="hint" id="jobHint">GET /v1/lps/jobs/{id}</span></div>
|
||||
<div class="card-body" id="jobBody">
|
||||
<div class="empty">
|
||||
<div class="big">📦</div>
|
||||
왼쪽에서 검색을 요청하면 여기에서<br/>진행 상태와 최저가 결과가 실시간으로 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최저가 이력 그래프 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">3</span><h2>최저가 이력 그래프</h2><span class="hint">GET /v1/lps/products/{code}/history</span></div>
|
||||
<div class="card-body">
|
||||
<div class="chart-controls">
|
||||
<div class="field">
|
||||
<label>상품 코드</label>
|
||||
<input id="histCode" placeholder="P1" autocomplete="off"/>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" id="loadHistBtn" style="height:37px">이력 불러오기</button>
|
||||
</div>
|
||||
<div id="chartArea">
|
||||
<div class="empty"><div class="big">📈</div>상품 코드를 입력하고 이력을 불러오세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast-wrap" id="toasts"></div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = (s,el=document)=>el.querySelector(s);
|
||||
const $$ = (s,el=document)=>Array.from(el.querySelectorAll(s));
|
||||
const base = ()=> $("#baseUrl").value.replace(/\/+$/,"");
|
||||
const won = n => (n==null? "—" : Number(n).toLocaleString("ko-KR"));
|
||||
const esc = s => (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||
|
||||
/* 배송 뱃지: shipping_type + shipping_fee → 라벨. 네이버는 항상 '배송비 별도'. */
|
||||
function shipBadge(p){
|
||||
const t=p.shipping_type, fee=p.shipping_fee;
|
||||
if(p.source==="naver") return `<span class="ship-tag unknown" title="네이버 오픈API는 배송비 제외 상품가입니다">배송비 별도</span>`;
|
||||
const map={
|
||||
rocket:["rocket","🚀 로켓"], rocket_merchant:["rocket_merchant","🚀 판매자로켓"],
|
||||
free:["free","무료배송"], paid:["paid", fee!=null?`배송비 ${won(fee)}원`:"유료배송"],
|
||||
};
|
||||
if(t && map[t]) return `<span class="ship-tag ${map[t][0]}">${map[t][1]}</span>`;
|
||||
return `<span class="ship-tag unknown">배송비 미확인</span>`;
|
||||
}
|
||||
/* 실구매가(상품가+확정 배송비). 미확인이면 null. */
|
||||
function totalPrice(p){ return (typeof p.shipping_fee==="number") ? p.price+p.shipping_fee : null; }
|
||||
|
||||
/* 소스 메타: 표시명·색상. gmarket/auction/st11 은 네이버 폴백 크롤 소스. */
|
||||
const SRC = {
|
||||
naver: {label:"NAVER", color:"#03c75a", crawl:false},
|
||||
coupang:{label:"COUPANG",color:"#f0455b", crawl:false},
|
||||
gmarket:{label:"G마켓", color:"#00b25a", crawl:true},
|
||||
auction:{label:"옥션", color:"#e60012", crawl:true},
|
||||
st11: {label:"11번가", color:"#ff0038", crawl:true},
|
||||
};
|
||||
function srcColor(s){ return (SRC[s]||{}).color || "#6b7280"; }
|
||||
function srcTag(p){
|
||||
const m=SRC[p.source]||{label:p.source};
|
||||
return `<span class="src-tag ${p.source}${m.crawl?' fallback':''}" title="${m.crawl?'네이버 미커버 → 실사이트 크롤':''}">${esc(m.label)}</span>`;
|
||||
}
|
||||
/* 판매몰 표시명: 네이버 가격비교(mall='네이버')는 '여러 판매자 최저가 롤업'임을 명시. */
|
||||
function mallLabel(p){
|
||||
if(p.source==="coupang") return "쿠팡";
|
||||
const m=p.mall_name||(SRC[p.source]||{}).label||"기타";
|
||||
return (m==="네이버") ? "네이버 가격비교" : m;
|
||||
}
|
||||
function isCatalog(p){ return p.source==="naver" && (p.mall_name==="네이버"); }
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
function toast(msg,type=""){
|
||||
const t=document.createElement("div"); t.className="toast "+type; t.textContent=msg;
|
||||
$("#toasts").appendChild(t); setTimeout(()=>{t.style.opacity="0";t.style.transition="opacity .3s";setTimeout(()=>t.remove(),300)},3200);
|
||||
}
|
||||
|
||||
/* ---------- fetch helper ---------- */
|
||||
async function api(path,opts){
|
||||
const r = await fetch(base()+path, Object.assign({headers:{"Content-Type":"application/json"}},opts));
|
||||
const txt = await r.text();
|
||||
let data; try{ data = txt? JSON.parse(txt):{} }catch(e){ throw new Error("응답 파싱 실패: "+txt.slice(0,120)) }
|
||||
if(!r.ok) throw new Error("HTTP "+r.status+" · "+(data?.result?.desc||txt.slice(0,120)));
|
||||
if(data?.result && data.result.success===false) throw new Error(data.result.desc||"API 실패");
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------- health + queue polling ---------- */
|
||||
async function refreshHealth(){
|
||||
try{
|
||||
const t = await api("/healthz");
|
||||
$("#healthDot").className="pulse up";
|
||||
$("#healthTxt").textContent = "온라인 · "+(typeof t==="string"?t.replace("T"," ").slice(0,19):"");
|
||||
}catch(e){
|
||||
$("#healthDot").className="pulse";
|
||||
$("#healthTxt").textContent="오프라인";
|
||||
}
|
||||
}
|
||||
async function refreshQueue(){
|
||||
try{
|
||||
const d = await api("/v1/lps/queue/stats");
|
||||
const c = d.counts||{};
|
||||
const b = $$("#queueBadges .badge b");
|
||||
b[0].textContent=c.PENDING??0; b[1].textContent=c.RUNNING??0; b[2].textContent=c.DONE??0; b[3].textContent=c.DEAD??0;
|
||||
}catch(e){}
|
||||
}
|
||||
function poll(){ refreshHealth(); refreshQueue(); }
|
||||
poll(); setInterval(poll, 4000);
|
||||
$("#pingBtn").addEventListener("click",()=>{ refreshHealth(); refreshQueue(); toast("연결 확인 중…"); });
|
||||
|
||||
/* ---------- examples ---------- */
|
||||
const EXAMPLES = [
|
||||
{label:"맥심 커피", product_code:"T1", product_name:"맥심 모카골드 커피믹스", specification:"1박스, 160개입", company:"동서식품", model:"모카골드", price:"25000"},
|
||||
{label:"신라면", product_code:"T2", product_name:"농심 신라면", specification:"1박스 40개입", company:"농심", price:"22000"},
|
||||
{label:"P1 (샘플이력)", product_code:"P1", product_name:"테스트 상품 P1", specification:"", price:""},
|
||||
];
|
||||
const exWrap = $("#examples");
|
||||
EXAMPLES.forEach(ex=>{
|
||||
const c=document.createElement("button"); c.type="button"; c.className="chip"; c.textContent=ex.label;
|
||||
c.addEventListener("click",()=>{ for(const k in ex){ if(k==="label")continue; const f=$(`[name=${k}]`); if(f) f.value=ex[k]; } toast("예시 채움: "+ex.label); });
|
||||
exWrap.appendChild(c);
|
||||
});
|
||||
|
||||
/* ---------- submit search ---------- */
|
||||
let pollTimer=null;
|
||||
$("#searchForm").addEventListener("submit", async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target); const item={};
|
||||
fd.forEach((v,k)=> item[k]=String(v).trim());
|
||||
if(!item.product_code||!item.product_name){ toast("상품 코드와 상품명은 필수입니다","err"); return; }
|
||||
const btn=$("#submitBtn"); btn.disabled=true; btn.innerHTML='<span class="spin-inline"></span> 요청 중…';
|
||||
try{
|
||||
const d = await api("/v1/lps/search",{method:"POST",body:JSON.stringify({data:[item]})});
|
||||
const it = (d.items||[])[0]||{};
|
||||
if(it.duplicated){ toast("이미 처리 중인 활성 작업이라 중복 접수 생략됨","err"); }
|
||||
else if(it.job_id){ toast("접수 완료 · 추적 시작","ok"); $("#histCode").value=item.product_code; trackJob(it.job_id, item); }
|
||||
else { toast("접수됐지만 job_id가 없습니다","err"); }
|
||||
}catch(err){ toast(err.message,"err"); }
|
||||
finally{ btn.disabled=false; btn.textContent="검색 요청 보내기"; }
|
||||
});
|
||||
|
||||
/* ---------- job tracking (poll until DONE/DEAD) ---------- */
|
||||
function trackJob(jobId, submitted){
|
||||
if(pollTimer) clearInterval(pollTimer);
|
||||
$("#jobHint").textContent = "job "+jobId.slice(0,8)+"…";
|
||||
let tries=0;
|
||||
const tick = async ()=>{
|
||||
tries++;
|
||||
try{
|
||||
const d = await api("/v1/lps/jobs/"+jobId);
|
||||
renderJob(d, submitted);
|
||||
if(d.status==="DONE"||d.status==="DEAD"){
|
||||
clearInterval(pollTimer); pollTimer=null;
|
||||
if(d.status==="DONE" && d.output?.outcome==="found") loadHistory(submitted.product_code);
|
||||
}
|
||||
}catch(err){
|
||||
renderJobError(err.message);
|
||||
if(tries>3){ clearInterval(pollTimer); pollTimer=null; }
|
||||
}
|
||||
};
|
||||
tick(); pollTimer=setInterval(tick, 1500);
|
||||
}
|
||||
function renderJobError(msg){ $("#jobBody").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(msg)}</div>`; }
|
||||
|
||||
function renderJob(d, submitted){
|
||||
const st=d.status||"PENDING";
|
||||
const o=d.output||null;
|
||||
const spinning=(st==="PENDING"||st==="RUNNING");
|
||||
let html = `<div class="job-meta">
|
||||
<span class="status-pill ${st}">${spinning?'<span class="spin"></span>':''}${st}</span>
|
||||
<span class="job-id">${esc(d.job_id||"")}</span>
|
||||
<span class="muted" style="font-size:12px">시도 ${d.attempts??0}/${d.max_attempts??"–"}</span>
|
||||
</div>`;
|
||||
|
||||
if(!o && spinning){
|
||||
html += `<div class="empty" style="padding:26px"><span class="spin-inline"></span> 워커가 네이버·쿠팡을 검색하고 AI가 같은 상품을 판정하는 중…
|
||||
<div class="muted" style="margin-top:8px;font-size:11.5px">쿠팡 크롤링은 수 초~수십 초 걸릴 수 있습니다.</div></div>`;
|
||||
$("#jobBody").innerHTML=html; return;
|
||||
}
|
||||
if(d.last_error && st==="DEAD"){
|
||||
html += `<div class="outcome-hero nf"><div><div class="outcome-label">작업 실패 (DEAD)</div><div class="muted" style="font-size:13px">${esc(d.last_error)}</div></div></div>`;
|
||||
}
|
||||
if(o){
|
||||
const found = o.outcome==="found";
|
||||
const low = o.lowest;
|
||||
// hero
|
||||
html += `<div class="outcome-hero ${found?'':'nf'}">
|
||||
<div style="flex:1">
|
||||
<div class="outcome-label">${found?'최저가 발견':(o.cached?'네거티브 캐시 (최근 없음)':'같은 상품 없음 · not_found')}</div>
|
||||
${ found && low ? `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<div class="price-big">${won(low.price)}<span class="won">원</span></div>
|
||||
${srcTag(low)}
|
||||
${shipBadge(low)}
|
||||
</div>
|
||||
<div class="muted" style="font-size:11.5px;margin-top:5px">판매몰 <b style="color:var(--text-700)">${esc(mallLabel(low))}</b>${ isCatalog(low)?` <span title="여러 판매자 중 최저가 롤업 · 배송비 제외">· 여러 판매자 최저가</span>`:``}</div>
|
||||
${ totalPrice(low)!=null && low.shipping_fee>0 ? `<div class="muted" style="font-size:11.5px;margin-top:2px">실구매가(배송비 포함) <b style="color:var(--text-700)">${won(totalPrice(low))}원</b></div>`:``}
|
||||
<div class="muted" style="font-size:12px;margin-top:6px">${esc(low.name||"")}</div>`
|
||||
: `<div class="price-big muted" style="font-size:22px">—</div>` }
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="muted" style="font-size:11px">검색어</div>
|
||||
<div style="font-weight:600;font-size:13px">${esc(o.query||submitted?.product_name||"")}</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">${o.rounds_tried??0} 라운드${o.round?' · '+esc(o.round):''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// source counts
|
||||
if(o.sources){
|
||||
html += `<div class="src-counts">`;
|
||||
for(const src of ["naver","coupang"]){
|
||||
const s=o.sources[src];
|
||||
html += `<div class="src-count"><div class="lbl ${src}">${src}</div>`;
|
||||
if(s && s.error!==undefined) html+=`<div class="err">차단/오류</div>`;
|
||||
else html += `<div class="n">${s?.count??0}<span class="muted" style="font-size:12px;font-weight:600"> 건</span></div>`;
|
||||
html+=`</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// pipeline stages
|
||||
if(o.stages && o.stages.length){
|
||||
html += `<div class="sec-title">파이프라인 단계 <span class="cnt">몇 건이 걸러졌나</span></div><div class="stages">`;
|
||||
o.stages.forEach(s=>{
|
||||
const drop=(s.in??0)-(s.out??0);
|
||||
html += `<div class="stage"><div class="st-name">${esc(s.stage)}</div>
|
||||
<div class="st-io">${s.in??0}<span class="arr">→</span>${s.out??0}</div>
|
||||
${drop>0?`<div class="st-drop">−${drop}</div>`:``}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// 검색 원가 계측(리소스/비용/시간)
|
||||
const mt=o.metrics;
|
||||
if(mt){
|
||||
const fmtBytes=b=>{const kb=(b||0)/1024; return kb>=1024?(kb/1024).toFixed(1)+" MB":Math.round(kb)+" KB";};
|
||||
const c=mt.cost||{}; const total=c.total_usd??(mt.ai?.est_cost_usd||0);
|
||||
const fc=v=>"$"+(v||0).toFixed((v||0)<0.01?6:4);
|
||||
const srcMs=mt.source_ms||{};
|
||||
const srcTxt=Object.entries(srcMs).map(([s,ms])=>`${(SRC[s]||{}).label||s} ${(ms/1000).toFixed(1)}s`).join(" · ");
|
||||
const crawledTxt=(mt.crawl?.malls_crawled||[]).length?' · 크롤 '+mt.crawl.malls_crawled.map(s=>(SRC[s]||{}).label||s).join(','):'';
|
||||
html += `<div class="sec-title">검색 원가 <span class="cnt">이 검색 1건이 쓴 리소스·비용·시간</span></div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="mk">소요 시간</div><div class="mv">${(mt.duration_ms/1000).toFixed(1)}<small>s</small></div>${srcTxt?`<div class="msub" title="${esc(srcTxt)}">${esc(srcTxt.length>34?srcTxt.slice(0,34)+'…':srcTxt)}</div>`:``}</div>
|
||||
<div class="metric"><div class="mk">총 비용</div><div class="mv">${fc(total)}</div><div class="msub" title="AI(OpenAI) + DECODO(프록시 대역폭)">AI ${fc(c.ai_usd)} · DECODO ${fc(c.proxy_usd)}</div></div>
|
||||
<div class="metric"><div class="mk">AI 토큰</div><div class="mv">${won((mt.ai?.prompt_tokens||0)+(mt.ai?.completion_tokens||0))}</div><div class="msub">${mt.ai?.calls||0}회 · in ${won(mt.ai?.prompt_tokens||0)}/out ${won(mt.ai?.completion_tokens||0)}</div></div>
|
||||
<div class="metric"><div class="mk">크롤 트래픽</div><div class="mv">${fmtBytes(mt.crawl?.html_bytes)}</div><div class="msub" title="프록시 경유 ${fmtBytes(mt.crawl?.proxy_bytes)} (DECODO 과금분)">${mt.crawl?.fetches||0} fetch · 프록시 ${fmtBytes(mt.crawl?.proxy_bytes)}${crawledTxt}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 몰별 최저가 분해 (네이버 payload에 이미 들어온 G마켓/옥션/11번가 등을 추가요청 0으로 노출)
|
||||
const byMall=o.by_mall||[];
|
||||
if(byMall.length>1){
|
||||
const maxP=Math.max(...byMall.map(m=>m.price));
|
||||
html += `<div class="sec-title">몰별 최저가 <span class="cnt">네이버 포함 몰 + 미커버 몰은 직접 크롤(크롤 뱃지)</span></div><div class="mall-bars">`;
|
||||
byMall.forEach((m,i)=>{
|
||||
const w=Math.max(8,Math.round(m.price/maxP*100));
|
||||
const color=srcColor(m.source);
|
||||
html += `<div class="mall-bar ${i===0?'best':''}">
|
||||
<div class="mname"><span class="sdot" style="background:${color}"></span>${esc(mallLabel(m))}</div>
|
||||
<div class="track"><div class="fill" style="width:${w}%;background:${color}"></div>
|
||||
${i===0?`<div class="badge-best">최저</div>`:``}
|
||||
<div class="mprice">${won(m.price)}원</div></div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// top-N product list
|
||||
const top=o.top||[];
|
||||
if(top.length){
|
||||
html += `<div class="sec-title">같은 상품 최저가 <span class="cnt">상위 ${top.length}개 · 가격은 상품가 기준</span></div>
|
||||
<table><thead><tr><th>#</th><th>소스 · 판매몰</th><th>상품명</th><th>배송</th><th style="text-align:right">상품가</th></tr></thead><tbody>`;
|
||||
top.forEach((p,i)=>{
|
||||
const tot=totalPrice(p);
|
||||
html += `<tr>
|
||||
<td><span class="rank ${i===0?'top':''}">${i+1}</span></td>
|
||||
<td style="white-space:nowrap">${srcTag(p)}<div class="muted" style="font-size:11px;font-weight:600;margin-top:3px">${esc(mallLabel(p))}</div></td>
|
||||
<td class="name-cell">${p.detail_url?`<a href="${esc(p.detail_url)}" target="_blank" rel="noopener">${esc(p.name)}</a>`:esc(p.name)}</td>
|
||||
<td>${shipBadge(p)}</td>
|
||||
<td class="price-cell" style="text-align:right">${won(p.price)}원${ tot!=null&&p.shipping_fee>0?`<div class="muted" style="font-size:10.5px;font-weight:600">+배송 ${won(tot)}</div>`:``}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
}
|
||||
$("#jobBody").innerHTML=html;
|
||||
}
|
||||
|
||||
/* ---------- price history chart (dependency-free SVG) ---------- */
|
||||
const SERIES=[
|
||||
{key:"naver", label:"네이버", color:"var(--naver)", raw:"#03c75a", dash:false},
|
||||
{key:"coupang", label:"쿠팡", color:"var(--coupang)", raw:"#f0455b", dash:false},
|
||||
{key:"final", label:"최종최저", color:"var(--final)", raw:"#4f46e5", dash:true},
|
||||
];
|
||||
const hidden=new Set();
|
||||
let lastPoints=[];
|
||||
|
||||
$("#loadHistBtn").addEventListener("click",()=> loadHistory($("#histCode").value.trim()));
|
||||
$("#histCode").addEventListener("keydown",e=>{ if(e.key==="Enter") loadHistory(e.target.value.trim()); });
|
||||
|
||||
async function loadHistory(code){
|
||||
if(!code){ toast("상품 코드를 입력하세요","err"); return; }
|
||||
$("#histCode").value=code;
|
||||
$("#chartArea").innerHTML=`<div class="empty" style="padding:30px"><span class="spin-inline"></span> 이력 불러오는 중…</div>`;
|
||||
try{
|
||||
const d = await api(`/v1/lps/products/${encodeURIComponent(code)}/history?limit=200`);
|
||||
lastPoints = d.points||[];
|
||||
renderChart(lastPoints, code);
|
||||
}catch(err){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(err.message)}</div>`; }
|
||||
}
|
||||
|
||||
function renderChart(points, code){
|
||||
if(!points.length){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">🗒️</div><b>${esc(code)}</b> 상품의 이력이 아직 없습니다.<div class="muted" style="margin-top:6px;font-size:12px">검색이 완료되면 스냅샷이 쌓입니다.</div></div>`; return; }
|
||||
const W=760,H=300,padL=62,padR=18,padT=18,padB=46;
|
||||
const iw=W-padL-padR, ih=H-padT-padB;
|
||||
const vals=[]; points.forEach(p=>SERIES.forEach(s=>{ if(!hidden.has(s.key)&&p[s.key]!=null) vals.push(p[s.key]); }));
|
||||
let min = vals.length?Math.min(...vals):0, max=vals.length?Math.max(...vals):1;
|
||||
if(min===max){ min=min*0.9; max=max*1.1||1; }
|
||||
const pad=(max-min)*0.12; min=Math.max(0,min-pad); max=max+pad;
|
||||
const n=points.length;
|
||||
const X = i => padL + (n===1? iw/2 : iw*i/(n-1));
|
||||
const Y = v => padT + ih*(1-(v-min)/(max-min));
|
||||
|
||||
// y ticks
|
||||
const ticks=5; let yAxis="";
|
||||
for(let t=0;t<=ticks;t++){ const v=min+(max-min)*t/ticks; const y=Y(v);
|
||||
yAxis+=`<line x1="${padL}" y1="${y}" x2="${W-padR}" y2="${y}" stroke="var(--border-2)" stroke-width="1"/>`;
|
||||
yAxis+=`<text x="${padL-8}" y="${y+4}" text-anchor="end" font-size="10" fill="var(--text-400)">${won(Math.round(v))}</text>`;
|
||||
}
|
||||
// x labels (sparse)
|
||||
let xAxis=""; const step=Math.ceil(n/6);
|
||||
points.forEach((p,i)=>{ if(i%step!==0 && i!==n-1) return; const x=X(i);
|
||||
const t=(p.triggered_at||"").replace("T"," ").slice(5,16);
|
||||
xAxis+=`<text x="${x}" y="${H-padB+18}" text-anchor="middle" font-size="9.5" fill="var(--text-400)">${esc(t)}</text>`;
|
||||
xAxis+=`<line x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--border-2)" stroke-width="1" stroke-dasharray="2 3" opacity=".5"/>`;
|
||||
});
|
||||
// lines + dots
|
||||
let paths="",dots="";
|
||||
SERIES.forEach(s=>{
|
||||
if(hidden.has(s.key)) return;
|
||||
// build segments skipping nulls
|
||||
let seg=[];
|
||||
const flush=()=>{ if(seg.length>1){ paths+=`<polyline points="${seg.map(pt=>pt.x+","+pt.y).join(" ")}" fill="none" stroke="${s.raw}" stroke-width="${s.dash?2.5:2}" ${s.dash?'stroke-dasharray="6 4"':''} stroke-linejoin="round" stroke-linecap="round"/>`; } seg=[]; };
|
||||
points.forEach((p,i)=>{ const v=p[s.key]; if(v==null){ flush(); return; } const x=X(i),y=Y(v); seg.push({x,y});
|
||||
dots+=`<circle cx="${x}" cy="${y}" r="3" fill="#fff" stroke="${s.raw}" stroke-width="2"/>`; });
|
||||
flush();
|
||||
});
|
||||
// hover columns
|
||||
let hover="";
|
||||
points.forEach((p,i)=>{ const x=X(i);
|
||||
hover+=`<rect x="${x-(iw/n/2||14)}" y="${padT}" width="${iw/n||28}" height="${ih}" fill="transparent" data-i="${i}" class="hovcol"/>`;
|
||||
hover+=`<line class="hovline" data-i="${i}" x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--primary-500)" stroke-width="1" opacity="0"/>`;
|
||||
});
|
||||
|
||||
$("#chartArea").innerHTML=`
|
||||
<div class="chart-wrap">
|
||||
<svg class="chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
|
||||
${yAxis}${xAxis}${paths}${dots}${hover}
|
||||
</svg>
|
||||
<div class="chart-tip" id="chartTip"></div>
|
||||
</div>
|
||||
<div class="legend" id="legend"></div>
|
||||
<p class="muted" style="font-size:11.5px;margin:12px 0 0">스냅샷 ${n}건 · 점선(최종최저)이 네이버·쿠팡 중 낮은 값을 따라갑니다. 값이 빈 구간은 그 소스에 상품이 없던 시점입니다.</p>
|
||||
`;
|
||||
// legend
|
||||
const lg=$("#legend");
|
||||
SERIES.forEach(s=>{
|
||||
const el=document.createElement("div"); el.className="legend-item"+(hidden.has(s.key)?" off":"");
|
||||
el.innerHTML=`<span class="swatch ${s.dash?'dashed':''}" style="${s.dash?`border-color:${s.raw}`:`background:${s.raw}`}"></span>${s.label}`;
|
||||
el.addEventListener("click",()=>{ if(hidden.has(s.key))hidden.delete(s.key); else hidden.add(s.key); renderChart(lastPoints,code); });
|
||||
lg.appendChild(el);
|
||||
});
|
||||
// hover interaction
|
||||
const tip=$("#chartTip"), svg=$(".chart");
|
||||
$$(".hovcol").forEach(col=>{
|
||||
col.style.cursor="crosshair";
|
||||
col.addEventListener("mouseenter",()=>{
|
||||
const i=+col.dataset.i, p=points[i];
|
||||
$$(".hovline").forEach(l=>l.setAttribute("opacity", (+l.dataset.i===i)?".5":"0"));
|
||||
let rows="";
|
||||
SERIES.forEach(s=>{ if(hidden.has(s.key)) return; const v=p[s.key];
|
||||
rows+=`<div class="tt-row"><span class="k"><span class="tt-dot" style="background:${s.raw}"></span>${s.label}</span><span>${v==null?'—':won(v)+'원'}</span></div>`; });
|
||||
tip.innerHTML=`<div class="tt-time">${esc((p.triggered_at||'').replace('T',' ').slice(0,19))} · ${esc(p.outcome||'')}</div>${rows}`;
|
||||
// position
|
||||
const bb=svg.getBoundingClientRect(); const scale=bb.width/W; const px=(X(i))*scale; const py=padT*scale+8;
|
||||
tip.style.left=px+"px"; tip.style.top=py+"px"; tip.style.opacity="1";
|
||||
});
|
||||
col.addEventListener("mouseleave",()=>{ tip.style.opacity="0"; $$(".hovline").forEach(l=>l.setAttribute("opacity","0")); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# LPS 임시 프론트엔드 서버 (대화형).
|
||||
# CORS 때문에 반드시 :5173 에서 서빙해야 lps API(:9600) 호출이 통과한다.
|
||||
# (lps/config.local.toml 의 cors_origins = http://localhost:5173)
|
||||
#
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PORT=5173
|
||||
echo "── LPS 임시 FE 서버 ──"
|
||||
echo " API(:9600) 는 별도로 실행돼 있어야 합니다 (lps/run_local_server.sh + worker_main.py)."
|
||||
echo ""
|
||||
read -rp "포트 [${PORT}]: " p
|
||||
PORT="${p:-$PORT}"
|
||||
|
||||
# 포트 정리
|
||||
if lsof -ti:"$PORT" >/dev/null 2>&1; then
|
||||
echo "[info] 포트 $PORT 사용 중 → 기존 프로세스 종료"
|
||||
lsof -ti:"$PORT" | xargs kill 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo "[run] http://localhost:${PORT} (Ctrl+C 로 종료)"
|
||||
# 브라우저 자동 오픈 (mac)
|
||||
( sleep 1; command -v open >/dev/null && open "http://localhost:${PORT}" ) &
|
||||
exec python3 -m http.server "$PORT" --bind 127.0.0.1
|
||||
@ -39,7 +39,7 @@
|
||||
**핵심 포인트**
|
||||
- **즉시 응답 + 나중 처리**: 요청하면 바로 "접수번호(job_id)"를 주고, 실제 검색은 뒤에서 진행됩니다. (검색은 몇 초~수십 초 걸림)
|
||||
- **못 찾으면 검색어를 바꿔 재시도**: "맥심 커피"로 안 나오면 "맥심 모카골드 커피믹스"처럼 **AI가 검색어를 다듬어** 다시 시도하고, 그래도 없으면 "없음"으로 정리합니다. (무한 재시도 안 함)
|
||||
- **차단 대응**: 쿠팡(Akamai)·G마켓(Cloudflare 사람확인) 등이 봇으로 감지하면 **다른 IP로 바꿔** 재시도하고, 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다.
|
||||
- **차단 대응**: IP당 요청 예산(기본 3회)에 닿으면 **차단당하기 전에 IP를 선제 교체**하고(평판 보존 — 그 IP는 로테이션 복귀 시 재사용), 그래도 감지되면 그 포트를 쿨다운 격리 후 다른 IP로 재시도합니다. 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다.
|
||||
- **원가 투명**: 검색 1건이 쓴 AI 비용·프록시 대역폭·시간을 함께 기록합니다.
|
||||
|
||||
---
|
||||
@ -49,14 +49,16 @@
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(Akamai 우회) 동시 검색·병합 |
|
||||
| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`LPS_FALLBACKS`, [배경](docs/decision-openmarket-crawler.md)) |
|
||||
| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`[WorkerConfig].fallbacks`, [배경](docs/decision-openmarket-crawler.md)) |
|
||||
| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) |
|
||||
| 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 |
|
||||
| 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 + 몰별(by_mall) 최저가를 시계열로 기록 |
|
||||
| 검색 원가 계측 | 검색 1건의 AI 토큰·비용 + DECODO 대역폭(실측 CDP) + 시간을 집계 |
|
||||
| 다중 상품 병렬 | 워커별 브라우저 세트로 여러 상품 동시 검색(`WORKER_CONCURRENCY`) |
|
||||
| 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 |
|
||||
| 프록시 IP 회전 | 봇 감지·전송오류 시 IP 자동 순환 + 시작 웜업(DECODO) |
|
||||
| 프록시 IP 선제 회전 | 요청 예산(기본 3회) 도달 시 **차단 전 선제 교체** + 불탄 포트 쿨다운 + 봇 감지·전송오류 즉시 순환 + 시작 웜업(DECODO). 예산 튜닝용 `ip_session` 관측 로그 |
|
||||
| 임계 알림 | 큐·차단·DB풀·소스별 장기실패·비용 등 10룰 — 쿨다운(스팸 방지)·해소 알림, Slack 웹훅([룰 표](docs/operations.md)) |
|
||||
| API guard | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방 모드) |
|
||||
|
||||
---
|
||||
|
||||
@ -84,6 +86,19 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
|
||||
-d '{"data":[{"product_code":"T1","product_name":"맥심 커피","specification":"1박스, 160개입"}]}'
|
||||
```
|
||||
|
||||
**서버 실행 — Docker**
|
||||
```bash
|
||||
# 리포 루트에서 전체 스택과 함께 (권장)
|
||||
docker compose up -d # negosium 스택 + lps-api·lps-worker·lps-admin 모두 기동
|
||||
|
||||
# lps 서브셋만 (대화형: 설정 검증·guard 키 안전장치 + admin 포함)
|
||||
./run_docker.sh
|
||||
```
|
||||
> 환경 구분이 없습니다(도메인 backend·negodata·agent 와 동일) — 항상 `config.local.toml`.
|
||||
> **prod 서버**도 그 서버의 `config.local.toml` 에 prod 값(시크릿·guard 키·스케일)을 채우고 그냥 `docker compose up -d`.
|
||||
> DB 는 컨테이너에서 `host.docker.internal`(호스트 DB)로 접속하고, 관리형 DB 면 `LPS_DB_HOST=` 로 override 를 끄고 toml 의 호스트를 씁니다.
|
||||
> 운영에서 API 를 외부에 열지 않으려면 `export LPS_API_BIND=127.0.0.1`(리버스프록시 뒤).
|
||||
|
||||
> 자세한 실행/설정은 [운영 가이드](docs/operations.md) 참고.
|
||||
|
||||
---
|
||||
@ -93,7 +108,7 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
|
||||
| 문서 | 대상 | 내용 |
|
||||
|------|------|------|
|
||||
| **[아키텍처](docs/architecture.md)** | 개발자/기획자 | 구성요소·파이프라인·안티봇(Akamai/Turnstile)·비용계측·동시성 |
|
||||
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 4종 구조와 코드값(+by_mall) |
|
||||
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 5종 구조와 코드값(+by_mall·ip_session) |
|
||||
| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 |
|
||||
| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 |
|
||||
| **[크롤러 논의](docs/decision-openmarket-crawler.md)** | 팀 | 오픈마켓 크롤러 유지 여부(ROI) 의사결정 메모 |
|
||||
@ -109,16 +124,17 @@ lps/
|
||||
├── Dockerfile # API 이미지(lean) · Dockerfile.worker # 워커(Chromium+Xvfb)
|
||||
├── run_local_server.sh # 로컬 API 실행 (대화형)
|
||||
├── run_local_worker.sh # 로컬 워커 실행 (대화형: 동시성·프로필)
|
||||
├── run_docker.sh # lps 서브셋 Docker 실행 (대화형: 설정 검증·guard 안전장치 + admin 포함)
|
||||
├── run_loadtest_gui.sh # 부하 테스트 Locust 웹 UI(:8089) 실행 (대화형)
|
||||
├── config/ # 설정(config.local.toml — 포트/DB/API키, 미커밋; 배포는 env 주입)
|
||||
├── common/ # 공통(enums, DB 세션, 모델, 로거)
|
||||
├── common/ # 공통(enums, DB 세션, 모델, 로거, alerts=임계 알림 관리자)
|
||||
│ └── database/model/models.py # DB 테이블 정의
|
||||
├── loadtest.py # 부하 테스트 (N개 상품 → 처리량·지연·비용 집계)
|
||||
├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection)
|
||||
├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection, ip_session)
|
||||
├── services/
|
||||
│ ├── search/ # 소스 어댑터 (coupang, naver, esm=G마켓·옥션, st11=11번가)
|
||||
│ │ ├── browser_base.py # patchright 공통(수명·프록시회전·차단감지·CDP 바이트계측)
|
||||
│ │ ├── proxy.py # DECODO(IP 회전·프리플라이트)
|
||||
│ │ ├── proxy.py # DECODO(IP 회전·포트 쿨다운·프리플라이트)
|
||||
│ │ └── card_parser.py # 오픈마켓 공용 카드 파서
|
||||
│ ├── pipeline/ # 필터·이상치·최저가 정렬(+몰별 분해)
|
||||
│ ├── ai/ # AI 유사도 판정·검색어 생성 (OpenAI)
|
||||
|
||||
80
lps/common/alerts.py
Normal file
80
lps/common/alerts.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""임계 알림 관리자 — 쿨다운(스팸 방지)·회복 알림. 워커(ops-monitor)·API(풀 모니터) 공용.
|
||||
|
||||
기존 방식(임계 초과 시 매 틱 웹훅)은 조건이 지속되면 30초마다 같은 알림이 반복 발송됐다.
|
||||
AlertManager 는 룰 키별로 상태를 관리한다:
|
||||
발화: 비활성→활성 전환 시 1회 + 이후 쿨다운([AlertConfig].cooldown_min, 기본 30분)마다 리마인드
|
||||
회복: 활성→비활성 전환 시 '해소' 알림 1회
|
||||
채널: WARN/INFO 로그(항상) + Slack 호환 웹훅([AlertConfig].webhook 있을 때만, 실패 무시).
|
||||
sender/clock 주입으로 네트워크·시간 없이 단위 테스트 가능.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from common.logger import LOG
|
||||
from config.server_configs import alert_config
|
||||
|
||||
|
||||
class AlertManager:
|
||||
def __init__(self, origin: str = "worker", webhook: str | None = None,
|
||||
cooldown_sec: float | None = None, sender=None, clock=time.monotonic):
|
||||
self.origin = origin # 알림 출처(worker/api) — 메시지에 표기
|
||||
self._webhook = webhook if webhook is not None else alert_config.webhook
|
||||
self._cooldown = cooldown_sec if cooldown_sec is not None else alert_config.cooldown_min * 60
|
||||
self._sender = sender # async def(text: str) — 테스트 주입용(없으면 웹훅)
|
||||
self._clock = clock
|
||||
self._state: dict[str, dict] = {} # key → {"active": bool, "last_sent": float}
|
||||
|
||||
async def check(self, key: str, active: bool, message: str, snap: dict | None = None):
|
||||
"""룰 1개 평가. active 가 True 로 지속돼도 쿨다운 안에는 재발송하지 않는다."""
|
||||
st = self._state.setdefault(key, {"active": False, "last_sent": 0.0})
|
||||
now = self._clock()
|
||||
if active:
|
||||
due = (not st["active"]) or (now - st["last_sent"] >= self._cooldown)
|
||||
st["active"] = True
|
||||
if due:
|
||||
st["last_sent"] = now
|
||||
LOG.w(f"[alert:{key}] {message}")
|
||||
await self._send(f":rotating_light: LPS({self.origin}) [{key}] {message}", snap)
|
||||
elif st["active"]:
|
||||
st["active"] = False
|
||||
LOG.i(f"[alert:{key}] 해소 — {message}")
|
||||
await self._send(f":white_check_mark: LPS({self.origin}) [{key}] 해소 — {message}", snap)
|
||||
|
||||
def is_active(self, key: str) -> bool:
|
||||
return self._state.get(key, {}).get("active", False)
|
||||
|
||||
async def _send(self, text: str, snap: dict | None):
|
||||
if self._sender is not None:
|
||||
await self._sender(text)
|
||||
return
|
||||
if not self._webhook:
|
||||
return
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
await c.post(self._webhook, json={"text": text + (f"\n```{snap}```" if snap else "")})
|
||||
except Exception:
|
||||
pass # 알림 실패가 모니터 루프를 막지 않는다
|
||||
|
||||
|
||||
async def run_pool_monitor(stop, interval: float = 60.0, alerts: AlertManager | None = None):
|
||||
"""DB 커넥션 풀 포화 감시 루프 — API 프로세스용 경량 모니터(lifespan 에서 기동).
|
||||
대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있어, 워커 ops-monitor 와 별도로 감시한다."""
|
||||
import asyncio
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
|
||||
alerts = alerts or AlertManager(origin="api")
|
||||
threshold = alert_config.pool_pct
|
||||
while not stop.is_set():
|
||||
try:
|
||||
st = DB_SESSION_MNG.pool_status()
|
||||
await alerts.check("db_pool", st["pct"] >= threshold,
|
||||
f"DB 풀 포화 {st['pct']}% (checked_out {st['checked_out']}/{st['capacity']})", st)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[pool-monitor] {type(ex).__name__}: {ex}")
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=interval)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
@ -84,6 +84,17 @@ class DBSessionManager(Singleton):
|
||||
)
|
||||
return scoped_session
|
||||
|
||||
def pool_status(self) -> dict:
|
||||
"""전 엔진 합산 커넥션 풀 사용 현황 — 포화 감시(알림)·/ops 노출용.
|
||||
checked_out=현재 사용 중, capacity=(pool_size+max_overflow)×엔진수, pct=포화율(%)."""
|
||||
checked_out = capacity = 0
|
||||
for engine in self.__engines:
|
||||
pool = engine.sync_engine.pool
|
||||
checked_out += pool.checkedout()
|
||||
capacity += pool.size() + getattr(pool, "_max_overflow", 0)
|
||||
pct = round(checked_out * 100 / capacity) if capacity else 0
|
||||
return {"checked_out": checked_out, "capacity": capacity, "pct": pct}
|
||||
|
||||
async def dispose_all(self):
|
||||
"""모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다.
|
||||
호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다.
|
||||
|
||||
@ -105,6 +105,33 @@ class price_history(MAIN_BASE):
|
||||
)
|
||||
|
||||
|
||||
class ip_session(MAIN_BASE):
|
||||
"""IP(프록시 포트) 세션 종료 이력 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'를 매 세션 기록.
|
||||
bot_detection 은 차단된 세션만 남지만 여기엔 무사 종료도 남아, 요청 예산(LPS_IP_REQUEST_BUDGET)
|
||||
상한 튜닝의 원천 데이터가 된다. (예: end_reason='block' 의 requests 분포 → 안전 상한 산출)"""
|
||||
|
||||
@staticmethod
|
||||
def DBType():
|
||||
return DBType.MAIN.value
|
||||
|
||||
__tablename__ = "ip_session"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
||||
source = Column(String(20), nullable=False) # coupang 등
|
||||
proxy_port = Column(Integer, nullable=True) # 사용 포트(=IP 세션), 프록시 미사용이면 NULL
|
||||
requests = Column(Integer, nullable=False) # 이 IP 로 보낸 요청 수
|
||||
ok_count = Column(Integer, nullable=False, server_default=text("0")) # 성공 검색 수
|
||||
blocked_count = Column(Integer, nullable=False, server_default=text("0")) # 차단 감지 수
|
||||
elapsed_sec = Column(Integer, nullable=True) # 세션 지속 시간(초)
|
||||
end_reason = Column(String(20), nullable=False) # budget/block/proxy_error/window/idle/shutdown/rotate
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # 세션 종료 시각
|
||||
|
||||
__table_args__ = (
|
||||
# 상한 튜닝 쿼리(소스·기간별 종료 사유 분포) 최적화
|
||||
Index("ix_ip_session_source", "source", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class bot_detection(MAIN_BASE):
|
||||
"""봇 감지 이력 — '이 IP로 몇 번째 요청에서, 어떤 방식으로 차단됐나'를 축적해 패턴 분석.
|
||||
(예: SELECT avg(ip_request_no) → IP당 평균 몇 요청 만에 감지되는지)"""
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
# 복사해서 사용: cp config.local.toml.example config.local.toml
|
||||
# 실제 config.local.toml 은 시크릿 포함이라 커밋하지 않는다(.gitignore: *.toml).
|
||||
# 모든 서버는 APP_ENV=local 로 띄우며 이 파일을 읽는다.
|
||||
#
|
||||
# ── 프로덕션: 시크릿을 이미지에 굽지 말고 env 로 주입(server_configs 가 override) ──
|
||||
# DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME
|
||||
# OPENAI_API_KEY / OPENAI_MODEL
|
||||
# DECODO_HOST / DECODO_USERNAME / DECODO_PASSWORD / DECODO_COST_PER_GB
|
||||
# NAVER_KEYS="id1:secret1,id2:secret2"
|
||||
# LPS_PROFILE_DIR=/profiles (Chrome 프로필 영속 볼륨), LPS_CHROME_EXECUTABLE=/usr/bin/chromium
|
||||
# → 배포 시엔 아래 시크릿 값을 비워두고 위 env 로 채우면 이미지에 시크릿이 안 남는다.
|
||||
# ── 설정 소스는 TOML 하나다(2026-07-13 협의 — env/.env 이중 관리 제거) ──
|
||||
# 호스트 실행: APP_ENV=local(기본) → 이 파일
|
||||
# Docker : APP_ENV=dev|prod → config.dev.toml / config.prod.toml 을 컨테이너에 마운트
|
||||
# (각 example 참고, 실행은 ../run_docker.sh 대화형 권장)
|
||||
# env 는 APP_ENV·PROCESS_COUNT/WORKER_CONCURRENCY(실행 스크립트 대화형 입력)·LPS_LIVE(테스트)만 남는다.
|
||||
[WebServerConfig]
|
||||
server_name = "LpsServer"
|
||||
port = 9600
|
||||
@ -17,13 +14,15 @@ is_ssl = false
|
||||
is_test = true
|
||||
# CORS 허용 오리진(프론트). 비우면 [] (CORS 미적용). 5173=vite dev.
|
||||
cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
# API guard 키 — 비우면 개방 모드(개발). prod 는 채운다(복수 등록 = 무중단 키 교체).
|
||||
# 생성 예: openssl rand -hex 32. 호출자(negodata)도 같은 키를 설정해야 한다.
|
||||
api_keys = []
|
||||
|
||||
[LogConfig]
|
||||
print_console = true
|
||||
log_level = "debug"
|
||||
|
||||
# DB Read/Write 분리. 도커 실행 시 host 는 docker-compose 의 DB_HOST 로 override.
|
||||
# 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require".
|
||||
# DB Read/Write 분리. 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require".
|
||||
# LPS 도메인 로직/테이블이 생기기 전까지는 접속하지 않으므로(엔진 lazy) placeholder 여도 부팅된다.
|
||||
[MainDBConfig]
|
||||
db_type = "postgresql"
|
||||
@ -43,10 +42,35 @@ max_overflow = 20 # 〃
|
||||
# (pool+overflow) × 2엔진 × process_count ≤ connection_budget.
|
||||
# 'lps API 가 쓸 총 커넥션 상한' — 공유 PG(max_connections)·동거 서비스(worker 등)를 고려한 값.
|
||||
# 예) 전용 PG(max_connections=100)면 90 근처, 공유 PG면 40 권장. 0 이면 자동 끔(위 pool 값 사용).
|
||||
connection_budget = 40 # env DB_CONNECTION_BUDGET 로 override
|
||||
connection_budget = 40
|
||||
sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full"
|
||||
|
||||
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ──
|
||||
# 워커 런타임 (worker_main.py). 동시성만 실행 시 WORKER_CONCURRENCY env 로 임시 override 가능.
|
||||
[WorkerConfig]
|
||||
concurrency = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3
|
||||
fallbacks = [] # 오픈마켓 폴백(기본 OFF). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크
|
||||
profile_dir = ".profiles" # Chrome 프로필 베이스. 영속 경로면 재시작에도 cf_clearance 유지(재웜업 회피)
|
||||
job_deadline_sec = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용)
|
||||
shutdown_grace_sec = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게
|
||||
chrome_channel = "chrome" # 로컬: 실제 Chrome
|
||||
chrome_executable = "" # 컨테이너: "/usr/bin/chromium" (설정 시 channel 무시)
|
||||
heartbeat_file = "/tmp/lps_worker_heartbeat" # Docker HEALTHCHECK 가 신선도 확인
|
||||
|
||||
# 임계 알림 (AlertManager — 룰 의미는 docs/operations.md 표)
|
||||
[AlertConfig]
|
||||
webhook = "" # Slack 호환 웹훅 URL. 비우면 로그로만 알림
|
||||
cooldown_min = 30 # 같은 룰 재발송 억제(분). 해소 알림은 즉시
|
||||
dead_1h = 20 # 최근 1h DEAD 잡 수
|
||||
blocks_1h = 80 # 최근 1h 봇 감지 수
|
||||
queue_lag_sec = 300 # 가장 오래된 PENDING 대기 초
|
||||
pool_pct = 90 # DB 커넥션 풀 포화율(%)
|
||||
source_fail_30m = 5 # 소스별 30분 내 시도 N회 이상 & 성공 0건
|
||||
deadline_1h = 5 # 최근 1h 잡 데드라인 강제종료 수
|
||||
cost_1h_usd = 1.0 # 최근 1h 검색원가 합($)
|
||||
ports_low_pct = 30 # 가용 프록시 포트 비율(%)
|
||||
block_sessions_6h = 1 # 최근 6h '예산 회전에도 차단된' IP 세션 수
|
||||
|
||||
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). ──
|
||||
|
||||
# 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함.
|
||||
[NaverConfig]
|
||||
@ -69,6 +93,8 @@ host = "" # 예: gate.decodo.com
|
||||
username = "" # 대시보드 USERNAME (예: sppd6a3ze3)
|
||||
password = "" # 대시보드 PASSWORD
|
||||
port_start = 0 # 예: 10001
|
||||
port_end = 0 # 예: 10010
|
||||
port_end = 0 # 예: 10010 — 포트를 늘리면(계약 변경) 이 범위만 넓히면 됨(코드 무변경)
|
||||
session_minutes = 10 # 대시보드 Sticky 지속시간(분)과 일치
|
||||
cost_per_gb = 0.0 # DECODO 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게, 예 3.0)
|
||||
ip_request_budget = 3 # IP당 요청 예산 — 도달 시 차단 전 선제 회전(0=비활성). 튜닝은 docs/database.md
|
||||
port_cooldown_sec = 0 # 차단 감지 포트 격리 초. 0=자동 max(sticky, 30분)
|
||||
|
||||
@ -11,6 +11,9 @@ class WebServerConfig(ConfigModel):
|
||||
is_test: bool = False
|
||||
# CORS 허용 오리진(프론트). 비우면 CORS 미적용. 예: ["http://localhost:5173"]
|
||||
cors_origins: list[str] = []
|
||||
# API guard 키. 비우면 개방 모드(개발). 채우면 /v1 전체에 X-API-Key 검증(prod).
|
||||
# 여러 개 등록 가능 — 무중단 키 교체(새 키 추가 → 호출자 전환 → 옛 키 제거).
|
||||
api_keys: list[str] = []
|
||||
|
||||
|
||||
class LogConfig(ConfigModel):
|
||||
@ -76,3 +79,37 @@ class DecodoConfig(ConfigModel):
|
||||
port_end: int = 0
|
||||
session_minutes: int = 10
|
||||
cost_per_gb: float = 0.0 # DECODO residential 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게 설정)
|
||||
# IP(포트 세션)당 요청 예산 — 도달 시 차단당하기 전에 선제 회전(평판 보존). 0=비활성.
|
||||
# 실측상 5회 부근 차단 이력 → 보수적 3. 튜닝은 ip_session 분석(docs/database.md).
|
||||
ip_request_budget: int = 3
|
||||
# 차단 감지된 포트 격리 시간(초). 0=자동(max(sticky, 30분)) — sticky 만료 후 복귀라 사실상 새 IP.
|
||||
port_cooldown_sec: int = 0
|
||||
|
||||
|
||||
class WorkerConfig(ConfigModel):
|
||||
"""워커 런타임 설정. (동시성만 실행 시 WORKER_CONCURRENCY env 로 임시 override 가능 — 대화형 스크립트용)"""
|
||||
|
||||
concurrency: int = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3
|
||||
fallbacks: list[str] = [] # 오픈마켓 폴백(기본 비활성). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크
|
||||
profile_dir: str = "/tmp" # Chrome 프로필 베이스 경로. 영속 볼륨이면 재시작에도 cf_clearance 유지(재웜업 회피)
|
||||
job_deadline_sec: float = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용)
|
||||
shutdown_grace_sec: float = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게
|
||||
chrome_channel: str = "chrome" # 로컬: 실제 Chrome 채널
|
||||
chrome_executable: str = "" # 컨테이너: 시스템 chromium 경로(설정 시 channel 무시, --no-sandbox 적용)
|
||||
heartbeat_file: str = "/tmp/lps_worker_heartbeat" # 하트비트 파일(Docker HEALTHCHECK 신선도 확인)
|
||||
|
||||
|
||||
class AlertConfig(ConfigModel):
|
||||
"""임계 알림(AlertManager) 설정 — 룰 의미는 docs/operations.md 표 참고."""
|
||||
|
||||
webhook: str = "" # Slack 호환 웹훅 URL. 비우면 로그로만 알림
|
||||
cooldown_min: int = 30 # 같은 룰 재발송 억제 시간(분). 해소 알림은 즉시
|
||||
dead_1h: int = 20 # 최근 1h DEAD 잡 수 임계
|
||||
blocks_1h: int = 80 # 최근 1h 봇 감지 수 임계
|
||||
queue_lag_sec: int = 300 # 가장 오래된 PENDING 대기 초 임계
|
||||
pool_pct: int = 90 # DB 커넥션 풀 포화율(%) 임계
|
||||
source_fail_30m: int = 5 # 소스별 30분 내 시도 N회 이상 & 성공 0건
|
||||
deadline_1h: int = 5 # 최근 1h 잡 데드라인 강제종료 수 임계
|
||||
cost_1h_usd: float = 1.0 # 최근 1h 검색원가 합($) 임계
|
||||
ports_low_pct: int = 30 # 가용 프록시 포트 비율(%) 임계
|
||||
block_sessions_6h: int = 1 # 최근 6h '예산 회전에도 차단된' IP 세션 수 임계
|
||||
|
||||
@ -2,35 +2,51 @@ import os
|
||||
|
||||
from config.config_loader import Configs
|
||||
from config.config_models import (
|
||||
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, NaverKey, OpenAIConfig, DecodoConfig,
|
||||
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig,
|
||||
WorkerConfig, AlertConfig,
|
||||
)
|
||||
|
||||
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
|
||||
# ── 설정 소스는 config.local.toml 하나다(도메인 backend 와 동일 — 서버마다 그 서버의 값, 미커밋). ──
|
||||
# 운영 전제: 항상 APP_ENV=local 로 띄운다 → config.local.toml (호스트 실행·pytest·docker 모두 동일).
|
||||
# prod 서버도 그 서버의 config.local.toml 에 prod 값(시크릿·guard키·스케일)을 채우고 `docker compose up -d`.
|
||||
# env 는 실행 입력과 '환경별로 바뀌는 접속점'만 담당한다:
|
||||
# DB_HOST/PORT/… : DB 접속 override — config.local.toml 은 127.0.0.1(호스트 실행·pytest) 유지하고,
|
||||
# 컨테이너에선 compose 가 DB_HOST=host.docker.internal 로 호스트 DB 를 가리킨다(_apply_db_env_override).
|
||||
# PROCESS_COUNT : 실행 스크립트·부하벤치의 대화형 입력(uvicorn 워커 수 임시 override)
|
||||
# WORKER_CONCURRENCY : 워커 실행 스크립트의 대화형 입력(worker_main 이 읽음)
|
||||
# LPS_LIVE : 라이브 스모크 테스트 옵트인(설정이 아니라 실행 스위치)
|
||||
# 시크릿(네이버·OpenAI·DECODO)은 env 로 넣지 않는다 — config.local.toml 에 두고 컨테이너엔 마운트한다.
|
||||
APP_ENV = os.environ.get("APP_ENV", "local")
|
||||
|
||||
_config_dir = os.path.dirname(__file__)
|
||||
_config_file = os.path.join(_config_dir, f"config.{APP_ENV}.toml")
|
||||
|
||||
# 운영 전제: 항상 APP_ENV=local 로 띄운다 → config.local.toml 사용 (test/docker 도 local 로 실행).
|
||||
if not os.path.exists(_config_file):
|
||||
raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). APP_ENV=local 로 실행하세요.")
|
||||
raise FileNotFoundError(
|
||||
f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). "
|
||||
"config.local.toml 을 준비하세요(config.local.toml.example 복사). 컨테이너는 compose 가 이 파일을 마운트합니다."
|
||||
)
|
||||
|
||||
configs = Configs(_config_file)
|
||||
|
||||
web_server_config: WebServerConfig = configs.get(WebServerConfig)
|
||||
log_config: LogConfig = configs.get(LogConfig)
|
||||
main_db_config: MainDBConfig = configs.get(MainDBConfig)
|
||||
# 시크릿 포함 설정도 TOML 로 통합. 섹션이 없으면 기본값(빈/비활성).
|
||||
# 섹션이 없으면 기본값(빈/비활성)으로 동작.
|
||||
naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig()
|
||||
openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig()
|
||||
decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig()
|
||||
worker_config: WorkerConfig = configs.get(WorkerConfig) or WorkerConfig()
|
||||
alert_config: AlertConfig = configs.get(AlertConfig) or AlertConfig()
|
||||
|
||||
|
||||
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.
|
||||
# DB 접속 env override — config.local.toml 의 접속 정보를 '환경별로 바뀌는 값만' 덮는다.
|
||||
# 로컬(호스트 uvicorn·pytest)은 env 미설정 → toml(127.0.0.1) 그대로. 컨테이너는 compose 가
|
||||
# DB_HOST=host.docker.internal 을 넣어 호스트 DB 를 가리킨다(도메인 backend 와 동일한 패턴).
|
||||
# 값이 빈 문자열이면 override 하지 않는다 → 관리형 DB 는 LPS_DB_HOST= 로 비워 config.local.toml 의 호스트를 쓴다.
|
||||
def _apply_db_env_override(cfg: MainDBConfig):
|
||||
h = os.environ.get("DB_HOST")
|
||||
if h:
|
||||
cfg.write_host = cfg.read_host = h
|
||||
if os.environ.get("DB_HOST"):
|
||||
cfg.write_host = cfg.read_host = os.environ["DB_HOST"]
|
||||
if os.environ.get("DB_PORT"):
|
||||
cfg.write_port = cfg.read_port = int(os.environ["DB_PORT"])
|
||||
if os.environ.get("DB_USER"):
|
||||
@ -39,7 +55,7 @@ def _apply_db_env_override(cfg: MainDBConfig):
|
||||
cfg.write_pw = cfg.read_pw = os.environ["DB_PASSWORD"]
|
||||
if os.environ.get("DB_NAME"):
|
||||
cfg.name = os.environ["DB_NAME"]
|
||||
# 커넥션 예산 override (자동 산정용). env DB_CONNECTION_BUDGET.
|
||||
# 커넥션 예산도 env 로 조정 가능(자동 산정 입력) — 전용/공유 PG 에 맞춰 서버별로.
|
||||
if os.environ.get("DB_CONNECTION_BUDGET"):
|
||||
cfg.connection_budget = int(os.environ["DB_CONNECTION_BUDGET"])
|
||||
|
||||
@ -66,49 +82,13 @@ def _autosize_pool(cfg: MainDBConfig, process_count: int):
|
||||
return cfg.pool_size, cfg.max_overflow
|
||||
|
||||
|
||||
def _apply_pool_env_override(cfg: MainDBConfig):
|
||||
"""명시적 풀 override — 자동 산정보다 우선(테스트·특수 배포용)."""
|
||||
if os.environ.get("DB_POOL_SIZE"):
|
||||
cfg.pool_size = int(os.environ["DB_POOL_SIZE"])
|
||||
if os.environ.get("DB_MAX_OVERFLOW"):
|
||||
cfg.max_overflow = int(os.environ["DB_MAX_OVERFLOW"])
|
||||
|
||||
|
||||
# 시크릿 env override — 프로덕션에선 API 키를 이미지에 굽지 않고 env(또는 시크릿매니저)로 주입한다.
|
||||
# 로컬은 env 미설정 → config.local.toml 값 그대로. (배포 시 toml 의 시크릿은 비워두고 아래 env 로 주입 권장)
|
||||
def _apply_secret_env_override():
|
||||
if os.environ.get("OPENAI_API_KEY"):
|
||||
openai_config.api_key = os.environ["OPENAI_API_KEY"]
|
||||
if os.environ.get("OPENAI_MODEL"):
|
||||
openai_config.model = os.environ["OPENAI_MODEL"]
|
||||
for k in ("host", "username", "password"):
|
||||
v = os.environ.get(f"DECODO_{k.upper()}")
|
||||
if v:
|
||||
setattr(decodo_config, k, v)
|
||||
# 포트 범위도 시크릿과 함께 env 주입 — example(플레이스홀더 0) 기반 이미지에서 이게 없으면
|
||||
# 자격증명을 넣어도 enabled=False(포트 0)로 프록시가 조용히 꺼진다.
|
||||
for k in ("port_start", "port_end", "session_minutes"):
|
||||
v = os.environ.get(f"DECODO_{k.upper()}")
|
||||
if v:
|
||||
setattr(decodo_config, k, int(v))
|
||||
if os.environ.get("DECODO_COST_PER_GB"):
|
||||
decodo_config.cost_per_gb = float(os.environ["DECODO_COST_PER_GB"])
|
||||
# NAVER_KEYS="id1:secret1,id2:secret2" 형식으로 키 로테이션 주입
|
||||
nk = os.environ.get("NAVER_KEYS")
|
||||
if nk:
|
||||
naver_config.keys = [NaverKey(id=i, secret=s)
|
||||
for i, s in (p.split(":", 1) for p in nk.split(",") if ":" in p)]
|
||||
|
||||
|
||||
# DB 접속점 override(호스트↔컨테이너) — 자동 산정(예산) 전에 적용해야 budget override 가 반영된다.
|
||||
_apply_db_env_override(main_db_config)
|
||||
_apply_secret_env_override()
|
||||
|
||||
# uvicorn 워커 수(멀티코어) env override — 부하테스트에서 1↔N 비교용(코드/toml 수정 없이).
|
||||
# uvicorn 워커 수 — 실행 스크립트/부하벤치의 대화형 입력만 env 로 임시 override(설정은 toml 이 소스).
|
||||
if os.environ.get("PROCESS_COUNT"):
|
||||
web_server_config.process_count = int(os.environ["PROCESS_COUNT"])
|
||||
|
||||
# 커넥션 풀 자동 산정: process_count(위에서 확정) 기준으로 예산 안에 맞춘다.
|
||||
# → 멀티워커 배포 시 풀 오버서브스크립션(→커넥션 고갈)을 config 가 스스로 방지.
|
||||
_pool_autosized = _autosize_pool(main_db_config, web_server_config.process_count)
|
||||
# 명시적 DB_POOL_SIZE/DB_MAX_OVERFLOW 는 자동 산정보다 우선(최종 override).
|
||||
_apply_pool_env_override(main_db_config)
|
||||
|
||||
@ -27,6 +27,27 @@ class BotDetectionLog:
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
|
||||
|
||||
async def admin_stats(self, hours: int = 168) -> dict:
|
||||
"""관리자 FE 용 차단 통계 — 시간대별 건수 + 최근 감지 목록."""
|
||||
p = {"h": hours}
|
||||
hourly = text("""
|
||||
SELECT date_trunc('hour', created_at) AS bucket, count(*) AS n FROM bot_detection
|
||||
WHERE created_at > now() - make_interval(hours => :h) GROUP BY 1 ORDER BY 1
|
||||
""")
|
||||
recent = text("""
|
||||
SELECT source, query, ip_request_no, proxy_port, elapsed_sec, marker, html_len, created_at
|
||||
FROM bot_detection WHERE created_at > now() - make_interval(hours => :h)
|
||||
ORDER BY created_at DESC LIMIT 50
|
||||
""")
|
||||
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||
try:
|
||||
return {
|
||||
"hourly": [{"bucket": r[0].isoformat(), "count": int(r[1])} for r in (await s.execute(hourly, p)).all()],
|
||||
"items": [dict(r) for r in (await s.execute(recent, p)).mappings().all()],
|
||||
}
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||
|
||||
async def recent_count(self, minutes: int = 60) -> int:
|
||||
"""최근 N분간 봇 감지(차단) 건수 — 차단율 급증 알림·모니터링용."""
|
||||
sql = text("SELECT count(*) FROM bot_detection WHERE created_at > now() - make_interval(mins => :m)")
|
||||
|
||||
73
lps/crud/ip_session.py
Normal file
73
lps/crud/ip_session.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""IP 세션 종료 이력 CRUD — 세션당 요청 수·종료 사유를 축적(요청 예산 상한 튜닝용)."""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.enums import DBType, DBWRType
|
||||
|
||||
|
||||
class IpSessionLog:
|
||||
DB = DBType.MAIN.value
|
||||
|
||||
async def record(self, event: dict):
|
||||
"""세션 종료 이벤트 1건 저장. 기록 실패가 검색을 막지 않도록 호출부에서 예외를 삼킨다."""
|
||||
sql = text("""
|
||||
INSERT INTO ip_session (source, proxy_port, requests, ok_count, blocked_count, elapsed_sec, end_reason)
|
||||
VALUES (:source, :proxy_port, :requests, :ok_count, :blocked_count, :elapsed_sec, :end_reason)
|
||||
""")
|
||||
params = {k: event.get(k) for k in
|
||||
("source", "proxy_port", "requests", "ok_count", "blocked_count", "elapsed_sec", "end_reason")}
|
||||
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
|
||||
try:
|
||||
await s.execute(sql, params)
|
||||
await s.commit()
|
||||
except Exception:
|
||||
await s.rollback()
|
||||
raise
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
|
||||
|
||||
async def admin_stats(self, hours: int = 168) -> dict:
|
||||
"""관리자 FE 용 IP 세션 통계 — 종료 사유 분포·세션당 요청 수 히스토그램·
|
||||
차단 세션 최소 요청 수(예산 튜닝 기준선)·최근 세션 목록."""
|
||||
p = {"h": hours}
|
||||
by_reason = text("""
|
||||
SELECT end_reason, count(*) AS n FROM ip_session
|
||||
WHERE created_at > now() - make_interval(hours => :h) GROUP BY end_reason
|
||||
""")
|
||||
histogram = text("""
|
||||
SELECT requests, count(*) AS n FROM ip_session
|
||||
WHERE created_at > now() - make_interval(hours => :h) GROUP BY requests ORDER BY requests
|
||||
""")
|
||||
block_min = text("""
|
||||
SELECT min(requests) FROM ip_session
|
||||
WHERE created_at > now() - make_interval(hours => :h) AND end_reason = 'block'
|
||||
""")
|
||||
recent = text("""
|
||||
SELECT source, proxy_port, requests, ok_count, blocked_count, elapsed_sec, end_reason, created_at
|
||||
FROM ip_session WHERE created_at > now() - make_interval(hours => :h)
|
||||
ORDER BY created_at DESC LIMIT 50
|
||||
""")
|
||||
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||
try:
|
||||
return {
|
||||
"by_reason": {r[0]: int(r[1]) for r in (await s.execute(by_reason, p)).all()},
|
||||
"histogram": [{"requests": int(r[0]), "count": int(r[1])} for r in (await s.execute(histogram, p)).all()],
|
||||
"block_min_requests": (lambda v: int(v) if v is not None else None)((await s.execute(block_min, p)).scalar()),
|
||||
"sessions": [dict(r) for r in (await s.execute(recent, p)).mappings().all()],
|
||||
}
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||
|
||||
async def recent_stats(self, minutes: int = 60) -> dict:
|
||||
"""최근 N분 세션 요약 — 종료 사유별 건수(모니터링·알림용). 예: {"budget": 12, "block": 1}"""
|
||||
sql = text("""
|
||||
SELECT end_reason, count(*) FROM ip_session
|
||||
WHERE created_at > now() - make_interval(mins => :m) GROUP BY end_reason
|
||||
""")
|
||||
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||
try:
|
||||
rows = (await s.execute(sql, {"m": minutes})).all()
|
||||
return {r[0]: int(r[1]) for r in rows}
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||
@ -217,13 +217,98 @@ class JobQueue:
|
||||
(lease_until IS NOT NULL AND lease_until < now())
|
||||
OR run_started_at < now() - interval '10 minutes'
|
||||
)) AS stuck_running,
|
||||
COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at) FILTER (WHERE status = 1)))::int, 0) AS oldest_pending_sec
|
||||
COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at) FILTER (WHERE status = 1)))::int, 0) AS oldest_pending_sec,
|
||||
-- 데드라인 강제종료(크롤 행 신호). 재시도로 살아나면 dead 엔 안 잡혀 별도 집계.
|
||||
count(*) FILTER (WHERE last_error LIKE 'JobDeadlineExceeded%'
|
||||
AND updated_at > now() - interval '1 hour') AS deadline_1h,
|
||||
-- 최근 1h 완료 잡의 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시.
|
||||
COALESCE(sum((result #>> '{metrics,cost,total_usd}')::float)
|
||||
FILTER (WHERE status = 3 AND updated_at > now() - interval '1 hour'), 0) AS cost_1h_usd
|
||||
FROM job
|
||||
""")
|
||||
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||
try:
|
||||
row = (await s.execute(sql)).mappings().first()
|
||||
return {k: int(v) for k, v in dict(row).items()}
|
||||
snap = dict((await s.execute(sql)).mappings().first())
|
||||
cost = float(snap.pop("cost_1h_usd") or 0)
|
||||
snap = {k: int(v) for k, v in snap.items()}
|
||||
snap["cost_1h_usd"] = round(cost, 4)
|
||||
return snap
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||
|
||||
# ---- 관리자(FE) 조회/액션 -------------------------------------------
|
||||
async def list_jobs(self, status: int | None = None, q: str | None = None,
|
||||
limit: int = 50, offset: int = 0) -> tuple[list[dict], int]:
|
||||
"""잡 목록(최신순) + 전체 건수. status(코드)·q(product_code/상품명 부분일치) 필터."""
|
||||
where = ["TRUE"]
|
||||
params: dict = {"limit": limit, "offset": offset}
|
||||
if status is not None:
|
||||
where.append("status = :st")
|
||||
params["st"] = status
|
||||
if q:
|
||||
where.append("(payload->>'product_code' ILIKE :q OR payload->>'product_name' ILIKE :q)")
|
||||
params["q"] = f"%{q}%"
|
||||
cond = " AND ".join(where)
|
||||
sql = text(f"""
|
||||
SELECT job_id, job_type, status, priority, attempts, max_attempts,
|
||||
payload->>'product_code' AS product_code, payload->>'product_name' AS product_name,
|
||||
result->>'outcome' AS outcome,
|
||||
(result#>>'{{lowest,price}}')::int AS final_lowest,
|
||||
(result#>>'{{metrics,cost,total_usd}}')::float AS cost_usd,
|
||||
last_error, created_at, run_started_at, updated_at
|
||||
FROM job WHERE {cond}
|
||||
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
cnt = text(f"SELECT count(*) FROM job WHERE {cond}")
|
||||
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||
try:
|
||||
rows = [dict(r) for r in (await s.execute(sql, params)).mappings().all()]
|
||||
total = int((await s.execute(cnt, params)).scalar() or 0)
|
||||
for d in rows:
|
||||
d["job_id"] = str(d["job_id"])
|
||||
return rows, total
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||
|
||||
async def requeue(self, job_id: str) -> str | None:
|
||||
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움.
|
||||
DEAD 가 아니거나 없으면 None. 같은 dedupe_key 의 활성 잡이 있으면 부분 유니크
|
||||
위반(IntegrityError) — 호출부가 '활성 중복'으로 안내한다."""
|
||||
sql = text("""
|
||||
UPDATE job SET status = 1, attempts = 0, run_after = now(),
|
||||
lease_until = NULL, worker_id = NULL, run_started_at = NULL,
|
||||
last_error = NULL, updated_at = now()
|
||||
WHERE job_id = CAST(:jid AS uuid) AND status = 4
|
||||
RETURNING job_id
|
||||
""")
|
||||
|
||||
async def run(s):
|
||||
row = (await s.execute(sql, {"jid": job_id})).first()
|
||||
if row:
|
||||
await s.execute(text("SELECT pg_notify(:ch, '')"), {"ch": JOB_NOTIFY_CHANNEL})
|
||||
return str(row[0]) if row else None
|
||||
|
||||
return await self._tx(run)
|
||||
|
||||
async def cost_buckets(self, hours: int = 48) -> list[dict]:
|
||||
"""시간별 검색원가 집계(완료 잡의 metrics 합산) — 비용 차트용."""
|
||||
sql = text("""
|
||||
SELECT date_trunc('hour', updated_at) AS bucket,
|
||||
count(*) AS jobs,
|
||||
COALESCE(sum((result#>>'{metrics,cost,ai_usd}')::float), 0) AS ai_usd,
|
||||
COALESCE(sum((result#>>'{metrics,cost,proxy_usd}')::float), 0) AS proxy_usd,
|
||||
COALESCE(sum((result#>>'{metrics,cost,total_usd}')::float), 0) AS total_usd,
|
||||
COALESCE(avg((result#>>'{metrics,duration_ms}')::float), 0) AS avg_ms
|
||||
FROM job
|
||||
WHERE status = 3 AND updated_at > now() - make_interval(hours => :h)
|
||||
GROUP BY 1 ORDER BY 1
|
||||
""")
|
||||
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||
try:
|
||||
return [{"bucket": r["bucket"].isoformat(), "jobs": int(r["jobs"]),
|
||||
"ai_usd": round(float(r["ai_usd"]), 4), "proxy_usd": round(float(r["proxy_usd"]), 4),
|
||||
"total_usd": round(float(r["total_usd"]), 4), "avg_ms": int(r["avg_ms"])}
|
||||
for r in (await s.execute(sql, {"h": hours})).mappings().all()]
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||
|
||||
|
||||
@ -65,3 +65,27 @@ class PriceHistory:
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||
|
||||
async def list_products(self, q: str | None = None, limit: int = 50) -> list[dict]:
|
||||
"""이력이 있는 상품 목록(관리자 FE) — 상품별 최신 스냅샷 + 검색 횟수, 최근 검색순.
|
||||
상품명은 price_history 에 없어 최신 스냅샷의 매칭 상품명(네이버 우선)으로 대신한다."""
|
||||
where = "WHERE product_code ILIKE :q" if q else ""
|
||||
sql = text(f"""
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (product_code)
|
||||
product_code, triggered_at, outcome,
|
||||
naver_lowest, coupang_lowest, final_lowest, final_source,
|
||||
COALESCE(naver_name, coupang_name) AS display_name,
|
||||
count(*) OVER (PARTITION BY product_code) AS searches
|
||||
FROM price_history {where}
|
||||
ORDER BY product_code, triggered_at DESC
|
||||
) t ORDER BY triggered_at DESC LIMIT :lim
|
||||
""")
|
||||
params: dict = {"lim": limit}
|
||||
if q:
|
||||
params["q"] = f"%{q}%"
|
||||
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
||||
try:
|
||||
return [dict(r) for r in (await s.execute(sql, params)).mappings().all()]
|
||||
finally:
|
||||
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"result": { "success": true, "code": 0, "desc": "SUCCESS" }
|
||||
```
|
||||
(실패 시 `success:false`, `code`/`desc`에 오류 코드)
|
||||
- **인증(guard)**: 서버 toml 의 `[WebServerConfig].api_keys` 가 채워진 환경(prod)에서는 모든 `/v1/*` 요청에
|
||||
`X-API-Key` 헤더가 필요합니다(불일치 시 `401`). 개발(local/dev)은 키를 비워 **개방 모드**로
|
||||
동작합니다. `/healthz`·`/readyz` 는 항상 개방(LB 프로브). 키는 리스트로 복수 등록 가능
|
||||
(무중단 키 교체). 호출 예: `curl -H "X-API-Key: <키>" http://.../v1/lps/queue/stats`
|
||||
|
||||
---
|
||||
|
||||
@ -148,11 +152,29 @@ curl "localhost:9600/v1/lps/products/T1/history?limit=100"
|
||||
{ "result": {...}, "counts": { "PENDING": 0, "RUNNING": 1, "DONE": 12, "DEAD": 0 } }
|
||||
```
|
||||
|
||||
## 5. 헬스체크 — `GET /healthz`
|
||||
서버 기동 시각을 반환(살아있는지 확인용).
|
||||
## 5. 운영 스냅샷 — `GET /v1/lps/ops`
|
||||
|
||||
외부 모니터가 스크랩·임계 알림하기 좋은 **플랫 JSON**. 알림 룰·임계는 [운영 가이드](operations.md) 참고.
|
||||
|
||||
```json
|
||||
{
|
||||
"pending": 0, "running": 1, "done": 12, "dead": 0,
|
||||
"dead_1h": 0, // 최근 1h 재시도 소진 실패
|
||||
"stuck_running": 0, // lease 만료/장기 실행 좀비 신호
|
||||
"oldest_pending_sec": 3, // 큐 지연(가장 오래된 대기 잡)
|
||||
"blocks_1h": 0, // 최근 1h 봇 감지 수
|
||||
"deadline_1h": 0, // 최근 1h 잡 데드라인 강제종료(크롤 행 신호)
|
||||
"cost_1h_usd": 0.09, // 최근 1h 완료 잡 검색원가 합($)
|
||||
"pool_checked_out": 0, "pool_capacity": 40, "pool_pct": 0 // API 프로세스 DB 풀 사용률
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 헬스체크 — `GET /healthz` · `GET /readyz`
|
||||
`/healthz`: 서버 기동 시각 반환(liveness — 프로세스 생존만). `/readyz`: DB 도달성까지 확인(실패 시 503) — LB/오케스트레이터용. 둘 다 **guard 대상이 아니라 항상 개방**.
|
||||
|
||||
---
|
||||
|
||||
## 상태 코드 요약
|
||||
- **작업 상태**: `PENDING`(대기) · `RUNNING`(처리중) · `DONE`(완료) · `DEAD`(실패-확인필요)
|
||||
- **결과 outcome**: `found`(찾음) · `not_found`(검색했으나 같은 상품 없음)
|
||||
- **HTTP 401**: guard 활성 환경에서 `X-API-Key` 누락/불일치 — 키 주입 확인(상단 인증 참고)
|
||||
|
||||
@ -10,9 +10,11 @@
|
||||
| **큐(대기줄)** | `crud/job_crud.py` + `job` 테이블 | 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용) |
|
||||
| **워커(일꾼)** | `worker_main.py`, `worker/` | 큐에서 하나씩 꺼내 **실제 검색·판정·저장** 수행 |
|
||||
| **소스 어댑터** | `services/search/` | 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화) |
|
||||
| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`LPS_FALLBACKS`) |
|
||||
| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`[WorkerConfig].fallbacks`) |
|
||||
| **파이프라인** | `services/pipeline/` | 수집 결과를 필터·이상치 제거·최저가 정렬 |
|
||||
| **AI** | `services/ai/` | "같은 상품" 판정 + 검색어 생성 (OpenAI) |
|
||||
| **관측·알림** | `common/alerts.py` + 워커 ops-monitor | 큐·차단·DB풀·비용 등 10룰 임계 알림(쿨다운·해소 알림, Slack 웹훅) + 하트비트. API 도 자기 풀을 자체 감시. [룰 표](operations.md) |
|
||||
| **API guard** | `router/v1/validator/auth.py` | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방) |
|
||||
|
||||
> **API와 워커를 분리**한 이유: 요청 접수는 즉시(가벼움), 실제 검색은 무거움(브라우저·AI). 분리하면 요청이 밀리지 않고, 워커만 따로 늘릴 수 있습니다.
|
||||
|
||||
@ -41,7 +43,7 @@
|
||||
→ 매칭 0건 + 소스 정상: 다음 라운드로
|
||||
→ 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도)
|
||||
|
||||
⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — LPS_FALLBACKS 로 켬**)
|
||||
⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — [WorkerConfig].fallbacks 로 켬**)
|
||||
네이버가 커버 못 한 몰(G마켓·옥션·11번가)만 실사이트 크롤 → 같은 상품 판정 → 병합
|
||||
("네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 크롤". 크롤 실패는 격리)
|
||||
※ 2026-07-10 협의: 최종 최저가 기여 0회·시간/비용 과다로 로직에서 제외(코드 유지).
|
||||
@ -78,6 +80,7 @@
|
||||
|
||||
**핵심 메커니즘**
|
||||
- **IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단 → 시간창 기반 sticky + 봇감지/전송오류 시 즉시 회전. 감지 이력(`bot_detection`)을 기록해 패턴 분석. **프록시 전송오류(407/터널)** 도 사이트 차단과 구분해 회전.
|
||||
- **선제 회전(요청 예산)**: IP당 요청 수가 예산(`[DecodoConfig].ip_request_budget`, 기본 3 — 실측상 5회 부근 차단)에 닿으면 **차단당하기 전에** 회전. 선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용됩니다. 반면 **차단 감지된 포트는 쿨다운**(`[DecodoConfig].port_cooldown_sec`, 기본 max(sticky, 30분)) 동안 격리 — sticky 만료 후 복귀라 사실상 새 IP. 세션마다 `ip_session`(요청 수·종료 사유)을 남겨 예산 상한을 데이터로 튜닝합니다(쿼리는 database.md).
|
||||
- **시작 프리플라이트 + 웜업**: 기동 시 살아있는 프록시 포트를 선점(egress IP 로그)하고, 챌린지 소스를 미리 1회 풀어 **쿠키를 선점**(나쁜 IP는 회전 재시도) → 실 작업은 웜(빠름).
|
||||
- **동적 리소스 차단**: 이미지·폰트 등을 차단해 대역폭↓. 단 **Turnstile은 리소스 차단을 봇 신호로 감지**하므로, ESM은 챌린지 solving 중(콜드)엔 차단을 풀고 **cf_clearance 확보 후(웜)에만 차단**합니다.
|
||||
- **폴백 데드라인**: 오픈마켓 크롤은 '보강'이라 각 크롤에 시간 상한(기본 15초)을 둬, 한 몰이 안 풀려도 전체 지연이 늘지 않게 합니다.
|
||||
@ -104,7 +107,7 @@
|
||||
- **API 서버는 asyncio(스레드 1개) = 1 프로세스 1 코어**. 처리량을 코어만큼 올리려면 `process_count`(uvicorn 워커 수)를 늘린다.
|
||||
- **함정**: 프로세스마다 독립 커넥션 풀을 열어 `(pool_size + max_overflow) × 2엔진(R/W) × process_count` 만큼 커넥션을 요구 → PG `max_connections`(기본 100)를 넘으면 **커넥션 고갈로 요청 실패 폭증**(부하테스트로 실증: 풀 10/20 · 4프로세스 = 240 요구 → 실패 1만+).
|
||||
- **해결(자동)**: `MainDBConfig.connection_budget`(기본 40)를 두면 기동 시 `process_count`에 맞춰 `pool_size/max_overflow`를 **역산**해 `(pool+overflow)×2×process_count ≤ budget`을 스스로 보장(`server_configs._autosize_pool`). 워커를 늘려도 예산을 넘지 않는다. 기동 로그 `DB Pool : … = N conns (budget=…)`로 실효값 확인.
|
||||
- **예산 가이드**: 공유 PG=40(API+worker+타 서비스 공존, 안정 우선) / 전용 PG(`max_connections≈100`)=90(처리량 우선). env `DB_CONNECTION_BUDGET`. 더 큰 처리량은 예산↑ + PG `max_connections`↑ 또는 pgbouncer.
|
||||
- **예산 가이드**: 공유 PG=40(API+worker+타 서비스 공존, 안정 우선) / 전용 PG(`max_connections≈100`)=90(처리량 우선). `[MainDBConfig].connection_budget`. 더 큰 처리량은 예산↑ + PG `max_connections`↑ 또는 pgbouncer.
|
||||
- 상세·벤치 결과: [`../loadtest/README.md`](../loadtest/README.md).
|
||||
|
||||
### 6-2. 왜 브라우저는 워커당 1세트인가 (더 띄우면 안 되나?)
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
- **테이블 정의**: `common/database/model/models.py` (SQLAlchemy) — 이 파일이 스키마의 단일 출처
|
||||
- **공통 규칙**: 외래키(FK) 안 씀(무결성은 앱에서) · 코드값은 정수(SMALLINT) · 시각은 전부 `TIMESTAMPTZ`(UTC)
|
||||
|
||||
## 테이블 4종 한눈에
|
||||
## 테이블 5종 한눈에
|
||||
|
||||
| 테이블 | 용도 |
|
||||
|--------|------|
|
||||
@ -14,6 +14,7 @@
|
||||
| `price_history` | 최저가 이력 — 그래프용 시계열 스냅샷 |
|
||||
| `search_negative` | 네거티브 캐시 — "없음"으로 확인된 상품을 일정 시간 기억 |
|
||||
| `bot_detection` | 봇 감지 이력 — 쿠팡이 차단한 패턴 기록 |
|
||||
| `ip_session` | IP 세션 종료 이력 — 요청 예산(선제 회전) 상한 튜닝 데이터 |
|
||||
|
||||
---
|
||||
|
||||
@ -102,6 +103,33 @@ SELECT avg(ip_request_no), count(*) FROM bot_detection;
|
||||
|
||||
---
|
||||
|
||||
## 5. `ip_session` — IP(프록시 포트) 세션 종료 이력
|
||||
|
||||
브라우저(=IP 세션)가 끝날 때마다 기록. `bot_detection`은 **차단된** 세션만 남지만,
|
||||
여기엔 **무사 종료**(예산 선제 회전·시간창 만료 등)도 남아 요청 예산(`[DecodoConfig].ip_request_budget`)
|
||||
상한 튜닝의 원천 데이터가 됩니다.
|
||||
|
||||
| 컬럼 | 뜻 |
|
||||
|------|-----|
|
||||
| `source` | 소스(coupang 등) |
|
||||
| `proxy_port` | 사용 포트(=IP 세션). 프록시 미사용이면 NULL |
|
||||
| `requests` | 이 IP로 보낸 요청 수 |
|
||||
| `ok_count` / `blocked_count` | 성공 검색 수 / 차단 감지 수 |
|
||||
| `elapsed_sec` | 세션 지속 시간(초) |
|
||||
| `end_reason` | 종료 사유 — `budget`(예산 선제) / `block`(차단) / `proxy_error`(포트 사망) / `window`(시간창 만료) / `idle`(유휴 정리) / `shutdown`(종료) |
|
||||
| `created_at` | 세션 종료 시각 |
|
||||
|
||||
**예산 튜닝 쿼리** — 차단이 나기 시작하는 요청 수 분포를 보고 상한을 조정:
|
||||
```sql
|
||||
-- 종료 사유별 분포(최근 7일): budget 이 대다수 + block 0 이면 예산을 1씩 올려볼 수 있고,
|
||||
-- block 이 보이면 그 세션들의 requests 최솟값보다 예산을 낮게 유지한다.
|
||||
SELECT end_reason, count(*), avg(requests)::numeric(5,1) AS avg_req, min(requests), max(requests)
|
||||
FROM ip_session WHERE created_at > now() - interval '7 days'
|
||||
GROUP BY end_reason ORDER BY count(*) DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 스키마 생성/관리
|
||||
|
||||
- 개발·테스트: SQLAlchemy 모델에서 `create_all`로 자동 생성.
|
||||
|
||||
@ -60,9 +60,9 @@ LPS는 상품별 최저가를 찾는다. 소스는 2계층:
|
||||
|
||||
- [x] **결정: B. 게이트/OFF** — 검색은 **네이버+쿠팡만**. 오픈마켓 폴백 3종은 **코드·테스트 유지, 로직에서 제외(기본 비활성)**.
|
||||
- 근거: 크롤 몰의 최종 최저가 기여 0회 + 검색당 최대 15s(폴백 데드라인) + 비용의 ~87%(DECODO)가 이 경로.
|
||||
- [x] 구현: 주석처리가 아닌 **env 토글** — `LPS_FALLBACKS`(기본 빈값=OFF, 예: `gmarket,auction,st11`, 일부만도 가능).
|
||||
- [x] 구현: 주석처리가 아닌 **설정 토글** — `[WorkerConfig].fallbacks`(기본 []=OFF, 예: `["gmarket","auction","st11"]`, 일부만도 가능. 구현 당시 env `LPS_FALLBACKS`, 2026-07-13 toml 단일화로 이관).
|
||||
- `worker_main.py` 가 이 값으로만 폴백 어댑터를 생성. 핸들러는 빈 폴백을 원래 정상 처리(`worker/handlers.py`)라 로직 변경 없음.
|
||||
- 폴백 로직·파서 테스트는 fake 주입이라 **비활성 상태에서도 계속 돈다**(코드 부패 방지).
|
||||
- by_mall 은 네이버 노출 몰 + 쿠팡으로만 채워짐 → 소비처(프론트) 연동 시 공유할 것.
|
||||
- [x] 재개 트리거: by_mall 소비 화면이 생기거나, 특정 몰이 네이버 커버리지에서 빠져 가격 검증이 필요해질 때.
|
||||
- **재가동 절차**: ① 라이브 스모크로 셀렉터 드리프트 점검(`LPS_LIVE=1 pytest tests/test_browser_base.py::test_live_smoke` + 대상 몰 1회 검색) → ② `LPS_FALLBACKS` 설정(로컬은 `run_local_worker.sh` 질문, 배포는 compose env) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것.
|
||||
- **재가동 절차**: ① 라이브 스모크로 셀렉터 드리프트 점검(`LPS_LIVE=1 pytest tests/test_browser_base.py::test_live_smoke` + 대상 몰 1회 검색) → ② `[WorkerConfig].fallbacks` 설정(로컬은 config.local.toml, 배포는 config.docker.toml) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것.
|
||||
|
||||
@ -18,10 +18,12 @@ cp config/config.local.toml.example config/config.local.toml
|
||||
| `[OpenAIConfig]` | `api_key` (AI 판정·검색어 생성용) |
|
||||
| `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용) |
|
||||
|
||||
> **한 파일에 설정+시크릿 통합** 관리(로컬). **Docker 이미지에는 이 파일이 들어가지 않는다** —
|
||||
> 빌드 시 `.dockerignore` 로 제외되고 example(플레이스홀더)이 대신 들어가며, 실값은 compose 의
|
||||
> env 로 주입한다(리포 루트 `.env`, 템플릿 `.env.example`). `server_configs` 의 env override 가
|
||||
> DB 접속·`OPENAI_API_KEY`·`DECODO_*`(포트 포함)·`NAVER_KEYS` 를 모두 덮는다.
|
||||
> **설정 소스는 `config.local.toml` 하나다**(도메인 backend·negodata·agent 와 동일). 환경 구분이 없다 —
|
||||
> 호스트 실행·Docker·prod 서버 모두 같은 파일명을 쓰고, **서버마다 그 서버의 값**(시크릿·guard 키·스케일)을 담는다(미커밋).
|
||||
> 컨테이너는 compose 가 이 파일을 마운트한다 — 이미지엔 시크릿이 없고(빌드 시 `.dockerignore` 제외), 마운트를
|
||||
> 잊으면 기동 시 FileNotFoundError 로 즉시 실패한다. env 로는 '환경별로 바뀌는 접속점'만 준다:
|
||||
> `DB_HOST`(컨테이너→호스트 DB, compose 가 `host.docker.internal` 주입 — 관리형 DB 면 `LPS_DB_HOST=` 로 끔),
|
||||
> 그리고 실행 스크립트의 대화형 입력(`PROCESS_COUNT`/`WORKER_CONCURRENCY`)·`LPS_LIVE`(테스트).
|
||||
|
||||
**DB 준비**: `lps_db` 생성 후 최초 실행 시 테이블 자동 생성.
|
||||
```bash
|
||||
@ -31,6 +33,16 @@ psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcry
|
||||
|
||||
## 2. 실행
|
||||
|
||||
**환경 개요** — 환경 구분이 없다. 어디서든 `config.local.toml` 하나(서버마다 그 서버의 값):
|
||||
|
||||
| 실행 위치 | 설정 파일 | 실행 방법 |
|
||||
|----------|-----------|----------|
|
||||
| 개발자 호스트(비도커) | `config.local.toml` | `./run_local_server.sh` + `./run_local_worker.sh` |
|
||||
| 개발/운영 서버(Docker) | `config.local.toml` (그 서버 값) | `docker compose up -d` (또는 `./run_docker.sh` — lps 서브셋·안전장치) |
|
||||
|
||||
> **prod**도 별도 환경이 아니다 — prod 서버의 `config.local.toml` 에 prod 값(시크릿·`api_keys` guard·스케일)을 채우고
|
||||
> `docker compose up -d`. 외부 노출 차단은 `export LPS_API_BIND=127.0.0.1`(리버스프록시 뒤).
|
||||
|
||||
**API 서버** (요청 접수)
|
||||
```bash
|
||||
./run_local_server.sh # → http://localhost:9600/docs
|
||||
@ -40,16 +52,16 @@ psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcry
|
||||
```bash
|
||||
./run_local_worker.sh # 대화형: 동시성(WORKER_CONCURRENCY)·Chrome 프로필·폴백 선택
|
||||
# 또는 직접:
|
||||
PYTHONUNBUFFERED=1 python worker_main.py # 로그 실시간
|
||||
WORKER_CONCURRENCY=3 python worker_main.py # 동시성 2~3(로컬). Chrome 최대 4×N개
|
||||
LPS_FALLBACKS=gmarket,auction,st11 python worker_main.py # 오픈마켓 폴백 재가동(기본 OFF — decision 문서 참고)
|
||||
PYTHONUNBUFFERED=1 python worker_main.py # 로그 실시간. 동시성·폴백 등은 config.local.toml [WorkerConfig]
|
||||
WORKER_CONCURRENCY=3 python worker_main.py # 동시성만 실행 시 임시 override 가능(권장 2~3, Chrome 최대 4×N개)
|
||||
# 오픈마켓 폴백 재가동: [WorkerConfig].fallbacks = ["gmarket","auction","st11"] (기본 OFF — decision 문서 참고)
|
||||
```
|
||||
> 워커 실행 시 쿠팡 크롤링용 **Chrome 창이 뜹니다**(정상). 기동 로그에 `DECODO 프리플라이트 OK — egress IP ...`, `AI: ON/OFF`가 표시됩니다.
|
||||
> 동시성 N이면 상품 N개가 진짜 병렬 처리됩니다(각 워커가 자기 프로필·프록시 IP 사용).
|
||||
|
||||
**워커 종료 (graceful)**
|
||||
- `Ctrl+C`(SIGINT) 또는 `docker stop`(SIGTERM) 1회 → **새 잡은 안 받고, 하던 잡을 마무리한 뒤** 리스너·브라우저를 정리하고 종료합니다(`LPS 워커 종료 완료` 로그, 트레이스백 없음).
|
||||
- 유예시간 `LPS_SHUTDOWN_GRACE_SEC`(기본 60s) 안에 안 끝나면 강제 취소되고, 그 잡은 lease 만료(120s) 후 reaper 가 재큐합니다. **한 번 더 신호를 보내면 즉시 강제 종료**입니다.
|
||||
- 유예시간 `[WorkerConfig].shutdown_grace_sec`(기본 60s) 안에 안 끝나면 강제 취소되고, 그 잡은 lease 만료(120s) 후 reaper 가 재큐합니다. **한 번 더 신호를 보내면 즉시 강제 종료**입니다.
|
||||
- Docker 는 compose 의 `stop_grace_period: 75s`(유예 60s + 정리 여유)가 SIGKILL 을 그만큼 미뤄줍니다 — 유예를 늘리면 이 값도 같이 늘리세요.
|
||||
|
||||
**부하 테스트**
|
||||
@ -72,8 +84,8 @@ config 가 보장: 위 값 ≤ connection_budget (기본 40)
|
||||
- `process_count` 를 올리면 `pool_size/max_overflow` 가 **자동으로 축소**되어 예산을 넘지 않습니다.
|
||||
(수동 튜닝 불필요 — 예전엔 이걸 안 맞춰서 워커↑ 시 커넥션 고갈→요청 실패가 났음)
|
||||
- 기동 로그에서 실효값 확인: `DB Pool : pool_size=.. max_overflow=.. × 2engine × Nworkers = M conns (budget=..)`
|
||||
- **예산 조정**: 공유 PG 는 40 유지, 전용 PG(`max_connections≈100`)면 `DB_CONNECTION_BUDGET=90` 으로 상향.
|
||||
- env 로 조절(코드/toml 수정 없이): `PROCESS_COUNT`, `DB_CONNECTION_BUDGET`, (특수 시)`DB_POOL_SIZE`/`DB_MAX_OVERFLOW`.
|
||||
- **예산 조정**: 공유 PG 는 40 유지, 전용 PG(`max_connections≈100`)면 `[MainDBConfig].connection_budget = 90` 으로 상향.
|
||||
- `PROCESS_COUNT` env 는 실행 스크립트·부하벤치의 대화형 입력 전용 임시 override(설정은 toml 이 소스).
|
||||
- 부하 한계 측정은 [`loadtest/README.md`](../loadtest/README.md) 참고(Locust 멀티코어 벤치).
|
||||
|
||||
## 3. 로그 보는 법 (워커 터미널)
|
||||
@ -86,7 +98,9 @@ config 가 보장: 위 값 ≤ connection_budget (기본 40)
|
||||
| `[coupang] query='...' → N건 (ip_req#K)` | 쿠팡 결과 수 / 이 IP로 K번째 요청 |
|
||||
| `[gmarket/auction/st11] query='...' → N건` | 오픈마켓 폴백 크롤 결과 수 |
|
||||
| `[ai] 판정 N건 중 매칭 M건` | AI 같은상품 선별 결과 |
|
||||
| `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → IP 회전 |
|
||||
| `[coupang] IP 회전 — 요청예산 3회 도달` | 예산 선제 회전(정상 동작 — 차단 전 교체, 포트는 재사용됨) |
|
||||
| `[proxy] 포트 10005 쿨다운 1800s — 활성 N/100` | 차단 감지된 포트 격리(만료까지 로테이션이 건너뜀) |
|
||||
| `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → 포트 쿨다운 + IP 회전 |
|
||||
| `[gmarket] IP 회전 — 프록시 전송오류/봇 감지` | 프록시 죽음(407/터널) 또는 차단 → 새 IP |
|
||||
| `[fallback:gmarket] 데드라인 15s 초과 → 스킵` | 폴백 크롤이 시간 상한 초과 → 그 몰만 스킵 |
|
||||
| `[coupang] 유휴 120s 초과 → 브라우저 정리` | 유휴 브라우저 닫아 메모리 회수(다음 검색 때 재기동) |
|
||||
@ -140,16 +154,35 @@ SELECT key, until, reason FROM search_negative ORDER BY created_at DESC;
|
||||
코어별 CPU·프로세스 그룹(worker/api/chrome/postgres) 사용률을 2초 간격으로 시각화.
|
||||
부하테스트/e2e(`N=100 python loadtest.py`) 관측용. 상세는 `loadtest/README.md`.
|
||||
|
||||
**임계 알림**(워커 ops-monitor): 초과 시 WARN 로그 + (env 있으면) Slack 호환 웹훅.
|
||||
```
|
||||
LPS_ALERT_WEBHOOK=https://hooks.slack.com/... # 있으면 알림 전송
|
||||
LPS_ALERT_DEAD_1H=20 LPS_ALERT_BLOCKS_1H=80 LPS_ALERT_QUEUE_LAG_SEC=300
|
||||
**임계 알림**(AlertManager — 워커 ops-monitor + API 풀 모니터 공용): 룰별로 상태를 관리해
|
||||
발화 시 1회 + 쿨다운(기본 30분)마다 리마인드, **조건 해소 시 '해소' 알림 1회**를 보낸다
|
||||
(과거처럼 조건 지속 중 30초마다 반복 발송되지 않음). WARN/INFO 로그는 항상, 웹훅은 env 있을 때만.
|
||||
|
||||
| 룰 키 | 조건 | 임계 [AlertConfig] 키(기본) |
|
||||
|------|------|----------------|
|
||||
| `dead` | 최근 1h DEAD 잡 수 | `dead_1h`(20) |
|
||||
| `blocks` | 최근 1h 봇 감지 수 | `blocks_1h`(80) |
|
||||
| `queue_lag` | 가장 오래된 PENDING 대기 초 | `queue_lag_sec`(300) |
|
||||
| `stuck` | lease 만료 RUNNING 잔존 | (0 초과 시) |
|
||||
| `db_pool` | DB 커넥션 풀 포화율(%) — 워커·API 각자 자기 풀 감시 | `pool_pct`(90) |
|
||||
| `source_fail:<src>` | 소스별 최근 30분 시도 N회 이상 & 성공 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) | `source_fail_30m`(5) |
|
||||
| `deadline` | 최근 1h 잡 데드라인 강제종료 수(크롤 행 반복 신호 — 재시도로 살아나면 dead 엔 안 잡힘) | `deadline_1h`(5) |
|
||||
| `cost` | 최근 1h 완료 잡 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시 | `cost_1h_usd`(1.0) |
|
||||
| `proxy_ports_low` | 가용 프록시 포트 비율(%) — 쿨다운 격리 누적, blocks 보다 먼저 우는 대규모 차단 조기 신호 | `ports_low_pct`(30) |
|
||||
| `budget_leak` | 최근 6h '예산 회전에도 차단된' IP 세션 수 — 현재 요청 예산이 안전하지 않다는 신호(예산 하향 검토) | `block_sessions_6h`(1) |
|
||||
|
||||
```toml
|
||||
[AlertConfig]
|
||||
webhook = "https://hooks.slack.com/..." # 있으면 웹훅 알림 전송(워커·API 공통)
|
||||
cooldown_min = 30 # 같은 룰 재발송 억제 시간(분)
|
||||
```
|
||||
지표는 알림 없이도 `GET /v1/lps/ops` 로 노출된다(`pool_pct`·`deadline_1h`·`cost_1h_usd` 포함) — 외부 모니터 스크랩용.
|
||||
(`proxy_ports_avail`·`block_sessions_6h` 는 워커 웹훅 스냅샷에만 포함 — 프록시 상태는 워커 프로세스에만 있음)
|
||||
|
||||
## 5. 테스트
|
||||
|
||||
```bash
|
||||
python -m pytest # 단위·통합(96) — 브라우저/네트워크 불필요
|
||||
python -m pytest # 단위·통합(145) — 브라우저/네트워크 불필요
|
||||
LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라이브 스모크(셀렉터·안티봇 드리프트 감지)
|
||||
```
|
||||
> ⚠️ **워커가 실행 중이면 테스트가 깨집니다** — 워커가 같은 `lps_db`의 테스트 작업을 가로채기 때문. 테스트 전 워커를 멈추세요:
|
||||
@ -184,13 +217,21 @@ docker ps # lps-worker "(healthy)" 확인
|
||||
- **워커 = 헤드풀 Chromium + Xvfb**(`Dockerfile.worker`): **headless 는 Akamai·Cloudflare Turnstile 에 탐지됨**(실측). Xvfb 가상 디스플레이로 headful 실행.
|
||||
- **API = lean**(`Dockerfile`, 브라우저 불필요).
|
||||
- **시크릿은 이미지에 없음(강제)**: 이미지는 example config 로 빌드된다(`.dockerignore` 가
|
||||
config.local.toml·`.profiles/` 제외). 실값은 **리포 루트 `.env`**(템플릿 `.env.example`)에서
|
||||
compose env 로 주입. `.env` 없이 뜨면 AI OFF·프록시 미사용으로 조용히 동작하니, 기동 로그의
|
||||
config.local.toml·`.profiles/` 제외). 실값은 **`lps/config/config.<env>.toml`**(dev/prod example 복사,
|
||||
미커밋)을 compose 가 마운트해 주입(`APP_ENV` 선택). 마운트를 잊으면 기동 시 즉시 실패. 기동 로그의
|
||||
`AI: ON/OFF`·`DECODO 프록시: ON/OFF` 로 주입 성공을 반드시 확인할 것.
|
||||
- **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `LPS_PROFILE_DIR`): 재시작해도 cf_clearance 유지 → 재웜업 회피.
|
||||
- **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `[WorkerConfig].profile_dir`): 재시작해도 cf_clearance 유지 → 재웜업 회피.
|
||||
- **워커 헬스**: HEALTHCHECK(하트비트<120s)로 행 워커 감지. compose 의 `restart` 는 unhealthy 를
|
||||
재시작하지 않으므로 **autoheal 컨테이너**(라벨 `autoheal=true` 감시)가 재시작 담당. k8s 는 liveness probe 로 대체.
|
||||
- **잡 데드라인**: 잡 1건 300s 상한(`LPS_JOB_DEADLINE_SEC`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함.
|
||||
- **잡 데드라인**: 잡 1건 300s 상한(`[WorkerConfig].job_deadline_sec`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함.
|
||||
- **IP 선제 회전**: `[DecodoConfig].ip_request_budget`(기본 3) — IP당 요청 예산, 도달 시 차단 전에 회전(0=비활성).
|
||||
`[DecodoConfig].port_cooldown_sec`(0=자동 max(sticky, 1800)) — 차단 감지된 포트 격리 시간. 포트 수를 늘리면
|
||||
([DecodoConfig].port_start/end) 자동 반영 — 코드에 포트 수 하드코딩 없음. 튜닝은 `ip_session` 분석 쿼리(database.md) 참고.
|
||||
|
||||
**남은 배포 과제**: API 인증·레이트리밋(비용 남용 방지), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정.
|
||||
- **API guard**: `[WebServerConfig].api_keys` 설정 시 `/v1/*` 전체에 X-API-Key 검증(복수 키 —
|
||||
무중단 교체). 개발기는 빈값=개방 모드. **prod 체크리스트**: ① prod 서버의 config.local.toml 에
|
||||
`api_keys` 채움(negodata 쪽은 `lps_api_key` 에 같은 키 — 헤더 자동 첨부) ② lps-api 외부 노출 차단
|
||||
(`export LPS_API_BIND=127.0.0.1` 또는 compose `ports:` 삭제) ③ 기동 로그에서 `API guard ON` 확인.
|
||||
|
||||
**남은 배포 과제**: 레이트리밋(키별 요청량 제한), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정.
|
||||
**비용**: 대역폭이 원가의 대부분(오픈마켓 크롤) — 같은 상품 재크롤을 줄이는 **TTL 캐시**가 다음 절감 후보.
|
||||
|
||||
@ -64,7 +64,7 @@ API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn `w
|
||||
| 4 | pool 3 / overflow 2 | 40 |
|
||||
| 8 | pool 1 / overflow 1 | 32 |
|
||||
|
||||
- **예산 설정**: 전용 PG(max_connections=100)면 `connection_budget≈90`, 공유 PG면 40 권장. `env DB_CONNECTION_BUDGET`.
|
||||
- **예산 설정**: 전용 PG(max_connections=100)면 `connection_budget≈90`, 공유 PG면 40 권장(`[MainDBConfig].connection_budget`).
|
||||
(공유 PG 기본 40 은 API + worker + 타 서비스가 100 안에 공존하도록 잡은 안전값 → 처리량보다 안정 우선)
|
||||
- **override 우선순위**: 명시 `DB_POOL_SIZE`/`DB_MAX_OVERFLOW` > 자동 산정(budget>0) > toml `pool_size/max_overflow`(budget=0)
|
||||
- 더 큰 처리량이 필요하면: 예산 상향 + **PG `max_connections` 상향** 또는 **pgbouncer**(커넥션 풀러) 도입
|
||||
|
||||
17
lps/migrations/2026-07-13-ip_session.sql
Normal file
17
lps/migrations/2026-07-13-ip_session.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- IP(프록시 포트) 세션 종료 이력 — 요청 예산(LPS_IP_REQUEST_BUDGET) 상한 튜닝의 원천 데이터.
|
||||
-- bot_detection 은 차단된 세션만 남지만, 여기엔 무사 종료(budget/window 등)도 남는다.
|
||||
-- 적용: psql -h <host> -U <user> -d lps_db -f migrations/2026-07-13-ip_session.sql
|
||||
-- (postgres-init/init-data/init.sql 의 lps_db 섹션에도 동일 DDL 반영됨 — 신규 설치는 그쪽이 소스)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_session (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source VARCHAR(20) NOT NULL, -- coupang 등
|
||||
proxy_port INTEGER NULL, -- 사용 포트(=IP 세션), 프록시 미사용이면 NULL
|
||||
requests INTEGER NOT NULL, -- 이 IP 로 보낸 요청 수
|
||||
ok_count INTEGER NOT NULL DEFAULT 0, -- 성공 검색 수
|
||||
blocked_count INTEGER NOT NULL DEFAULT 0, -- 차단 감지 수
|
||||
elapsed_sec INTEGER NULL, -- 세션 지속 시간(초)
|
||||
end_reason VARCHAR(20) NOT NULL, -- budget/block/proxy_error/window/idle/shutdown/rotate
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 세션 종료 시각
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_ip_session_source ON ip_session (source, created_at);
|
||||
@ -1,14 +1,18 @@
|
||||
import asyncio
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from common.alerts import run_pool_monitor
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
from config.server_configs import web_server_config
|
||||
from router.v1.validator.auth import configured_keys, require_api_key
|
||||
import router.v1.lps.admin
|
||||
import router.v1.lps.search
|
||||
|
||||
API_SERVER_START_TIME = GTime.UTCStr()
|
||||
@ -16,9 +20,17 @@ API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# startup
|
||||
# startup: API 자신의 DB 풀 포화 감시(경량) — 대량 폴링으로 풀을 고갈시키는 주범이 API 일 수 있다.
|
||||
stop = asyncio.Event()
|
||||
pool_monitor = asyncio.create_task(run_pool_monitor(stop))
|
||||
yield
|
||||
# shutdown: DB 엔진 커넥션 풀 정리
|
||||
# shutdown: 모니터 정지 후 DB 엔진 커넥션 풀 정리
|
||||
stop.set()
|
||||
pool_monitor.cancel()
|
||||
try:
|
||||
await pool_monitor
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
@ -75,4 +87,11 @@ async def readyz():
|
||||
|
||||
|
||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||
app.include_router(router.v1.lps.search.router)
|
||||
# guard: [WebServerConfig].api_keys 설정 시 /v1 전체에 X-API-Key 검증(개발은 빈값=개방 — auth.py 참고).
|
||||
app.include_router(router.v1.lps.search.router, dependencies=[Depends(require_api_key)])
|
||||
app.include_router(router.v1.lps.admin.router, dependencies=[Depends(require_api_key)])
|
||||
|
||||
if configured_keys():
|
||||
LOG.i(f"API guard ON — X-API-Key 검증({len(configured_keys())}개 키)")
|
||||
else:
|
||||
LOG.w("[WebServerConfig].api_keys 비어있음 — API 개방 모드(개발용). prod 는 toml 에 키 채움 + 포트 비공개 필수")
|
||||
|
||||
78
lps/router/v1/lps/admin.py
Normal file
78
lps/router/v1/lps/admin.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""관리자 FE 라우터 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계.
|
||||
|
||||
검증→service→응답만(backend 컨벤션). guard(X-API-Key)는 router.py 의 include 에서 일괄 적용.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from router.v1.validator.dependencies import RemoveNoneResponse
|
||||
from services.admin_service import AdminService
|
||||
from router.v1.lps.admin_protocol import (
|
||||
Res_BotStats, Res_CostStats, Res_IpSessionStats, Res_JobList, Res_ProductList, Res_Requeue,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/v1/lps", tags=["LPS Admin"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/jobs",
|
||||
response_model=Res_JobList,
|
||||
summary="잡 목록(관리자)",
|
||||
description="최신순 잡 목록 + 전체 건수. status(PENDING/RUNNING/DONE/DEAD)·q(상품코드/상품명 부분일치) 필터.",
|
||||
)
|
||||
async def list_jobs(status: str | None = Query(None), q: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
||||
service: AdminService = Depends()):
|
||||
return RemoveNoneResponse(await service.list_jobs(status, q, limit, offset))
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/jobs/{job_id}/requeue",
|
||||
response_model=Res_Requeue,
|
||||
summary="DEAD 잡 재큐(관리자)",
|
||||
description="재시도 소진으로 죽은 잡을 attempts 리셋 후 다시 대기열에 넣는다(워커 즉시 깨움). "
|
||||
"같은 상품의 활성 잡이 있으면 DB_ALREADY_SAME_KEY.",
|
||||
)
|
||||
async def requeue_job(job_id: str, service: AdminService = Depends()):
|
||||
return RemoveNoneResponse(await service.requeue(job_id))
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/products",
|
||||
response_model=Res_ProductList,
|
||||
summary="검색 이력 상품 목록(관리자)",
|
||||
description="price_history 에 이력이 있는 상품별 최신 스냅샷 + 누적 검색 수. 최근 검색순.",
|
||||
)
|
||||
async def list_products(q: str | None = Query(None), limit: int = Query(50, ge=1, le=200),
|
||||
service: AdminService = Depends()):
|
||||
return RemoveNoneResponse(await service.list_products(q, limit))
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/stats/ip-sessions",
|
||||
response_model=Res_IpSessionStats,
|
||||
summary="IP 세션 통계(관리자)",
|
||||
description="종료 사유 분포·세션당 요청 수 히스토그램·차단 세션 최소 요청 수(예산 튜닝 기준선)·최근 세션.",
|
||||
)
|
||||
async def ip_session_stats(hours: int = Query(168, ge=1, le=720), service: AdminService = Depends()):
|
||||
return RemoveNoneResponse(await service.ip_session_stats(hours))
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/stats/bot",
|
||||
response_model=Res_BotStats,
|
||||
summary="차단(봇 감지) 통계(관리자)",
|
||||
description="시간대별 차단 건수 + 최근 감지 목록(마커·포트·IP 요청순번).",
|
||||
)
|
||||
async def bot_stats(hours: int = Query(168, ge=1, le=720), service: AdminService = Depends()):
|
||||
return RemoveNoneResponse(await service.bot_stats(hours))
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/stats/cost",
|
||||
response_model=Res_CostStats,
|
||||
summary="검색원가 시계열(관리자)",
|
||||
description="완료 잡의 metrics 를 시간별 합산 — AI vs 프록시 대역폭 비용 분해 + 평균 소요.",
|
||||
)
|
||||
async def cost_stats(hours: int = Query(48, ge=1, le=720), service: AdminService = Depends()):
|
||||
return RemoveNoneResponse(await service.cost_stats(hours))
|
||||
108
lps/router/v1/lps/admin_protocol.py
Normal file
108
lps/router/v1/lps/admin_protocol.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""관리자 FE 응답 프로토콜 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common.models.gmodel import Res_WebPacketProtocol
|
||||
|
||||
|
||||
class JobListItem(BaseModel):
|
||||
job_id: str
|
||||
job_type: int
|
||||
status: str = Field(description="JobStatus 이름(PENDING/RUNNING/DONE/DEAD)")
|
||||
priority: int
|
||||
attempts: int
|
||||
max_attempts: int
|
||||
product_code: Optional[str] = None
|
||||
product_name: Optional[str] = None
|
||||
outcome: Optional[str] = Field(None, description="found / not_found (완료 시)")
|
||||
final_lowest: Optional[int] = None
|
||||
cost_usd: Optional[float] = Field(None, description="검색 원가($, metrics 합)")
|
||||
last_error: Optional[str] = None
|
||||
created_at: str
|
||||
run_started_at: Optional[str] = None
|
||||
updated_at: str
|
||||
|
||||
|
||||
class Res_JobList(Res_WebPacketProtocol):
|
||||
items: list[JobListItem] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class Res_Requeue(Res_WebPacketProtocol):
|
||||
job_id: Optional[str] = None
|
||||
requeued: bool = False
|
||||
|
||||
|
||||
class ProductItem(BaseModel):
|
||||
product_code: str
|
||||
display_name: Optional[str] = Field(None, description="최신 매칭 상품명(네이버 우선) — 표시용 근사값")
|
||||
triggered_at: str = Field(description="마지막 검색 시각")
|
||||
outcome: str
|
||||
naver_lowest: Optional[int] = None
|
||||
coupang_lowest: Optional[int] = None
|
||||
final_lowest: Optional[int] = None
|
||||
final_source: Optional[str] = None
|
||||
searches: int = Field(0, description="누적 검색(이력) 수")
|
||||
|
||||
|
||||
class Res_ProductList(Res_WebPacketProtocol):
|
||||
items: list[ProductItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HistogramBin(BaseModel):
|
||||
requests: int
|
||||
count: int
|
||||
|
||||
|
||||
class IpSessionRow(BaseModel):
|
||||
source: str
|
||||
proxy_port: Optional[int] = None
|
||||
requests: int
|
||||
ok_count: int
|
||||
blocked_count: int
|
||||
elapsed_sec: Optional[int] = None
|
||||
end_reason: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class Res_IpSessionStats(Res_WebPacketProtocol):
|
||||
by_reason: dict[str, int] = Field(default_factory=dict, description="종료 사유별 세션 수")
|
||||
histogram: list[HistogramBin] = Field(default_factory=list, description="세션당 요청 수 분포")
|
||||
block_min_requests: Optional[int] = Field(None, description="차단 세션의 최소 요청 수 — 예산은 이보다 낮게")
|
||||
sessions: list[IpSessionRow] = Field(default_factory=list, description="최근 세션 50건")
|
||||
|
||||
|
||||
class HourlyCount(BaseModel):
|
||||
bucket: str
|
||||
count: int
|
||||
|
||||
|
||||
class BotRow(BaseModel):
|
||||
source: str
|
||||
query: Optional[str] = None
|
||||
ip_request_no: Optional[int] = None
|
||||
proxy_port: Optional[int] = None
|
||||
elapsed_sec: Optional[int] = None
|
||||
marker: Optional[str] = None
|
||||
html_len: Optional[int] = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class Res_BotStats(Res_WebPacketProtocol):
|
||||
hourly: list[HourlyCount] = Field(default_factory=list)
|
||||
items: list[BotRow] = Field(default_factory=list, description="최근 감지 50건")
|
||||
|
||||
|
||||
class CostBucket(BaseModel):
|
||||
bucket: str = Field(description="시간(hour truncate)")
|
||||
jobs: int
|
||||
ai_usd: float
|
||||
proxy_usd: float
|
||||
total_usd: float
|
||||
avg_ms: int = Field(description="검색 1건 평균 소요(ms)")
|
||||
|
||||
|
||||
class Res_CostStats(Res_WebPacketProtocol):
|
||||
buckets: list[CostBucket] = Field(default_factory=list)
|
||||
29
lps/router/v1/validator/auth.py
Normal file
29
lps/router/v1/validator/auth.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""API 키 guard — [WebServerConfig].api_keys 가 설정된 경우에만 /v1 라우터 전체를 보호한다.
|
||||
|
||||
개발(local/dev)은 키를 비워 **개방 모드**로 쓰고, prod toml 에서만 키를 채운다(협의 결정
|
||||
2026-07-13). '키의 존재'가 토글이다.
|
||||
- 키는 복수 허용 — 무중단 키 교체(새 키 추가 → 호출자 전환 → 옛 키 제거).
|
||||
- 비교는 secrets.compare_digest(상수시간) — 타이밍 공격 방지.
|
||||
- /healthz·/readyz 는 라우터 밖이라 guard 대상이 아니다(LB/오케스트레이터 프로브).
|
||||
- prod 는 여기에 더해 lps-api 포트 비공개(내부 네트워크만)를 권장 — docs/operations.md.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
from config.server_configs import web_server_config
|
||||
|
||||
|
||||
def configured_keys() -> set[str]:
|
||||
"""유효 API 키 집합. 매 호출 config 를 읽는다 — 테스트에서 monkeypatch 로 on/off 전환 가능."""
|
||||
return {k.strip() for k in web_server_config.api_keys if k.strip()}
|
||||
|
||||
|
||||
async def require_api_key(x_api_key: str | None = Header(None, alias="X-API-Key")):
|
||||
"""/v1 공통 의존성. 키 미설정=개방 모드(무검증), 설정 시 X-API-Key 불일치는 401."""
|
||||
keys = configured_keys()
|
||||
if not keys:
|
||||
return
|
||||
if not x_api_key or not any(secrets.compare_digest(x_api_key, k) for k in keys):
|
||||
raise HTTPException(status_code=401, detail="invalid or missing X-API-Key")
|
||||
40
lps/run_docker.sh
Executable file
40
lps/run_docker.sh
Executable file
@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# LPS Docker 실행(대화형) — lps-api/lps-worker/lps-admin 을 관리한다.
|
||||
# 환경 구분 없음(도메인 backend·negodata·agent 와 동일): 항상 config.local.toml.
|
||||
# 어느 서버든 그 서버의 config.local.toml 값으로 뜬다 — prod 서버엔 prod 값을 채워두면 그만.
|
||||
# 호스트(비도커) 실행은 run_local_server.sh / run_local_worker.sh 를 사용.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # 리포 루트(docker-compose.yml 위치)
|
||||
|
||||
echo "── LPS Docker 실행 ──"
|
||||
|
||||
CFG="lps/config/config.local.toml"
|
||||
if [[ ! -f "$CFG" ]]; then
|
||||
echo "[warn] $CFG 가 없습니다 (시크릿 포함, 미커밋 — 서버마다 직접 준비)."
|
||||
read -rp "example 을 복사해 생성할까요? [Y/n] " yn
|
||||
if [[ "${yn:-Y}" =~ ^[Yy] ]]; then
|
||||
cp "lps/config/config.local.toml.example" "$CFG"
|
||||
echo "[ok] $CFG 생성 — DB 접속·API 키 등 값을 채운 뒤 다시 실행하세요."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# prod 서버 안전 점검: guard 키가 비어 있으면 API 가 개방 모드로 뜬다 — 명시적 확인.
|
||||
if grep -qE '^\s*api_keys\s*=\s*\[\s*\]' "$CFG"; then
|
||||
echo "[warn] [WebServerConfig].api_keys 가 비어 있습니다 → API 개방 모드(무인증). prod 라면 키를 채우세요."
|
||||
read -rp "그래도 계속할까요? [y/N] " go
|
||||
[[ "${go:-N}" =~ ^[Yy] ]] || { echo "중단합니다."; exit 1; }
|
||||
fi
|
||||
|
||||
# 운영(리버스프록시 뒤)에서 API 를 외부에 열지 않으려면 실행 전에: export LPS_API_BIND=127.0.0.1
|
||||
echo ""
|
||||
echo "설정: $CFG · API 바인드: ${LPS_API_BIND:-0.0.0.0}"
|
||||
read -rp "동작 선택 [1] 시작/재빌드(기본) [2] 재시작 [3] 중지 [4] 로그 : " act
|
||||
case "${act:-1}" in
|
||||
1) docker compose up -d --build lps-api lps-worker lps-admin autoheal
|
||||
echo "[ok] 기동 — admin: http://localhost:3400 · 로그의 'API guard ON/개방 모드' · 'DECODO 프리플라이트' 확인: docker compose logs -f lps-api lps-worker lps-admin" ;;
|
||||
2) docker compose restart lps-api lps-worker lps-admin ;;
|
||||
3) docker compose stop lps-api lps-worker lps-admin ;;
|
||||
4) docker compose logs -f lps-api lps-worker lps-admin ;;
|
||||
*) echo "[error] 알 수 없는 선택: $act"; exit 1 ;;
|
||||
esac
|
||||
@ -51,15 +51,11 @@ export APP_ENV=local
|
||||
case "$choice" in
|
||||
1) # 멀티코어: API 는 asyncio(단일 스레드)라 프로세스 수 = 사용 코어 수. 커넥션 풀은 예산에서 자동 역산.
|
||||
cpu_count="$(sysctl -n hw.ncpu 2>/dev/null || echo '?')"
|
||||
read -rp "프로세스 수 PROCESS_COUNT [${PROCESS_COUNT:-1}] (CPU ${cpu_count}코어, 부하테스트 벤치는 4): " pc
|
||||
pc="${pc:-${PROCESS_COUNT:-1}}"
|
||||
export PROCESS_COUNT="$pc"
|
||||
if [[ "$pc" != "1" ]]; then
|
||||
read -rp "DB 커넥션 예산 DB_CONNECTION_BUDGET [${DB_CONNECTION_BUDGET:-96}] (전용PG≈90+, 공유PG 40): " budget
|
||||
export DB_CONNECTION_BUDGET="${budget:-${DB_CONNECTION_BUDGET:-96}}"
|
||||
fi
|
||||
echo "[run] web_main.py → http://localhost:$PORT/docs (프로세스 ${pc}개)"
|
||||
echo " 기동 로그의 'DB Pool : ... × ${pc}workers ... (budget=...)' 으로 실효 풀 확인"
|
||||
# 커넥션 예산은 config.local.toml [MainDBConfig].connection_budget 이 소스(2026-07-13 toml 단일화).
|
||||
read -rp "프로세스 수 [엔터=toml 설정값] (CPU ${cpu_count}코어, 부하테스트 벤치는 4): " pc
|
||||
[[ -n "$pc" ]] && export PROCESS_COUNT="$pc"
|
||||
echo "[run] web_main.py → http://localhost:$PORT/docs (프로세스 ${pc:-toml 설정값}개)"
|
||||
echo " 기동 로그의 'DB Pool : ...' 으로 실효 풀 확인"
|
||||
exec "$PY" web_main.py ;;
|
||||
2) echo "[run] uvicorn --reload → http://localhost:$PORT/docs"
|
||||
exec "$VENV/bin/uvicorn" router.router:app --host 0.0.0.0 --port "$PORT" --reload ;;
|
||||
|
||||
@ -36,27 +36,17 @@ if pgrep -f worker_main.py >/dev/null 2>&1; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4) 동시성 입력 (상품 동시 검색 수 · 워커별 브라우저 세트 · Chrome 최대 4×N개)
|
||||
echo "── 워커 설정 ──"
|
||||
read -rp "동시 검색 수 WORKER_CONCURRENCY [3] (로컬 권장 2~3): " CONC
|
||||
CONC="${CONC:-3}"
|
||||
|
||||
# 5) Chrome 프로필 영속 디렉터리 (재시작해도 cf_clearance 유지 → 재웜업 회피). 비우면 기본(/tmp)
|
||||
read -rp "Chrome 프로필 디렉터리 LPS_PROFILE_DIR [.profiles] (엔터=유지): " PROFILE
|
||||
PROFILE="${PROFILE:-.profiles}"
|
||||
mkdir -p "$PROFILE"
|
||||
|
||||
# 6) 오픈마켓 폴백 — 기본 비활성(2026-07-10 협의: 최종 최저가 기여 0회, 검색당 최대 15s·프록시 비용의 대부분)
|
||||
read -rp "오픈마켓 폴백 LPS_FALLBACKS [비활성] (켜려면 예: gmarket,auction,st11): " FALLBACKS
|
||||
export LPS_FALLBACKS="${FALLBACKS:-}"
|
||||
# 4) 동시성 입력 — 실행 시 임시 override(엔터=toml [WorkerConfig].concurrency 사용).
|
||||
# 프로필·폴백·데드라인 등 나머지 설정은 config.local.toml [WorkerConfig] 가 소스(2026-07-13 toml 단일화).
|
||||
echo "── 워커 설정 (프로필·폴백 등은 config.local.toml [WorkerConfig]에서) ──"
|
||||
read -rp "동시 검색 수 [엔터=toml 설정값] (로컬 권장 2~3): " CONC
|
||||
|
||||
export APP_ENV=local
|
||||
export WORKER_CONCURRENCY="$CONC"
|
||||
export LPS_PROFILE_DIR="$PROFILE"
|
||||
[[ -n "$CONC" ]] && export WORKER_CONCURRENCY="$CONC"
|
||||
export PYTHONUNBUFFERED=1 # 로그 실시간 출력
|
||||
|
||||
echo ""
|
||||
echo "[run] worker_main.py (동시성=$CONC · 프로필=$PROFILE · 폴백=${LPS_FALLBACKS:-OFF})"
|
||||
echo "[run] worker_main.py (동시성=${CONC:-toml 설정값})"
|
||||
echo " 기동 로그의 'DECODO 프리플라이트 OK — egress IP ...' / 'AI: ON/OFF' 확인"
|
||||
echo " 중단: Ctrl+C"
|
||||
echo ""
|
||||
|
||||
116
lps/services/admin_service.py
Normal file
116
lps/services/admin_service.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""관리자 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),
|
||||
) 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
|
||||
@ -8,6 +8,7 @@ import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.enums import ErrorType, JobStatus, JobType
|
||||
from crud.job_crud import JobQueue
|
||||
from crud.price_history import PriceHistory
|
||||
@ -74,9 +75,11 @@ class LpsService:
|
||||
return res
|
||||
|
||||
async def ops(self) -> dict:
|
||||
"""운영 스냅샷(모니터링·알림용, 플랫 JSON): 큐 카운트·지연·최근 DEAD·최근 차단 수."""
|
||||
"""운영 스냅샷(모니터링·알림용, 플랫 JSON): 큐 카운트·지연·최근 DEAD·최근 차단 수 + DB 풀 사용률."""
|
||||
snap = await self.queue.ops()
|
||||
snap["blocks_1h"] = await self.bot_log.recent_count(60)
|
||||
pool = DB_SESSION_MNG.pool_status() # API 프로세스 자신의 풀(워커 풀은 워커 ops-monitor 가 감시)
|
||||
snap["pool_checked_out"], snap["pool_capacity"], snap["pool_pct"] = pool["checked_out"], pool["capacity"], pool["pct"]
|
||||
return snap
|
||||
|
||||
async def price_history(self, product_code: str, limit: int = 100) -> Res_PriceHistory:
|
||||
|
||||
@ -14,13 +14,13 @@ detect_block 은 순수 함수로 분리 — 브라우저 없이 단위 테스
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
|
||||
from patchright.async_api import async_playwright
|
||||
|
||||
from common.logger import LOG
|
||||
from config.server_configs import decodo_config, worker_config
|
||||
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
|
||||
from services.search.rate_limiter import RateLimiter
|
||||
|
||||
@ -28,10 +28,10 @@ from services.search.rate_limiter import RateLimiter
|
||||
# 오픈마켓(ESM/11번가)은 CSS/JS 를 막으면 렌더/챌린지가 깨져 이미지·미디어·폰트만 막는다(어댑터에서 override).
|
||||
_BLOCKED_RESOURCES = {"image", "media", "font", "stylesheet"}
|
||||
|
||||
# 브라우저 실행 대상(env override): 로컬 Mac=실제 Chrome(channel=chrome), 컨테이너=시스템 chromium(executable_path).
|
||||
# 브라우저 실행 대상([WorkerConfig]): 로컬 Mac=실제 Chrome(channel), 컨테이너=시스템 chromium(executable).
|
||||
# headless 는 안티봇에 탐지되므로 서버에선 Xvfb(가상 디스플레이)로 headful 실행한다(headless 실측 실패).
|
||||
_CHROME_CHANNEL = os.environ.get("LPS_CHROME_CHANNEL", "chrome")
|
||||
_CHROME_EXECUTABLE = os.environ.get("LPS_CHROME_EXECUTABLE") or None
|
||||
_CHROME_CHANNEL = worker_config.chrome_channel
|
||||
_CHROME_EXECUTABLE = worker_config.chrome_executable or None
|
||||
|
||||
|
||||
def detect_block(html: str, product_count: int, markers: tuple, min_len: int) -> str | None:
|
||||
@ -73,7 +73,8 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
max_proxy_retries: int = 2 # 프록시 전송오류(포트 사망) 시 IP 회전 재시도 횟수
|
||||
|
||||
def __init__(self, headless: bool = False, user_data_dir: str | None = None, rate_limiter: RateLimiter | None = None,
|
||||
proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1):
|
||||
proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1,
|
||||
ip_request_budget: int | None = None, on_session_end=None):
|
||||
self._headless = headless
|
||||
self._user_data_dir = user_data_dir or f"/tmp/lps_{self.source}_profile"
|
||||
self._rl = rate_limiter or RateLimiter()
|
||||
@ -82,6 +83,10 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
self._block_active = self._block_resources # 요청별 실제 차단 여부(_blocking_now 로 갱신)
|
||||
self._on_detect = on_detect # async def(event: dict) — 감지 영속화(선택)
|
||||
self._max_block_retries = max_block_retries
|
||||
# IP(포트 세션)당 요청 예산([DecodoConfig].ip_request_budget) — 도달하면 차단당하기 **전에**
|
||||
# 선제 회전해 IP 평판을 보존한다. 실측상 5회 부근 차단 이력 → 기본 3. 0=비활성(시간창 회전만).
|
||||
self._ip_budget = decodo_config.ip_request_budget if ip_request_budget is None else ip_request_budget
|
||||
self._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터)
|
||||
self._pw = None
|
||||
self._ctx = None
|
||||
self._launched_at = 0.0
|
||||
@ -91,6 +96,9 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
self._lock = asyncio.Lock()
|
||||
self._ok = 0
|
||||
self._blocked = 0
|
||||
self._sess_ok = 0 # 현재 IP 세션의 성공/차단(세션 종료 기록용, 재기동 시 리셋)
|
||||
self._sess_blocked = 0
|
||||
self._end_reason = None # 이번 세션이 끝나는 이유(budget/block/proxy_error/window/idle/shutdown)
|
||||
self._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용
|
||||
self._cdp = None # CDP 세션(실제 네트워크 바이트 계측용). 미지원 시 None → DOM 크기 폴백
|
||||
self._net_bytes = 0 # 현재 검색의 실제 전송 바이트(encodedDataLength 누적)
|
||||
@ -143,6 +151,8 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
if self._ctx is not None:
|
||||
if self._recycle_due():
|
||||
LOG.d(f"[{self.source}] 브라우저 재기동(IP 회전)")
|
||||
if self._end_reason is None: # force 가 아닌 시간창 만료 재기동
|
||||
self._end_reason = "window"
|
||||
await self._close_ctx()
|
||||
else:
|
||||
return
|
||||
@ -163,16 +173,35 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
await self._ctx.route("**/*", self._route)
|
||||
self._launched_at = time.monotonic()
|
||||
self._ip_requests = 0
|
||||
self._sess_ok = self._sess_blocked = 0
|
||||
self._end_reason = None
|
||||
self._force_recycle = False
|
||||
|
||||
async def _close_ctx(self):
|
||||
self._cdp = None # 컨텍스트와 함께 CDP 세션도 죽음 → 다음 검색 때 재부착
|
||||
if self._ctx is not None:
|
||||
await self._record_session_end()
|
||||
try:
|
||||
await self._ctx.close()
|
||||
finally:
|
||||
self._ctx = None
|
||||
|
||||
async def _record_session_end(self):
|
||||
"""IP 세션 종료 1건 기록 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'. 예산(상한) 튜닝의 원천 데이터.
|
||||
요청이 없던 세션(유휴 정리 등)은 노이즈라 기록하지 않는다. 기록 실패가 검색을 막지 않는다."""
|
||||
if self._on_session_end is None or self._ip_requests == 0:
|
||||
self._end_reason = None
|
||||
return
|
||||
event = {"source": self.source, "proxy_port": self._current_port,
|
||||
"requests": self._ip_requests, "ok_count": self._sess_ok, "blocked_count": self._sess_blocked,
|
||||
"elapsed_sec": int(time.monotonic() - self._launched_at),
|
||||
"end_reason": self._end_reason or "window"}
|
||||
self._end_reason = None
|
||||
try:
|
||||
await self._on_session_end(event)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[{self.source}] IP 세션 기록 실패(무시): {ex}")
|
||||
|
||||
def _add_net(self, event):
|
||||
"""CDP Network.loadingFinished 콜백 — 실제 전송 바이트(encodedDataLength) 누적."""
|
||||
try:
|
||||
@ -196,12 +225,18 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
"""이 어댑터가 프록시(DECODO)를 경유하는지 — 대역폭 비용 귀속용."""
|
||||
return bool(self._proxy and self._proxy.enabled)
|
||||
|
||||
def _rotate_ip(self, reason: str):
|
||||
"""즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동."""
|
||||
def _budget_reached(self) -> bool:
|
||||
"""현재 IP 로 요청 예산을 소진했는지(선제 회전 트리거). 프록시 미사용·예산 0(비활성)이면 False."""
|
||||
return self.uses_proxy and self._ip_budget > 0 and self._ip_requests >= self._ip_budget
|
||||
|
||||
def _rotate_ip(self, reason: str, kind: str = "rotate", warn: bool = True):
|
||||
"""즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동.
|
||||
kind 는 세션 종료 사유로 기록된다(budget=선제/block=차단/proxy_error=포트사망)."""
|
||||
if self._proxy and self._proxy.enabled:
|
||||
self._proxy.rotate()
|
||||
self._force_recycle = True
|
||||
LOG.w(f"[{self.source}] IP 회전 — {reason}")
|
||||
self._end_reason = kind
|
||||
(LOG.w if warn else LOG.i)(f"[{self.source}] IP 회전 — {reason}")
|
||||
|
||||
# ---- 검색(프록시 전송오류·봇 감지 → IP 회전 인라인 재시도) --------
|
||||
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
|
||||
@ -210,6 +245,10 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
proxy_retries, block_retries = self.max_proxy_retries, self._max_block_retries
|
||||
while True:
|
||||
await self._rl.wait()
|
||||
# 예산 도달 → 차단당하기 전에 선제 회전. 이 포트는 불탄 게 아니라 쿨다운 없이
|
||||
# 로테이션 복귀 시 재사용된다(IP 평판 보존이 목적).
|
||||
if self._budget_reached():
|
||||
self._rotate_ip(f"요청예산 {self._ip_budget}회 도달 — 선제 회전", kind="budget", warn=False)
|
||||
await self._ensure_browser()
|
||||
self._ip_requests += 1
|
||||
page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
|
||||
@ -225,8 +264,10 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
# 프록시 전송 실패(포트/IP 사망·407)면 사이트 문제가 아니므로 IP 회전 후 재시도
|
||||
if self.uses_proxy and is_proxy_error(str(ex)) and proxy_retries > 0:
|
||||
proxy_retries -= 1
|
||||
self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}")
|
||||
self._proxy.mark_burned(self._current_port) # 죽은 포트 — 쿨다운 뒤 복귀(sticky 만료로 새 IP)
|
||||
self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}", kind="proxy_error")
|
||||
continue
|
||||
self._note_result(False)
|
||||
raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex
|
||||
|
||||
# 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백
|
||||
@ -234,6 +275,8 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
products = self._parse(html)
|
||||
if products:
|
||||
self._ok += 1
|
||||
self._sess_ok += 1
|
||||
self._note_result(True)
|
||||
LOG.d(f"[{self.source}] query={query!r} → {len(products)}건 (limit {limit}, ip_req#{self._ip_requests})")
|
||||
return products[:limit]
|
||||
|
||||
@ -241,13 +284,19 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
blocked = marker is not None
|
||||
self._blocked += 1
|
||||
if blocked:
|
||||
self._sess_blocked += 1
|
||||
await self._report_detection(query, marker, len(html))
|
||||
if self.uses_proxy:
|
||||
self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)
|
||||
|
||||
if blocked and self.uses_proxy and block_retries > 0:
|
||||
block_retries -= 1
|
||||
self._rotate_ip(f"봇 감지 재시도 {self._max_block_retries - block_retries}/{self._max_block_retries}")
|
||||
self._rotate_ip(f"봇 감지 재시도 {self._max_block_retries - block_retries}/{self._max_block_retries}", kind="block")
|
||||
continue
|
||||
|
||||
if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기
|
||||
self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block")
|
||||
self._note_result(False)
|
||||
raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", source=self.source, blocked=blocked)
|
||||
|
||||
async def _report_detection(self, query: str, marker: str, html_len: int):
|
||||
@ -279,12 +328,14 @@ class BrowserSearchAdapter(SearchAdapter):
|
||||
async with self._lock:
|
||||
if self._ctx is not None and time.monotonic() - self._last_used >= idle_sec:
|
||||
LOG.d(f"[{self.source}] 유휴 {idle_sec:.0f}s 초과 → 브라우저 정리(다음 검색 때 재기동)")
|
||||
if self._end_reason is None:
|
||||
self._end_reason = "idle"
|
||||
await self._close_ctx()
|
||||
|
||||
async def close(self):
|
||||
if self._ctx is not None:
|
||||
await self._ctx.close()
|
||||
self._ctx = None
|
||||
if self._end_reason is None:
|
||||
self._end_reason = "shutdown"
|
||||
await self._close_ctx()
|
||||
if self._pw is not None:
|
||||
await self._pw.stop()
|
||||
self._pw = None
|
||||
|
||||
@ -5,7 +5,9 @@
|
||||
새 소스는 SearchAdapter 를 구현하기만 하면 코어 변경 없이 붙는다(트레드밀 격리).
|
||||
"""
|
||||
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@ -59,3 +61,24 @@ class SearchAdapter(ABC):
|
||||
async def health(self) -> AdapterHealth:
|
||||
"""기본 건강도. 어댑터가 관측 지표를 축적하면 override."""
|
||||
return AdapterHealth(source=self.source, ok=True)
|
||||
|
||||
# ---- 시간 윈도우 성공/실패 카운터(장기 실패 알림용) ------------------
|
||||
# 기존 _ok/_blocked 는 기동 후 누적이라 '최근 30분 성공 0건' 같은 장기 실패를 못 본다.
|
||||
# 어댑터의 search 성공/실패 지점에서 _note_result 를 부르면 ops-monitor 가 recent_stats 로 읽는다.
|
||||
# (lazy init — 서브클래스가 super().__init__ 을 부르지 않아도 동작)
|
||||
|
||||
def _note_result(self, ok: bool):
|
||||
ev = getattr(self, "_win_events", None)
|
||||
if ev is None:
|
||||
ev = self._win_events = deque(maxlen=512)
|
||||
ev.append((time.monotonic(), ok))
|
||||
|
||||
def recent_stats(self, window_sec: float = 1800.0) -> tuple[int, int]:
|
||||
"""최근 window_sec 내 (시도 수, 성공 수)."""
|
||||
now = time.monotonic()
|
||||
tries = ok = 0
|
||||
for t, s in getattr(self, "_win_events", ()):
|
||||
if now - t <= window_sec:
|
||||
tries += 1
|
||||
ok += s
|
||||
return tries, ok
|
||||
|
||||
@ -50,24 +50,29 @@ class NaverAdapter(SearchAdapter):
|
||||
|
||||
self.last_bytes = 0
|
||||
collected: list[dict] = []
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
start = 1
|
||||
while len(collected) < limit and start <= _MAX_START:
|
||||
display = min(_MAX_DISPLAY, limit - len(collected))
|
||||
data = await self._request(client, {
|
||||
"query": query, "display": display, "start": start,
|
||||
"sort": "sim", "exclude": "used:rental:cbshop",
|
||||
})
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
break
|
||||
collected.extend(items)
|
||||
start += display
|
||||
if len(items) < display:
|
||||
break
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
start = 1
|
||||
while len(collected) < limit and start <= _MAX_START:
|
||||
display = min(_MAX_DISPLAY, limit - len(collected))
|
||||
data = await self._request(client, {
|
||||
"query": query, "display": display, "start": start,
|
||||
"sort": "sim", "exclude": "used:rental:cbshop",
|
||||
})
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
break
|
||||
collected.extend(items)
|
||||
start += display
|
||||
if len(items) < display:
|
||||
break
|
||||
except Exception:
|
||||
self._note_result(False) # 장기 실패 알림용 윈도우 카운터(쿼터 소진·네트워크 포함)
|
||||
raise
|
||||
|
||||
products = transform_items(collected, self.source)
|
||||
self._ok += 1
|
||||
self._note_result(True)
|
||||
LOG.d(f"[naver] query={query!r} → {len(products)}건 (limit {limit})")
|
||||
return products[:limit]
|
||||
|
||||
|
||||
@ -28,24 +28,51 @@ class DecodoProxy:
|
||||
self.port_end = cfg.port_end
|
||||
self.session_minutes = cfg.session_minutes or 10
|
||||
self._rotate_offset = 0 # 봇 감지 등으로 '즉시 회전'이 필요할 때 증가
|
||||
# 불탄(차단 감지된) 포트 격리 시간([DecodoConfig].port_cooldown_sec). sticky 만료(session_minutes)
|
||||
# 이상이어야 쿨다운 복귀 시 같은 포트라도 사실상 새 IP 가 배정된다. 0=자동 max(sticky, 30분).
|
||||
self.cooldown_sec = getattr(cfg, "port_cooldown_sec", 0) or max(self.session_minutes * 60, 1800)
|
||||
self._burned: dict[int, float] = {} # port → 쿨다운 만료 시각(monotonic)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return all([self.host, self.username, self.password, self.port_start, self.port_end])
|
||||
|
||||
def rotate(self):
|
||||
"""시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지 시 호출."""
|
||||
"""시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지·예산 도달 시 호출."""
|
||||
self._rotate_offset += 1
|
||||
|
||||
def seed_offset(self, k: int):
|
||||
"""워커별 시작 포트 분산용 — 동시 워커가 같은 포트(=같은 IP)를 쓰지 않도록 시작점을 벌린다."""
|
||||
self._rotate_offset = k
|
||||
|
||||
def mark_burned(self, port: int | None, cooldown_sec: float | None = None):
|
||||
"""차단 감지된 포트를 쿨다운 격리 — _port() 가 만료 전까지 건너뛴다.
|
||||
선제(예산) 회전된 포트는 부르지 않는다 — 불탄 게 아니므로 로테이션 복귀 시 재사용."""
|
||||
if port is None:
|
||||
return
|
||||
self._burned[port] = time.monotonic() + (cooldown_sec if cooldown_sec is not None else self.cooldown_sec)
|
||||
LOG.i(f"[proxy] 포트 {port} 쿨다운 {int(cooldown_sec or self.cooldown_sec)}s — 활성 {self.available_ports()}/{self.port_end - self.port_start + 1}")
|
||||
|
||||
def available_ports(self) -> int:
|
||||
"""쿨다운 중이 아닌 포트 수(관측·알림용)."""
|
||||
self._prune_burned()
|
||||
return (self.port_end - self.port_start + 1) - len(self._burned)
|
||||
|
||||
def _prune_burned(self):
|
||||
now = time.monotonic()
|
||||
self._burned = {p: t for p, t in self._burned.items() if t > now}
|
||||
|
||||
def _port(self) -> int:
|
||||
"""시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP."""
|
||||
"""시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP.
|
||||
쿨다운 중인 포트는 건너뛰고, 전 포트가 쿨다운이면 만료가 가장 임박한 포트를 쓴다(가용성 우선)."""
|
||||
n = self.port_end - self.port_start + 1
|
||||
bucket = int(time.time() // (self.session_minutes * 60))
|
||||
return self.port_start + ((bucket + self._rotate_offset) % n)
|
||||
self._prune_burned()
|
||||
for k in range(n):
|
||||
port = self.port_start + ((bucket + self._rotate_offset + k) % n)
|
||||
if port not in self._burned:
|
||||
return port
|
||||
return min(self._burned, key=self._burned.get)
|
||||
|
||||
@property
|
||||
def current_port(self):
|
||||
|
||||
139
lps/tests/test_admin_api.py
Normal file
139
lps/tests/test_admin_api.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""관리자 FE API 테스트 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계 (실 lps_db)."""
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import JobType
|
||||
from crud.job_crud import JobQueue
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def clean_all(db_engine):
|
||||
async with db_engine.begin() as conn:
|
||||
for t in ("job", "price_history", "ip_session", "bot_detection"):
|
||||
await conn.execute(text(f"TRUNCATE {t}"))
|
||||
return db_engine
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def q(clean_all):
|
||||
return JobQueue()
|
||||
|
||||
|
||||
# ---- 잡 목록 --------------------------------------------------------------
|
||||
|
||||
async def test_jobs_list_with_filters(client, q):
|
||||
await q.enqueue(JobType.SEARCH.value, {"product_code": "A1", "product_name": "맥심 커피"})
|
||||
await q.enqueue(JobType.SEARCH.value, {"product_code": "B2", "product_name": "생수"})
|
||||
job = await q.claim("w1")
|
||||
await q.complete(job["job_id"], "w1", {"outcome": "found", "lowest": {"price": 12000},
|
||||
"metrics": {"cost": {"total_usd": 0.01}}})
|
||||
r = await client.get("/v1/lps/jobs")
|
||||
j = r.json()
|
||||
assert r.status_code == 200 and j["total"] == 2
|
||||
|
||||
r = await client.get("/v1/lps/jobs", params={"status": "DONE"})
|
||||
j = r.json()
|
||||
assert j["total"] == 1
|
||||
assert j["items"][0]["outcome"] == "found" and j["items"][0]["final_lowest"] == 12000
|
||||
assert j["items"][0]["cost_usd"] == 0.01
|
||||
|
||||
r = await client.get("/v1/lps/jobs", params={"q": "맥심"})
|
||||
assert r.json()["total"] == 1
|
||||
|
||||
|
||||
async def test_jobs_list_unknown_status(client, q):
|
||||
r = await client.get("/v1/lps/jobs", params={"status": "NOPE"})
|
||||
assert r.json()["result"]["success"] is False
|
||||
|
||||
|
||||
# ---- 재큐 ------------------------------------------------------------------
|
||||
|
||||
async def test_requeue_dead_job(client, q):
|
||||
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, max_attempts=1, dedupe_key="search-A1")
|
||||
await q.claim("w1")
|
||||
await q.fail(jid, "w1", "boom", backoff_sec=0) # 1/1 → DEAD
|
||||
r = await client.post(f"/v1/lps/jobs/{jid}/requeue")
|
||||
assert r.json()["requeued"] is True
|
||||
assert (await q.counts())["PENDING"] == 1 # DEAD → PENDING
|
||||
|
||||
|
||||
async def test_requeue_rejects_non_dead_and_missing(client, q):
|
||||
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"})
|
||||
r = await client.post(f"/v1/lps/jobs/{jid}/requeue") # PENDING — 재큐 대상 아님
|
||||
assert r.json()["result"]["success"] is False
|
||||
r = await client.post("/v1/lps/jobs/not-a-uuid/requeue")
|
||||
assert r.json()["result"]["success"] is False
|
||||
|
||||
|
||||
async def test_requeue_blocked_by_active_duplicate(client, q):
|
||||
dead = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, max_attempts=1, dedupe_key="search-A1")
|
||||
await q.claim("w1")
|
||||
await q.fail(dead, "w1", "boom", backoff_sec=0)
|
||||
await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, dedupe_key="search-A1") # 활성 중복 생성
|
||||
r = await client.post(f"/v1/lps/jobs/{dead}/requeue")
|
||||
assert r.json()["requeued"] is False
|
||||
assert r.json()["result"]["success"] is False # DB_ALREADY_SAME_KEY
|
||||
|
||||
|
||||
# ---- 상품 목록 --------------------------------------------------------------
|
||||
|
||||
async def test_products_list_latest_snapshot(client, clean_all):
|
||||
async with clean_all.begin() as conn:
|
||||
await conn.execute(text("""
|
||||
INSERT INTO price_history (product_code, outcome, naver_lowest, final_lowest, naver_name, triggered_at)
|
||||
VALUES ('P1', 'found', 1000, 900, '커피 320개입', now() - interval '2 hour'),
|
||||
('P1', 'found', 1100, 950, '커피 320개입', now() - interval '1 hour'),
|
||||
('P2', 'not_found', NULL, NULL, NULL, now())
|
||||
"""))
|
||||
r = await client.get("/v1/lps/products")
|
||||
items = r.json()["items"]
|
||||
assert [i["product_code"] for i in items] == ["P2", "P1"] # 최근 검색순
|
||||
p1 = items[1]
|
||||
assert p1["searches"] == 2 and p1["final_lowest"] == 950 # 최신 스냅샷 + 누적 횟수
|
||||
r = await client.get("/v1/lps/products", params={"q": "P1"})
|
||||
assert len(r.json()["items"]) == 1
|
||||
|
||||
|
||||
# ---- 통계 3종 ---------------------------------------------------------------
|
||||
|
||||
async def test_ip_session_stats(client, clean_all):
|
||||
async with clean_all.begin() as conn:
|
||||
await conn.execute(text("""
|
||||
INSERT INTO ip_session (source, proxy_port, requests, ok_count, blocked_count, end_reason)
|
||||
VALUES ('coupang', 10001, 3, 3, 0, 'budget'),
|
||||
('coupang', 10002, 3, 3, 0, 'budget'),
|
||||
('coupang', 10003, 5, 4, 1, 'block')
|
||||
"""))
|
||||
r = await client.get("/v1/lps/stats/ip-sessions")
|
||||
j = r.json()
|
||||
assert j["by_reason"] == {"budget": 2, "block": 1}
|
||||
assert j["block_min_requests"] == 5 # 예산 튜닝 기준선
|
||||
assert {"requests": 3, "count": 2} in j["histogram"]
|
||||
assert len(j["sessions"]) == 3
|
||||
|
||||
|
||||
async def test_bot_stats(client, clean_all):
|
||||
async with clean_all.begin() as conn:
|
||||
await conn.execute(text("""
|
||||
INSERT INTO bot_detection (source, query, ip_request_no, proxy_port, marker)
|
||||
VALUES ('coupang', '생수', 4, 10001, '/akam/')
|
||||
"""))
|
||||
r = await client.get("/v1/lps/stats/bot")
|
||||
j = r.json()
|
||||
assert len(j["items"]) == 1 and j["items"][0]["marker"] == "/akam/"
|
||||
assert sum(h["count"] for h in j["hourly"]) == 1
|
||||
|
||||
|
||||
async def test_cost_stats(client, q):
|
||||
for cost in (0.01, 0.02):
|
||||
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": f"C{cost}"})
|
||||
job = await q.claim("w1")
|
||||
await q.complete(job["job_id"], "w1",
|
||||
{"metrics": {"cost": {"ai_usd": cost / 2, "proxy_usd": cost / 2, "total_usd": cost},
|
||||
"duration_ms": 15000}})
|
||||
r = await client.get("/v1/lps/stats/cost")
|
||||
buckets = r.json()["buckets"]
|
||||
assert sum(b["total_usd"] for b in buckets) == 0.03
|
||||
assert sum(b["jobs"] for b in buckets) == 2
|
||||
assert buckets[0]["avg_ms"] == 15000
|
||||
127
lps/tests/test_alerts.py
Normal file
127
lps/tests/test_alerts.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""AlertManager 테스트 — 발화·쿨다운(스팸 방지)·회복 알림 (sender/clock 주입, 네트워크·대기 없음)."""
|
||||
|
||||
from common.alerts import AlertManager
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from services.search.naver.adapter import NaverAdapter
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def _mgr(cooldown=1800):
|
||||
sent = []
|
||||
|
||||
async def sender(text):
|
||||
sent.append(text)
|
||||
|
||||
clock = _Clock()
|
||||
return AlertManager(origin="test", cooldown_sec=cooldown, sender=sender, clock=clock), sent, clock
|
||||
|
||||
|
||||
async def test_fires_once_on_activation():
|
||||
mgr, sent, _ = _mgr()
|
||||
await mgr.check("dead", True, "DEAD 1h=25")
|
||||
await mgr.check("dead", True, "DEAD 1h=26") # 조건 지속 — 쿨다운 안이라 재발송 없음
|
||||
assert len(sent) == 1 and "DEAD 1h=25" in sent[0]
|
||||
assert mgr.is_active("dead")
|
||||
|
||||
|
||||
async def test_refires_after_cooldown():
|
||||
mgr, sent, clock = _mgr(cooldown=1800)
|
||||
await mgr.check("dead", True, "m1")
|
||||
clock.t += 1801 # 쿨다운 경과 — 리마인드 1회
|
||||
await mgr.check("dead", True, "m2")
|
||||
assert len(sent) == 2 and "m2" in sent[1]
|
||||
|
||||
|
||||
async def test_recovery_notice_once():
|
||||
mgr, sent, _ = _mgr()
|
||||
await mgr.check("blocks", True, "차단 1h=90")
|
||||
await mgr.check("blocks", False, "차단 1h=3") # 해소 알림 1회
|
||||
await mgr.check("blocks", False, "차단 1h=0") # 이미 비활성 — 무발송
|
||||
assert len(sent) == 2
|
||||
assert "해소" in sent[1]
|
||||
assert not mgr.is_active("blocks")
|
||||
|
||||
|
||||
async def test_inactive_rule_never_fires():
|
||||
mgr, sent, _ = _mgr()
|
||||
await mgr.check("queue_lag", False, "큐지연=3s")
|
||||
assert sent == []
|
||||
|
||||
|
||||
async def test_rules_are_independent():
|
||||
mgr, sent, _ = _mgr()
|
||||
await mgr.check("dead", True, "a")
|
||||
await mgr.check("db_pool", True, "b") # 다른 키 — 각자 발화
|
||||
assert len(sent) == 2
|
||||
|
||||
|
||||
async def test_sender_failure_does_not_raise():
|
||||
async def boom(text):
|
||||
raise RuntimeError("webhook down")
|
||||
|
||||
mgr = AlertManager(origin="test", cooldown_sec=10, sender=boom)
|
||||
try:
|
||||
await mgr.check("dead", True, "m")
|
||||
except RuntimeError:
|
||||
# sender 주입 시 예외는 호출부(모니터 루프의 try)가 처리 — 여기서는 전파돼도 루프가 삼킨다
|
||||
pass
|
||||
|
||||
|
||||
# ---- 윈도우 성공/실패 카운터(소스별 장기 실패 룰의 데이터) ----------------
|
||||
|
||||
def test_recent_stats_counts_within_window():
|
||||
ad = NaverAdapter(keys=[("id", "sec")])
|
||||
ad._note_result(True)
|
||||
ad._note_result(False)
|
||||
ad._note_result(False)
|
||||
assert ad.recent_stats(1800) == (3, 1)
|
||||
assert ad.recent_stats(0) == (0, 0) # 창 밖이면 미집계
|
||||
|
||||
|
||||
def test_recent_stats_empty_adapter():
|
||||
ad = NaverAdapter(keys=[("id", "sec")])
|
||||
assert ad.recent_stats(1800) == (0, 0) # lazy init — 기록 전에도 안전
|
||||
|
||||
|
||||
# ---- DB 풀 사용 현황 ------------------------------------------------------
|
||||
|
||||
def test_pool_status_shape():
|
||||
st = DB_SESSION_MNG.pool_status()
|
||||
assert set(st) == {"checked_out", "capacity", "pct"}
|
||||
assert st["capacity"] > 0 # R/W 2엔진 × (pool+overflow)
|
||||
assert 0 <= st["pct"] <= 100
|
||||
|
||||
|
||||
# ---- 가용 프록시 포트 현황(포트 고갈 룰의 데이터) --------------------------
|
||||
|
||||
def test_proxy_ports_snapshot_min_across_workers():
|
||||
from config.config_models import DecodoConfig
|
||||
from services.search.proxy import DecodoProxy
|
||||
from worker_main import _proxy_ports_snapshot
|
||||
|
||||
def _proxy():
|
||||
return DecodoProxy(DecodoConfig(host="h", username="u", password="p",
|
||||
port_start=10001, port_end=10010, session_minutes=10))
|
||||
|
||||
class _Ad:
|
||||
def __init__(self, proxy):
|
||||
self._proxy = proxy
|
||||
|
||||
p1, p2 = _proxy(), _proxy()
|
||||
p2.mark_burned(10001)
|
||||
p2.mark_burned(10002)
|
||||
avail, total = _proxy_ports_snapshot([_Ad(p1), _Ad(p2), object()]) # 프록시 없는 어댑터 혼재 OK
|
||||
assert (avail, total) == (8, 10) # 가장 소진된 워커(p2) 기준
|
||||
|
||||
|
||||
def test_proxy_ports_snapshot_none_without_proxy():
|
||||
from worker_main import _proxy_ports_snapshot
|
||||
assert _proxy_ports_snapshot([object()]) is None
|
||||
assert _proxy_ports_snapshot(None) is None
|
||||
45
lps/tests/test_api_guard.py
Normal file
45
lps/tests/test_api_guard.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""API guard 테스트 — [WebServerConfig].api_keys 설정 시에만 /v1 에 X-API-Key 검증(개발=빈값=개방 모드).
|
||||
|
||||
auth.configured_keys 가 매 요청 config 를 읽으므로 monkeypatch.setattr 만으로 on/off 를 전환한다
|
||||
(앱 재기동 불필요).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from config.server_configs import web_server_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarded(monkeypatch):
|
||||
monkeypatch.setattr(web_server_config, "api_keys", ["k1", "k2"])
|
||||
|
||||
|
||||
async def test_open_mode_without_keys(client, monkeypatch):
|
||||
monkeypatch.setattr(web_server_config, "api_keys", [])
|
||||
r = await client.get("/v1/lps/queue/stats") # 개방 모드 — 헤더 없이 통과
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_guarded_rejects_missing_header(client, guarded):
|
||||
r = await client.get("/v1/lps/queue/stats")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_guarded_rejects_wrong_key(client, guarded):
|
||||
r = await client.get("/v1/lps/queue/stats", headers={"X-API-Key": "nope"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_guarded_accepts_any_configured_key(client, guarded):
|
||||
for key in ("k1", "k2"): # 복수 키 — 무중단 키 교체용
|
||||
r = await client.get("/v1/lps/queue/stats", headers={"X-API-Key": key})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_guard_covers_post_search(client, guarded):
|
||||
r = await client.post("/v1/lps/search", json={"data": []})
|
||||
assert r.status_code == 401 # enqueue(비용 발생 경로)도 보호
|
||||
|
||||
|
||||
async def test_healthz_stays_open(client, guarded):
|
||||
assert (await client.get("/healthz")).status_code == 200 # LB 프로브는 guard 밖
|
||||
@ -49,8 +49,9 @@ class _MockCtx:
|
||||
class _MockProxy:
|
||||
enabled = True
|
||||
session_minutes = 10
|
||||
def __init__(self): self.rotations = 0
|
||||
def __init__(self): self.rotations, self.burned = 0, []
|
||||
def rotate(self): self.rotations += 1
|
||||
def mark_burned(self, port, cooldown_sec=None): self.burned.append(port)
|
||||
def playwright_proxy(self): return None
|
||||
@property
|
||||
def current_port(self): return 10001
|
||||
@ -70,6 +71,7 @@ class _MockAdapter(BrowserSearchAdapter):
|
||||
self._ctx = _MockCtx(self._page)
|
||||
self._force_recycle = False
|
||||
self._ip_requests = 0
|
||||
self._current_port = self._proxy.current_port if self._proxy else None # 실제 _ensure_browser 와 동일
|
||||
|
||||
async def _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기
|
||||
async def _wait_ready(self, page): pass
|
||||
@ -110,11 +112,12 @@ async def test_non_proxy_goto_error_raises_no_rotation():
|
||||
|
||||
|
||||
async def test_persistent_block_exhausts_and_raises_blocked():
|
||||
# 차단 2회 연속(max_block_retries=1) → 회전 1회 후 소진 → blocked=True 로 실패
|
||||
# 차단 2회 연속(max_block_retries=1) → 재시도 회전 1회 + 소진 후 '다음 검색용' 회전 1회 → blocked=True 로 실패
|
||||
ad = _ad(["x", "x"])
|
||||
with pytest.raises(AdapterError) as ei:
|
||||
await ad.search("q")
|
||||
assert ei.value.blocked is True and ad._proxy.rotations == 1
|
||||
assert ei.value.blocked is True and ad._proxy.rotations == 2
|
||||
assert ad._proxy.burned == [10001, 10001] # 차단마다 해당 포트 쿨다운 격리
|
||||
|
||||
|
||||
# ── 라이브 스모크(옵트인): 실제 사이트 셀렉터·안티봇 드리프트 감지 ──────
|
||||
|
||||
136
lps/tests/test_ip_session.py
Normal file
136
lps/tests/test_ip_session.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""IP 선제 로테이션 테스트 — 요청 예산 판정·세션 종료 기록(브라우저 불필요) + CRUD(실 lps_db)."""
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from config.config_models import DecodoConfig
|
||||
from crud.ip_session import IpSessionLog
|
||||
from services.search.coupang.adapter import CoupangAdapter
|
||||
from services.search.proxy import DecodoProxy
|
||||
|
||||
|
||||
def _proxy():
|
||||
return DecodoProxy(DecodoConfig(host="gate.decodo.com", username="u", password="p",
|
||||
port_start=10001, port_end=10010, session_minutes=10))
|
||||
|
||||
|
||||
def _adapter(**kw):
|
||||
# __init__ 은 브라우저를 띄우지 않는다 — 예산/세션 부기 로직만 검증
|
||||
return CoupangAdapter(headless=True, user_data_dir="/tmp/lps_test_profile", **kw)
|
||||
|
||||
|
||||
# ---- 요청 예산(선제 회전) 판정 ------------------------------------------
|
||||
|
||||
def test_budget_reached_only_with_proxy():
|
||||
ad = _adapter(ip_request_budget=3)
|
||||
ad._ip_requests = 3
|
||||
assert ad._budget_reached() is False # 프록시 미사용 — 예산 개념 없음
|
||||
ad._proxy = _proxy()
|
||||
assert ad._budget_reached() is True
|
||||
|
||||
|
||||
def test_budget_zero_disables_preemptive_rotation():
|
||||
ad = _adapter(ip_request_budget=0, proxy=_proxy())
|
||||
ad._ip_requests = 999
|
||||
assert ad._budget_reached() is False
|
||||
|
||||
|
||||
def test_budget_not_reached_below_threshold():
|
||||
ad = _adapter(ip_request_budget=3, proxy=_proxy())
|
||||
ad._ip_requests = 2
|
||||
assert ad._budget_reached() is False
|
||||
|
||||
|
||||
def test_default_budget_is_conservative():
|
||||
ad = _adapter(proxy=_proxy())
|
||||
assert ad._ip_budget == 3 # 실측상 5회 부근 차단 → 기본 3회
|
||||
|
||||
|
||||
def test_rotate_ip_records_end_reason_and_recycles():
|
||||
ad = _adapter(proxy=_proxy())
|
||||
before = ad._proxy.current_port
|
||||
ad._rotate_ip("예산 도달", kind="budget", warn=False)
|
||||
assert ad._end_reason == "budget"
|
||||
assert ad._force_recycle is True
|
||||
assert ad._proxy.current_port != before # 즉시 다음 포트
|
||||
|
||||
|
||||
# ---- 세션 종료 기록 -------------------------------------------------------
|
||||
|
||||
async def test_session_end_event_shape():
|
||||
events = []
|
||||
|
||||
async def cb(e):
|
||||
events.append(e)
|
||||
|
||||
ad = _adapter(proxy=_proxy(), on_session_end=cb)
|
||||
ad._ip_requests, ad._sess_ok, ad._sess_blocked = 3, 2, 1
|
||||
ad._current_port, ad._end_reason = 10005, "budget"
|
||||
await ad._record_session_end()
|
||||
assert events == [{
|
||||
"source": "coupang", "proxy_port": 10005, "requests": 3,
|
||||
"ok_count": 2, "blocked_count": 1, "elapsed_sec": events[0]["elapsed_sec"],
|
||||
"end_reason": "budget",
|
||||
}]
|
||||
assert ad._end_reason is None # 기록 후 리셋
|
||||
|
||||
|
||||
async def test_session_end_skips_empty_session():
|
||||
events = []
|
||||
|
||||
async def cb(e):
|
||||
events.append(e)
|
||||
|
||||
ad = _adapter(on_session_end=cb)
|
||||
ad._ip_requests = 0 # 요청 없던 세션(유휴 정리 등)은 노이즈 — 미기록
|
||||
await ad._record_session_end()
|
||||
assert events == []
|
||||
|
||||
|
||||
async def test_session_end_defaults_to_window_reason():
|
||||
events = []
|
||||
|
||||
async def cb(e):
|
||||
events.append(e)
|
||||
|
||||
ad = _adapter(on_session_end=cb)
|
||||
ad._ip_requests = 2 # end_reason 미지정 = 시간창 만료 재기동
|
||||
await ad._record_session_end()
|
||||
assert events[0]["end_reason"] == "window"
|
||||
|
||||
|
||||
async def test_session_end_callback_failure_is_swallowed():
|
||||
async def boom(e):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
ad = _adapter(on_session_end=boom)
|
||||
ad._ip_requests = 1
|
||||
await ad._record_session_end() # 예외가 검색을 막지 않는다
|
||||
|
||||
|
||||
# ---- CRUD (실 lps_db) ----------------------------------------------------
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ip_log(db_engine):
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text("TRUNCATE ip_session"))
|
||||
return IpSessionLog()
|
||||
|
||||
|
||||
async def test_record_persists_session(ip_log, db_engine):
|
||||
await ip_log.record({"source": "coupang", "proxy_port": 10003, "requests": 3,
|
||||
"ok_count": 3, "blocked_count": 0, "elapsed_sec": 120, "end_reason": "budget"})
|
||||
async with db_engine.begin() as conn:
|
||||
row = (await conn.execute(text(
|
||||
"SELECT source, proxy_port, requests, ok_count, end_reason FROM ip_session"
|
||||
))).first()
|
||||
assert row.source == "coupang" and row.proxy_port == 10003
|
||||
assert row.requests == 3 and row.ok_count == 3 and row.end_reason == "budget"
|
||||
|
||||
|
||||
async def test_recent_stats_groups_by_reason(ip_log, db_engine):
|
||||
for reason in ("budget", "budget", "block"):
|
||||
await ip_log.record({"source": "coupang", "requests": 3, "ok_count": 3,
|
||||
"blocked_count": 0, "end_reason": reason})
|
||||
stats = await ip_log.recent_stats(60)
|
||||
assert stats == {"budget": 2, "block": 1}
|
||||
@ -91,3 +91,26 @@ def test_backoff_is_exponential_capped():
|
||||
assert compute_backoff(2, base=5) == 10
|
||||
assert compute_backoff(3, base=5) == 20
|
||||
assert compute_backoff(100, base=5, cap=600) == 600
|
||||
|
||||
|
||||
async def test_ops_counts_deadline_and_cost(q):
|
||||
# 완료 잡 2건의 검색원가 합산 — claim 반환 잡을 완료(순서 의존 제거)
|
||||
for code, cost in (("b", 0.01), ("c", 0.02)):
|
||||
await q.enqueue(JobType.SEARCH.value, {"q": code})
|
||||
job = await q.claim("w1")
|
||||
await q.complete(job["job_id"], "w1", {"metrics": {"cost": {"total_usd": cost}}})
|
||||
# 데드라인 강제종료 — 재큐(PENDING)로 살아나도 deadline_1h 에 잡혀야 한다(dead 와 별개 축)
|
||||
j1 = await q.enqueue(JobType.SEARCH.value, {"q": "a"})
|
||||
await q.claim("w1")
|
||||
await q.fail(j1, "w1", "JobDeadlineExceeded: 300s", backoff_sec=0)
|
||||
snap = await q.ops()
|
||||
assert snap["deadline_1h"] == 1
|
||||
assert abs(snap["cost_1h_usd"] - 0.03) < 1e-9
|
||||
|
||||
|
||||
async def test_ops_cost_ignores_jobs_without_metrics(q):
|
||||
jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"})
|
||||
await q.claim("w1")
|
||||
await q.complete(jid, "w1", {"count": 3}) # metrics 없는 결과 — 합산에서 무시(0)
|
||||
snap = await q.ops()
|
||||
assert snap["cost_1h_usd"] == 0
|
||||
|
||||
@ -89,6 +89,8 @@ async def test_ops_snapshot(client, clean_jobs):
|
||||
r = await client.get("/v1/lps/ops")
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
for k in ("pending", "running", "done", "dead", "dead_1h", "stuck_running", "oldest_pending_sec", "blocks_1h"):
|
||||
for k in ("pending", "running", "done", "dead", "dead_1h", "stuck_running", "oldest_pending_sec",
|
||||
"blocks_1h", "deadline_1h", "pool_pct"):
|
||||
assert k in j and isinstance(j[k], int)
|
||||
assert isinstance(j["cost_1h_usd"], (int, float))
|
||||
assert j["pending"] == 1
|
||||
|
||||
@ -50,3 +50,38 @@ def test_rotate_advances_port_immediately():
|
||||
assert 10001 <= after <= 10003
|
||||
p.rotate(); p.rotate() # 3번 회전하면 한 바퀴 → 원위치
|
||||
assert p._port() == before
|
||||
|
||||
|
||||
def test_burned_port_is_skipped():
|
||||
p = _p(port_start=10001, port_end=10003)
|
||||
burned = p._port()
|
||||
p.mark_burned(burned)
|
||||
assert p._port() != burned # 쿨다운 중인 포트는 건너뜀
|
||||
assert p.available_ports() == 2
|
||||
|
||||
|
||||
def test_burned_port_returns_after_cooldown():
|
||||
p = _p(port_start=10001, port_end=10003)
|
||||
burned = p._port()
|
||||
p.mark_burned(burned, cooldown_sec=0) # 즉시 만료
|
||||
assert p._port() == burned # 만료 후엔 다시 사용 가능
|
||||
assert p.available_ports() == 3
|
||||
|
||||
|
||||
def test_all_burned_falls_back_to_earliest_expiry():
|
||||
p = _p(port_start=10001, port_end=10002) # 포트 2개
|
||||
first, second = 10001, 10002
|
||||
p.mark_burned(first, cooldown_sec=10) # 먼저 만료
|
||||
p.mark_burned(second, cooldown_sec=999)
|
||||
assert p._port() == first # 전 포트 쿨다운 — 만료 임박 포트 사용(가용성 우선)
|
||||
|
||||
|
||||
def test_mark_burned_ignores_none_port():
|
||||
p = _p()
|
||||
p.mark_burned(None) # 프록시 미사용 세션 — no-op
|
||||
assert p.available_ports() == 10
|
||||
|
||||
|
||||
def test_cooldown_default_at_least_30min():
|
||||
assert _p(session_minutes=10).cooldown_sec == 1800 # max(sticky 600, 1800)
|
||||
assert _p(session_minutes=60).cooldown_sec == 3600 # sticky 가 더 길면 sticky 만큼
|
||||
|
||||
@ -10,13 +10,14 @@ import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from common.alerts import AlertManager
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
from config.server_configs import web_server_config, openai_config, decodo_config
|
||||
from config.server_configs import web_server_config, openai_config, decodo_config, worker_config, alert_config
|
||||
from crud.job_crud import JobQueue
|
||||
from crud.negative_cache import NegativeCache
|
||||
from crud.bot_detection import BotDetectionLog
|
||||
from crud.ip_session import IpSessionLog
|
||||
from crud.price_history import PriceHistory
|
||||
from services.search.proxy import DecodoProxy
|
||||
from services.search.coupang.adapter import CoupangAdapter
|
||||
@ -34,16 +35,16 @@ LOG.SetPrefix(f"{web_server_config.server_name}-worker")
|
||||
# 오픈마켓 폴백(G마켓·옥션·11번가)은 **기본 비활성** — 2026-07-10 협의 결정.
|
||||
# 실측상 크롤 몰이 최종 최저가를 바꾼 적이 없고(0회), 검색당 최대 15s + 프록시 대역폭의
|
||||
# 대부분을 차지해 로직에서 제외했다(코드·테스트는 유지, 핸들러는 빈 폴백을 정상 처리).
|
||||
# 재가동: LPS_FALLBACKS=gmarket,auction,st11 (일부만도 가능) — 켜기 전 라이브 스모크로
|
||||
# 셀렉터 드리프트 점검. 배경은 docs/decision-openmarket-crawler.md.
|
||||
# 재가동: [WorkerConfig].fallbacks = ["gmarket", "auction", "st11"] (일부만도 가능) —
|
||||
# 켜기 전 라이브 스모크로 셀렉터 드리프트 점검. 배경은 docs/decision-openmarket-crawler.md.
|
||||
_FALLBACK_SOURCES = ("gmarket", "auction", "st11")
|
||||
|
||||
|
||||
def _enabled_fallbacks() -> list[str]:
|
||||
names = [s.strip() for s in os.environ.get("LPS_FALLBACKS", "").split(",") if s.strip()]
|
||||
names = [s.strip() for s in worker_config.fallbacks if s.strip()]
|
||||
unknown = [n for n in names if n not in _FALLBACK_SOURCES]
|
||||
if unknown:
|
||||
LOG.w(f"LPS_FALLBACKS 무시된 값: {unknown} (가능: {list(_FALLBACK_SOURCES)})")
|
||||
LOG.w(f"[WorkerConfig].fallbacks 무시된 값: {unknown} (가능: {list(_FALLBACK_SOURCES)})")
|
||||
return [n for n in names if n in _FALLBACK_SOURCES]
|
||||
|
||||
|
||||
@ -56,15 +57,16 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history
|
||||
n = proxy.port_end - proxy.port_start + 1
|
||||
proxy.seed_offset(i * max(1, n // concurrency))
|
||||
bot_log = BotDetectionLog()
|
||||
ip_log = IpSessionLog()
|
||||
suffix = f"_w{i}" if concurrency > 1 else ""
|
||||
|
||||
def _pf(source): # 워커별 Chrome 프로필 경로(중복 실행 시 ProcessSingleton 충돌 방지)
|
||||
# LPS_PROFILE_DIR 를 영속 볼륨으로 마운트하면 재시작해도 cf_clearance 등 쿠키 유지(재웜업 회피).
|
||||
base = os.environ.get("LPS_PROFILE_DIR", "/tmp")
|
||||
return f"{base}/lps_{source}{suffix}"
|
||||
# [WorkerConfig].profile_dir 를 영속 볼륨으로 두면 재시작해도 cf_clearance 등 쿠키 유지(재웜업 회피).
|
||||
return f"{worker_config.profile_dir}/lps_{source}{suffix}"
|
||||
|
||||
adapters = {
|
||||
"coupang": CoupangAdapter(headless=False, user_data_dir=_pf("coupang"), proxy=proxy, on_detect=bot_log.record),
|
||||
"coupang": CoupangAdapter(headless=False, user_data_dir=_pf("coupang"), proxy=proxy,
|
||||
on_detect=bot_log.record, on_session_end=ip_log.record),
|
||||
"naver": NaverAdapter(), # httpx 직접(프록시 미경유) — 워커별 인스턴스(last_bytes 경합 회피)
|
||||
}
|
||||
# 폴백은 기본 비활성(LPS_FALLBACKS 로 켬 — 상단 주석 참고). 켤 땐 데드라인이 상한이라
|
||||
@ -72,9 +74,11 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history
|
||||
fallback_adapters = {}
|
||||
for name in _enabled_fallbacks():
|
||||
if name == "st11":
|
||||
fallback_adapters[name] = ElevenStAdapter(headless=False, user_data_dir=_pf("st11"), proxy=proxy, on_detect=bot_log.record, max_block_retries=0)
|
||||
fallback_adapters[name] = ElevenStAdapter(headless=False, user_data_dir=_pf("st11"), proxy=proxy,
|
||||
on_detect=bot_log.record, on_session_end=ip_log.record, max_block_retries=0)
|
||||
else:
|
||||
fallback_adapters[name] = EsmAdapter(name, headless=False, user_data_dir=_pf(name), proxy=proxy, on_detect=bot_log.record, max_block_retries=0)
|
||||
fallback_adapters[name] = EsmAdapter(name, headless=False, user_data_dir=_pf(name), proxy=proxy,
|
||||
on_detect=bot_log.record, on_session_end=ip_log.record, max_block_retries=0)
|
||||
# AI 도 워커별 인스턴스 — 공유 상태(last_usage) 경합 원천 제거
|
||||
judge = SimilarityJudge() if has_openai else None
|
||||
keyword_gen = KeywordGenerator() if has_openai else None
|
||||
@ -109,23 +113,29 @@ async def _warmup_worker(worker_adapters, tries: int = 3, attempt_timeout: float
|
||||
LOG.w(f"[warmup:{ad.source}] {tries}회 실패(첫 잡에서 재시도): {type(ex).__name__}")
|
||||
|
||||
|
||||
async def _post_webhook(url: str, text: str, snap: dict):
|
||||
"""Slack 호환 웹훅으로 알림 전송(있을 때만). 실패는 무시."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
await c.post(url, json={"text": f":rotating_light: LPS {text}\n```{snap}```"})
|
||||
except Exception:
|
||||
pass
|
||||
def _proxy_ports_snapshot(adapters) -> tuple[int, int] | None:
|
||||
"""워커 프록시들의 가용 포트 현황 — (최소 가용 수, 전체 포트 수). 프록시 미사용이면 None.
|
||||
쿨다운 맵은 프록시 인스턴스(워커)별이라 가장 소진된 워커 기준(min)으로 본다."""
|
||||
proxies = {}
|
||||
for ad in (adapters or []):
|
||||
p = getattr(ad, "_proxy", None)
|
||||
if p is not None and p.enabled:
|
||||
proxies[id(p)] = p
|
||||
if not proxies:
|
||||
return None
|
||||
any_p = next(iter(proxies.values()))
|
||||
total = any_p.port_end - any_p.port_start + 1
|
||||
return min(p.available_ports() for p in proxies.values()), total
|
||||
|
||||
|
||||
async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0):
|
||||
async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters=None, alerts=None, ip_log=None):
|
||||
"""워커 헬스 하트비트 + 임계 알림. 주기적으로 (1) 하트비트 파일 갱신(Docker HEALTHCHECK 가
|
||||
행/좀비 워커 감지) (2) 큐/차단 지표 점검 → 임계 초과 시 WARN 로그 + (env 있으면) 웹훅 알림."""
|
||||
hb_path = os.environ.get("LPS_HEARTBEAT_FILE", "/tmp/lps_worker_heartbeat")
|
||||
webhook = os.environ.get("LPS_ALERT_WEBHOOK")
|
||||
th_dead = int(os.environ.get("LPS_ALERT_DEAD_1H", "20"))
|
||||
th_blocks = int(os.environ.get("LPS_ALERT_BLOCKS_1H", "80"))
|
||||
th_lag = int(os.environ.get("LPS_ALERT_QUEUE_LAG_SEC", "300"))
|
||||
행/좀비 워커 감지) (2) 큐/차단/DB풀/소스별 실패 지표 점검 → AlertManager 로 발화
|
||||
(룰별 쿨다운으로 스팸 방지, 조건 해소 시 회복 알림)."""
|
||||
hb_path = worker_config.heartbeat_file
|
||||
th = alert_config # 임계값은 [AlertConfig] 섹션이 소스(docs/operations.md 표)
|
||||
alerts = alerts or AlertManager(origin="worker")
|
||||
ip_log = ip_log or IpSessionLog()
|
||||
while not stop.is_set():
|
||||
try:
|
||||
with open(hb_path, "w") as f:
|
||||
@ -135,16 +145,40 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0):
|
||||
try:
|
||||
snap = await queue.ops()
|
||||
snap["blocks_1h"] = await bot_log.recent_count(60)
|
||||
alerts = []
|
||||
if snap["dead_1h"] >= th_dead: alerts.append(f"DEAD 1h={snap['dead_1h']}")
|
||||
if snap["blocks_1h"] >= th_blocks: alerts.append(f"차단 1h={snap['blocks_1h']}")
|
||||
if snap["oldest_pending_sec"] >= th_lag: alerts.append(f"큐지연={snap['oldest_pending_sec']}s")
|
||||
if snap["stuck_running"] > 0: alerts.append(f"stuck={snap['stuck_running']}")
|
||||
if alerts:
|
||||
msg = "[ops-alert] " + " · ".join(alerts)
|
||||
LOG.w(msg)
|
||||
if webhook:
|
||||
await _post_webhook(webhook, msg, snap)
|
||||
pool = DB_SESSION_MNG.pool_status()
|
||||
snap["pool_pct"] = pool["pct"]
|
||||
await alerts.check("dead", snap["dead_1h"] >= th.dead_1h, f"DEAD 1h={snap['dead_1h']}", snap)
|
||||
await alerts.check("blocks", snap["blocks_1h"] >= th.blocks_1h, f"차단 1h={snap['blocks_1h']}", snap)
|
||||
await alerts.check("queue_lag", snap["oldest_pending_sec"] >= th.queue_lag_sec, f"큐지연={snap['oldest_pending_sec']}s", snap)
|
||||
await alerts.check("stuck", snap["stuck_running"] > 0, f"stuck={snap['stuck_running']}", snap)
|
||||
await alerts.check("db_pool", pool["pct"] >= th.pool_pct,
|
||||
f"DB 풀 포화 {pool['pct']}% (checked_out {pool['checked_out']}/{pool['capacity']})", snap)
|
||||
await alerts.check("deadline", snap["deadline_1h"] >= th.deadline_1h,
|
||||
f"잡 데드라인 강제종료 1h={snap['deadline_1h']} — 크롤 행 반복 신호", snap)
|
||||
await alerts.check("cost", snap["cost_1h_usd"] >= th.cost_1h_usd,
|
||||
f"검색원가 1h=${snap['cost_1h_usd']} — 비용 폭주(리소스차단 풀림·재시도 루프) 점검", snap)
|
||||
# 가용 프록시 포트 고갈 — 쿨다운 격리 누적. blocks_1h 보다 먼저 우는 대규모 차단 조기 신호.
|
||||
ports = _proxy_ports_snapshot(adapters)
|
||||
if ports:
|
||||
avail, total = ports
|
||||
snap["proxy_ports_avail"], snap["proxy_ports_total"] = avail, total
|
||||
await alerts.check("proxy_ports_low", avail * 100 <= total * th.ports_low_pct,
|
||||
f"가용 프록시 포트 {avail}/{total} — 대규모 차단 진행 신호", snap)
|
||||
# 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생 = 현재 예산이 안전하지 않다는 신호.
|
||||
block_sessions = (await ip_log.recent_stats(360)).get("block", 0)
|
||||
snap["block_sessions_6h"] = block_sessions
|
||||
await alerts.check("budget_leak", block_sessions >= th.block_sessions_6h,
|
||||
f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — ip_request_budget 하향 검토", snap)
|
||||
# 소스별 장기 실패 — 최근 30분간 시도는 있는데 성공이 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호)
|
||||
per_source: dict[str, list[int]] = {}
|
||||
for ad in (adapters or []):
|
||||
tries, ok = ad.recent_stats(1800)
|
||||
agg = per_source.setdefault(ad.source, [0, 0])
|
||||
agg[0] += tries
|
||||
agg[1] += ok
|
||||
for src, (tries, ok) in per_source.items():
|
||||
await alerts.check(f"source_fail:{src}", tries >= th.source_fail_30m and ok == 0,
|
||||
f"{src} 최근 30분 {tries}회 시도·성공 0건", snap)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(f"[ops-monitor] {type(ex).__name__}: {ex}")
|
||||
try:
|
||||
@ -218,7 +252,7 @@ async def main(concurrency: int = 1):
|
||||
loop.add_signal_handler(sig, _request_stop, sig.name)
|
||||
|
||||
# 잡 1건 데드라인 — 정상 검색은 폴백 포함 수분 내 끝난다(실측 15~22s). 크롤 행 실측(15분) 대비 상한.
|
||||
job_deadline = float(os.environ.get("LPS_JOB_DEADLINE_SEC", "300"))
|
||||
job_deadline = worker_config.job_deadline_sec
|
||||
for i in range(concurrency):
|
||||
handler, worker_adapters = _build_worker(i, concurrency, has_openai, neg_cache, history)
|
||||
all_adapters += worker_adapters
|
||||
@ -231,12 +265,12 @@ async def main(concurrency: int = 1):
|
||||
|
||||
tasks.append(asyncio.create_task(run_reaper(queue, stop)))
|
||||
tasks.append(asyncio.create_task(run_browser_reaper(all_adapters, stop))) # 유휴 브라우저 정리
|
||||
tasks.append(asyncio.create_task(run_ops_monitor(queue, BotDetectionLog(), stop))) # 하트비트 + 임계 알림
|
||||
tasks.append(asyncio.create_task(run_ops_monitor(queue, BotDetectionLog(), stop, adapters=all_adapters))) # 하트비트 + 임계 알림
|
||||
LOG.i(f"LPS 워커 {concurrency}개 + reaper + 브라우저정리 + ops모니터(하트비트/알림) 기동 (워커별 세트 · 상품 {concurrency}개 동시)")
|
||||
|
||||
# 종료 유예: stop 후 하던 잡이 이 시간 안에 끝나면 자연 종료, 초과하면 강제 취소.
|
||||
# docker stop 을 쓰면 compose 의 stop_grace_period 를 이보다 길게 잡아야 SIGKILL 전에 마무리된다.
|
||||
grace = float(os.environ.get("LPS_SHUTDOWN_GRACE_SEC", "60"))
|
||||
grace = worker_config.shutdown_grace_sec
|
||||
gathered = asyncio.gather(*tasks)
|
||||
stop_waiter = asyncio.create_task(stop.wait())
|
||||
try:
|
||||
@ -273,4 +307,7 @@ async def main(concurrency: int = 1):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(int(os.environ.get("WORKER_CONCURRENCY", "1"))))
|
||||
# 동시성은 [WorkerConfig].concurrency 가 소스 — WORKER_CONCURRENCY env 는 실행 스크립트의
|
||||
# 대화형 입력 전용 임시 override(설정 관리는 toml 하나로, 2026-07-13 협의).
|
||||
_conc = int(os.environ.get("WORKER_CONCURRENCY", "0")) or worker_config.concurrency
|
||||
asyncio.run(main(_conc))
|
||||
|
||||
@ -132,6 +132,8 @@ class item_internet_lowest_prices(MainTableMixin, MAIN_BASE):
|
||||
fail_reason = Column(String(100), nullable=True) # 실패 사유(예: not_found)
|
||||
ai_model = Column(SmallInteger, nullable=True) # (예약) AI 모델 코드 — LPS 계약엔 미포함
|
||||
crawl_duration_ms = Column(Integer, nullable=True) # (예약) 수집 소요 — LPS 계약엔 미포함
|
||||
lp_name = Column(String(300), nullable=True) # 찾은 상품명(판매 페이지 기준) — 근거 검증용
|
||||
lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용
|
||||
crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준)
|
||||
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ class WebServerConfig(ConfigModel):
|
||||
nego_chat_url: str = "http://localhost:3300"
|
||||
agent_base_url: str = "http://localhost:9500" # 협상 agent(9500). 공용 카탈로그 변경 알림용.
|
||||
lps_base_url: str = "http://localhost:9600" # 인터넷 최저가 검색 LPS(9600). 검색요청 enqueue 용.
|
||||
lps_api_key: str = "" # LPS API guard 키 — LPS 쪽 [WebServerConfig].api_keys 와 동일 값. 빈값=헤더 미첨부(개발)
|
||||
|
||||
|
||||
class LogConfig(ConfigModel):
|
||||
|
||||
@ -63,3 +63,6 @@ _apply_lps_db_env_override(lps_db_config)
|
||||
# LPS API 주소 env override (도커: http://lps-api:9600 또는 host.docker.internal:9600)
|
||||
if os.environ.get("LPS_BASE_URL"):
|
||||
web_server_config.lps_base_url = os.environ["LPS_BASE_URL"]
|
||||
# LPS API guard 키 (LPS 는 toml 단일 관리로 전환 — negodata 쪽은 기존 관례대로 toml+env override 유지)
|
||||
if os.environ.get("LPS_API_KEY"):
|
||||
web_server_config.lps_api_key = os.environ["LPS_API_KEY"]
|
||||
|
||||
@ -25,6 +25,10 @@ _price_history = table(
|
||||
column("outcome"), # found | not_found
|
||||
column("final_lowest"), # 전체 최저가(원)
|
||||
column("final_source"), # naver | coupang | (폴백몰)
|
||||
column("naver_name"), # 네이버 최저가 상품명/링크 — final_source 에 맞는 출처를 실어온다
|
||||
column("naver_url"),
|
||||
column("coupang_name"), # 쿠팡 최저가 상품명/링크
|
||||
column("coupang_url"),
|
||||
column("created_at"),
|
||||
)
|
||||
|
||||
@ -108,6 +112,10 @@ class LpsSyncCRUD(ILpsSyncCRUD):
|
||||
_price_history.c.outcome,
|
||||
_price_history.c.final_lowest,
|
||||
_price_history.c.final_source,
|
||||
_price_history.c.naver_name,
|
||||
_price_history.c.naver_url,
|
||||
_price_history.c.coupang_name,
|
||||
_price_history.c.coupang_url,
|
||||
_price_history.c.created_at,
|
||||
).order_by(_price_history.c.created_at.asc())
|
||||
if since is not None:
|
||||
|
||||
@ -143,6 +143,8 @@ class LowestPriceEntry(WebPacketProtocol):
|
||||
website: int = 0 # LowestPriceWebsite 코드(1=naver 2=coupang …)
|
||||
success_yn: bool = False
|
||||
fail_reason: Optional[str] = None
|
||||
lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용
|
||||
lp_url: Optional[str] = None # 찾은 판매 페이지 링크
|
||||
crawl_end_time: Optional[datetime] = None
|
||||
|
||||
|
||||
|
||||
@ -72,9 +72,11 @@ class LpsSyncService:
|
||||
"price": str(item.price) if item.price else "",
|
||||
}
|
||||
base = web_server_config.lps_base_url.rstrip("/")
|
||||
# LPS API guard: prod 는 lps_api_key 를 채워 X-API-Key 로 인증(개발은 빈값=개방 모드).
|
||||
headers = {"X-API-Key": web_server_config.lps_api_key} if web_server_config.lps_api_key else None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.post(f"{base}/v1/lps/search", json={"data": [payload]})
|
||||
r = await client.post(f"{base}/v1/lps/search", json={"data": [payload]}, headers=headers)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
entry = (body.get("items") or [{}])[0]
|
||||
@ -129,11 +131,18 @@ class LpsSyncService:
|
||||
|
||||
# product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵
|
||||
parsed = []
|
||||
for code, outcome, final_lowest, final_source, created_at in rows:
|
||||
for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, created_at in rows:
|
||||
try:
|
||||
parsed.append((uuid.UUID(code), outcome, final_lowest, final_source, created_at))
|
||||
iid = uuid.UUID(code)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
results["skipped_not_uuid"] += 1
|
||||
continue
|
||||
# 출처(찾은 상품명·링크) — 최종 최저가를 낸 소스의 것을 싣는다(근거 검증용)
|
||||
src_name, src_url = {
|
||||
"naver": (nv_name, nv_url),
|
||||
"coupang": (cp_name, cp_url),
|
||||
}.get((final_source or "").lower(), (None, None))
|
||||
parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, created_at))
|
||||
|
||||
err, existing = await DB_SESSION_MNG.execute_lambda(
|
||||
DBType.MAIN.value, DBWRType.DB_READ.value,
|
||||
@ -143,7 +152,7 @@ class LpsSyncService:
|
||||
return results
|
||||
|
||||
history_rows, latest_found = [], {} # latest_found: item_id → (created_at, price)
|
||||
for item_id, outcome, final_lowest, final_source, created_at in parsed:
|
||||
for item_id, outcome, final_lowest, final_source, src_name, src_url, created_at in parsed:
|
||||
if item_id not in existing:
|
||||
results["skipped_unknown_item"] += 1
|
||||
continue
|
||||
@ -154,6 +163,8 @@ class LpsSyncService:
|
||||
website=LowestPriceWebsite.from_source(final_source).value,
|
||||
success_yn=found,
|
||||
fail_reason=None if found else (outcome or "unknown")[:100],
|
||||
lp_name=(src_name or None) and src_name[:300],
|
||||
lp_url=src_url or None,
|
||||
crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존
|
||||
))
|
||||
results["found" if found else "not_found"] += 1
|
||||
|
||||
@ -86,7 +86,9 @@ export * from './listUsersParams';
|
||||
export * from './lowestPriceEntry';
|
||||
export * from './lowestPriceEntryCrawlEndTime';
|
||||
export * from './lowestPriceEntryFailReason';
|
||||
export * from './lowestPriceEntryLpName';
|
||||
export * from './lowestPriceEntryLpPrice';
|
||||
export * from './lowestPriceEntryLpUrl';
|
||||
export * from './notificationData';
|
||||
export * from './notificationDataCreatedAt';
|
||||
export * from './notificationDataData';
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
*/
|
||||
import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice';
|
||||
import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason';
|
||||
import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName';
|
||||
import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl';
|
||||
import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime';
|
||||
|
||||
/**
|
||||
@ -16,5 +18,7 @@ export interface LowestPriceEntry {
|
||||
website?: number;
|
||||
success_yn?: boolean;
|
||||
fail_reason?: LowestPriceEntryFailReason;
|
||||
lp_name?: LowestPriceEntryLpName;
|
||||
lp_url?: LowestPriceEntryLpUrl;
|
||||
crawl_end_time?: LowestPriceEntryCrawlEndTime;
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type LowestPriceEntryLpName = string | null;
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type LowestPriceEntryLpUrl = string | null;
|
||||
@ -0,0 +1,232 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChartSpline, ExternalLink, Loader2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { useGetLowestPrice } from '@/api/generated/item/item';
|
||||
import type { LowestPriceEntry } from '@/api/generated/model';
|
||||
import type { Product } from '../types';
|
||||
|
||||
type LowestPriceHistorySheetProps = {
|
||||
product: Product;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// LowestPriceWebsite 코드(백엔드 common/enums.py) → 표시 라벨
|
||||
const WEBSITE_LABEL: Record<number, string> = {
|
||||
1: '네이버',
|
||||
2: '쿠팡',
|
||||
3: 'G마켓',
|
||||
4: '옥션',
|
||||
5: '11번가',
|
||||
99: '기타',
|
||||
};
|
||||
const websiteLabel = (code?: number) => WEBSITE_LABEL[code ?? 99] ?? '기타';
|
||||
|
||||
// 단일 시리즈(인터넷 최저가) — 가격 UI 컨벤션인 rose 를 라이트/다크 쌍으로(통계 팔레트와 동일 문법).
|
||||
const chartConfig = {
|
||||
price: { label: '인터넷 최저가', theme: { light: '#f43f5e', dark: '#fb7185' } },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const won = (n: number) => `₩${Math.round(n).toLocaleString()}`;
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
/** 견적 상세 드로어와 같은 문법의 섹션 카드 — 11px 볼드 타이틀 + 하단 구분선. */
|
||||
function SectionCard({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
|
||||
<div className="flex items-center justify-between border-b border-border pb-1">
|
||||
<span className="font-bold text-foreground text-[11px] font-sans">{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력.
|
||||
// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다.
|
||||
export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistorySheetProps) {
|
||||
// GET 이 서버에서 lps_db 증분 동기화를 겸하므로, 열 때마다 최신 상태가 온다.
|
||||
const { data, isLoading } = useGetLowestPrice(product.item_id);
|
||||
|
||||
const entries = data?.results ?? [];
|
||||
const successes = entries.filter((e): e is LowestPriceEntry & { lp_price: number } => !!e.success_yn && e.lp_price != null);
|
||||
const latest = successes[0]; // API 가 최신순으로 준다
|
||||
const representative = data?.lowest_price ?? product.internet_lowest_price ?? latest?.lp_price ?? null;
|
||||
const diff = product.price != null && representative != null ? Number(representative) - product.price : null;
|
||||
|
||||
// 그래프는 시간 오름차순(성공 수집만). 점 1개는 추이가 아니므로 2건부터 그린다.
|
||||
const chartData = [...successes]
|
||||
.reverse()
|
||||
.map((e) => ({ t: e.crawl_end_time ?? '', price: e.lp_price }));
|
||||
|
||||
return (
|
||||
<Sheet open title="인터넷 최저가 상세" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
{/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */}
|
||||
<div className="rounded border border-border/80 bg-muted/25 p-3">
|
||||
<Typography as="p" variant="caption" className="font-medium">{product.name}</Typography>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<span className="font-mono text-[26px] leading-none font-bold text-rose-600 dark:text-rose-400">
|
||||
{representative != null ? won(Number(representative)) : '수집 전'}
|
||||
</span>
|
||||
{diff != null && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
diff <= 0
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{diff <= 0 ? <TrendingDown size={12} /> : <TrendingUp size={12} />}
|
||||
단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Typography as="p" variant="caption" className="mt-1.5">
|
||||
상품 단가 {product.price != null ? won(product.price) : '-'}
|
||||
{latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}</>}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */}
|
||||
{latest && (
|
||||
<SectionCard
|
||||
title="최저가 출처"
|
||||
action={<Badge variant="secondary" className="text-[10px]">{websiteLabel(latest.website)}</Badge>}
|
||||
>
|
||||
{latest.lp_name ? (
|
||||
<Typography as="p" variant="small" className="text-[12.5px] leading-snug">{latest.lp_name}</Typography>
|
||||
) : (
|
||||
<Typography as="p" variant="caption">출처 상세 미수집(이전 버전 수집분)</Typography>
|
||||
)}
|
||||
{latest.lp_url && (
|
||||
<a
|
||||
href={latest.lp_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-[11px] font-medium text-foreground transition-colors hover:border-rose-400/60 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400"
|
||||
>
|
||||
판매 페이지에서 확인 <ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 가격 추이 ── */}
|
||||
<SectionCard
|
||||
title="가격 추이"
|
||||
action={
|
||||
successes.length > 0 ? (
|
||||
<Typography variant="caption">{successes.length}회 수집</Typography>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{chartData.length >= 2 ? (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 12, left: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={timeLabel} fontSize={10} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={52}
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={(v) => Number(v).toLocaleString()}
|
||||
fontSize={10}
|
||||
/>
|
||||
<ChartTooltip cursor={{ strokeDasharray: '3 3' }} content={<PriceTooltip />} />
|
||||
<Line
|
||||
dataKey="price"
|
||||
type="monotone"
|
||||
stroke="var(--color-price)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: 'var(--color-price)', strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 py-3 text-muted-foreground">
|
||||
<ChartSpline size={14} className="shrink-0" />
|
||||
<Typography variant="caption">
|
||||
{successes.length === 1
|
||||
? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.'
|
||||
: '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 수집 이력 ── */}
|
||||
<SectionCard title="최근 수집 이력">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<Typography variant="caption">이력을 불러오는 중...</Typography>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="space-y-1 rounded border border-dashed border-border bg-muted/10 py-6 text-center">
|
||||
<TrendingDown size={18} className="mx-auto text-muted-foreground/60" />
|
||||
<Typography as="p" variant="caption">
|
||||
수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요.
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{entries.map((e, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-2 py-2 first:pt-0.5 last:pb-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="w-14 shrink-0 justify-center text-[10px]">
|
||||
{websiteLabel(e.website)}
|
||||
</Badge>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2.5 font-mono text-[12px]">
|
||||
{e.success_yn && e.lp_price != null ? (
|
||||
<span className="font-semibold text-rose-600 dark:text-rose-400">{won(e.lp_price)}</span>
|
||||
) : (
|
||||
<span className="text-amber-600 dark:text-amber-400">미발견</span>
|
||||
)}
|
||||
{e.lp_url ? (
|
||||
<a
|
||||
href={e.lp_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-colors hover:text-rose-600 dark:hover:text-rose-400"
|
||||
title="판매 페이지 열기"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="w-3" /> /* 링크 없는 행도 가격 우측 정렬 유지 */
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// 툴팁 — 시각 + 가격(텍스트 토큰, 시리즈색은 마크에만)
|
||||
function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">{timeLabel(p.t)}</Typography>
|
||||
<Typography as="p" variant="caption" className="font-mono text-foreground">{won(p.price)}</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { Image as ImageIcon } from 'lucide-react';
|
||||
import { ChartSpline, Image as ImageIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTable, type Column } from '@/components/ui/data-table';
|
||||
import { TablePagination } from '@/components/ui/table-pagination';
|
||||
@ -10,6 +10,7 @@ type ProductTableProps = {
|
||||
selectedIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onRowClick: (prod: Product) => void;
|
||||
onLowestPriceClick: (prod: Product) => void; // 인터넷 최저가 셀 클릭 → 출처·이력 시트
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
@ -24,6 +25,7 @@ export function ProductTable({
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
onRowClick,
|
||||
onLowestPriceClick,
|
||||
page,
|
||||
totalPages,
|
||||
totalCount,
|
||||
@ -114,7 +116,24 @@ export function ProductTable({
|
||||
header: label('item.internet_lowest_price'),
|
||||
align: 'right',
|
||||
cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400',
|
||||
cell: (prod) => (prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'),
|
||||
// 값 + 상세보기 버튼(항상 노출) → 출처(사이트·링크)·가격 추이 시트. 행 클릭과 전파 차단.
|
||||
cell: (prod) => (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span>{prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLowestPriceClick(prod);
|
||||
}}
|
||||
className="cursor-pointer shrink-0 rounded border border-border/70 p-1 text-muted-foreground transition-colors hover:border-rose-400/60 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400"
|
||||
title="출처·가격 추이 상세보기"
|
||||
aria-label="인터넷 최저가 상세보기"
|
||||
>
|
||||
<ChartSpline size={13} />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: label('creator'),
|
||||
|
||||
@ -16,6 +16,7 @@ import type { ListItemsParams } from '@/api/generated/model/listItemsParams';
|
||||
import { ProductTable } from '@/features/products/components/ProductTable';
|
||||
import { ProductFormSheet } from '@/features/products/components/ProductFormSheet';
|
||||
import { PriceUpdateModal } from '@/features/products/components/PriceUpdateModal';
|
||||
import { LowestPriceHistorySheet } from '@/features/products/components/LowestPriceHistorySheet';
|
||||
import { ExcelUploadModal, downloadProductTemplate } from '@/features/products/components/ExcelUploadModal';
|
||||
import { type Product } from '@/features/products/types';
|
||||
|
||||
@ -51,13 +52,16 @@ export default function ProductsPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
// 딥링크·뒤로가기·새로고침 지원
|
||||
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
|
||||
const overlay = useOverlayRouter(['new', 'detail', 'modal', 'lowest']);
|
||||
const editId = overlay.get('detail');
|
||||
const modal = overlay.get('modal'); // 'price' | 'excel' | null
|
||||
const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null;
|
||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||
const isFormOpen = overlay.has('new') || !!editing;
|
||||
|
||||
const lowestId = overlay.get('lowest');
|
||||
const lowestProduct = products.find((prod) => prod.item_id === lowestId) ?? null;
|
||||
|
||||
const openCreate = () => overlay.open('new');
|
||||
const openEdit = (prod: Product) => overlay.open('detail', prod.item_id);
|
||||
|
||||
@ -175,6 +179,7 @@ export default function ProductsPage() {
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
onRowClick={openEdit}
|
||||
onLowestPriceClick={(prod) => overlay.open('lowest', prod.item_id)}
|
||||
page={list.page}
|
||||
totalPages={totalPages}
|
||||
totalCount={total}
|
||||
@ -209,6 +214,10 @@ export default function ProductsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{lowestProduct && (
|
||||
<LowestPriceHistorySheet product={lowestProduct} onClose={overlay.close} />
|
||||
)}
|
||||
|
||||
{modal === 'excel' && (
|
||||
<ExcelUploadModal
|
||||
open
|
||||
|
||||
6
postgres-init/alters/2026-07-10-iilp-source-link.sql
Normal file
6
postgres-init/alters/2026-07-10-iilp-source-link.sql
Normal file
@ -0,0 +1,6 @@
|
||||
-- 2026-07-10 · 최저가 수집 이력에 출처(찾은 상품명·링크) 추가
|
||||
-- 인터넷 최저가는 견적 목표가·협상 카드(시장가 근거)의 근거값이므로, MD 가 클릭 한 번으로
|
||||
-- 검증할 수 있어야 한다. LPS(price_history)의 naver/coupang name·url 을 동기화 때 함께 실어온다.
|
||||
-- 기존 행은 NULL(출처 미수집 — 이 컬럼 추가 이전 수집분)로 자연 공존.
|
||||
ALTER TABLE partner.item_internet_lowest_prices ADD COLUMN IF NOT EXISTS lp_name VARCHAR(300) NULL; -- 찾은 상품명(판매 페이지 기준)
|
||||
ALTER TABLE partner.item_internet_lowest_prices ADD COLUMN IF NOT EXISTS lp_url TEXT NULL; -- 찾은 판매 페이지 링크
|
||||
@ -183,7 +183,9 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices (
|
||||
success_yn BOOLEAN NOT NULL, -- 크롤링 성공 여부
|
||||
fail_reason VARCHAR(100) NULL, -- 실패 사유
|
||||
ai_model SMALLINT NULL, -- 사용한 AI 모델 (코드, 앱 enum 매핑)
|
||||
crawl_duration_ms INTEGER NULL, -- 크롤링 소요 시간(ms)
|
||||
crawl_duration_ms INTEGER NULL,
|
||||
lp_name VARCHAR(300) NULL, -- [2026-07-10] 찾은 상품명(판매 페이지 기준)
|
||||
lp_url TEXT NULL, -- [2026-07-10] 찾은 판매 페이지 링크(근거 검증용) -- 크롤링 소요 시간(ms)
|
||||
crawl_end_time TIMESTAMPTZ NOT NULL, -- 크롤링 종료 시각
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
@ -754,6 +756,20 @@ CREATE TABLE IF NOT EXISTS price_history (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_price_history_product ON price_history (product_code, triggered_at);
|
||||
|
||||
-- IP(프록시 포트) 세션 종료 이력 — 요청 예산(LPS_IP_REQUEST_BUDGET) 상한 튜닝의 원천 데이터
|
||||
CREATE TABLE IF NOT EXISTS ip_session (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source VARCHAR(20) NOT NULL, -- coupang 등
|
||||
proxy_port INTEGER NULL, -- 사용 포트(=IP 세션), 프록시 미사용이면 NULL
|
||||
requests INTEGER NOT NULL, -- 이 IP 로 보낸 요청 수
|
||||
ok_count INTEGER NOT NULL DEFAULT 0, -- 성공 검색 수
|
||||
blocked_count INTEGER NOT NULL DEFAULT 0, -- 차단 감지 수
|
||||
elapsed_sec INTEGER NULL, -- 세션 지속 시간(초)
|
||||
end_reason VARCHAR(20) NOT NULL, -- budget/block/proxy_error/window/idle/shutdown/rotate
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 세션 종료 시각
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_ip_session_source ON ip_session (source, created_at);
|
||||
|
||||
-- 봇 감지 이력 — IP·요청순번·차단방식 축적(패턴 분석)
|
||||
CREATE TABLE IF NOT EXISTS bot_detection (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user