우리동네 물가 체

사회부 0 381

 

// 우리동네 물가 체커 – 한국유통신문 삽입용 Apps SDK 스타일 React 위젯 (MVP)// ----------------------------------------------------------------------------// ✅ 목적//- 기사 하단에 [대화로 물가 확인하기] 버튼으로 여는 대화형 사이드패널 위젯//- 지역/품목 선택 → 가격 요약(최저/최고/중앙값, 전주 대비) + 상점 Top3 핀//- 제보(가격/사진 업로드) → 가드레일(스팸/5σ 이상/PII 유사 탐지) → 승인 큐//- 즐겨찾기, 공유, 추천 팔레트, GA4 이벤트 로깅 포함//- 실제 API는 mock으로 구성. 교체 지점 주석 표기// ----------------------------------------------------------------------------// ???? 사용법//1) 기사 템플릿 하단에 이 컴포넌트를 렌더하세요.////2) API 연동은 아래 "// TODO: 실제 API 연동" 구간의 fetch* 함수를 교체.//3) GA4: window.dataLayer 사용. 사이트에 GA4 스니펫이 있어야 이벤트 수집됨.//4) 스타일: TailwindCSS 가정. (캔버스 미리보기에선 내부 클래스만으로 동작)// ----------------------------------------------------------------------------import React, { useEffect, useMemo, useState } from 'react';import { motion, AnimatePresence } from 'framer-motion';import { Search, MapPin, X, Star, Share2, Wand2, Upload } from 'lucide-react';// ----------------------------- 타입 & 상수 ------------------------------type Store = { store_id: string; name: string; addr: string; lat: number; lng: number; type: 'mart'|'market'|'convenience'|'etc'; demo?: boolean };type PriceRow = { region_code: string; store_id: string; item_code: string; price: number; ts: string; source: 'public'|'user'|'editor' };type Item = { item_code: string; name: string; unit: string; brand_opt?: string[] };type Region = { code: string; name: string; parent?: string | null };type SubmitReportPayload = { user_hash: string; item_code: string; price: number; store_id_opt?: string; photo?: File };type SubmitReportResult = { report_id: string; status: 'pending'|'approved'|'rejected'; ts: string };// 데모용 지역 트리 (광역 → 구/군)const REGIONS: Region[] = [// 광역{ code: 'KR-11', name: '서울특별시', parent: null },{ code: 'KR-27', name: '대구광역시', parent: null },{ code: 'KR-47', name: '경상북도', parent: null },{ code: 'KR-48', name: '경상남도', parent: null },// 서울 구{ code: 'KR-11-YS', name: '영등포구', parent: 'KR-11' },{ code: 'KR-11-JJ', name: '종로구', parent: 'KR-11' },// 대구 구/군 (데모용 1개){ code: 'KR-27-SB', name: '수성구', parent: 'KR-27' },// 경상남도 시 (데모용 1개){ code: 'KR-48-GS', name: '김해시', parent: 'KR-48' },// 경상북도 시{ code: 'KR-47-PH', name: '포항시', parent: 'KR-47' },{ code: 'KR-47-GJ', name: '경주시', parent: 'KR-47' },{ code: 'KR-47-GM', name: '구미시', parent: 'KR-47' },{ code: 'KR-47-GC', name: '김천시', parent: 'KR-47' },{ code: 'KR-47-AD', name: '안동시', parent: 'KR-47' },{ code: 'KR-47-YJ', name: '영주시', parent: 'KR-47' },{ code: 'KR-47-YC', name: '영천시', parent: 'KR-47' },{ code: 'KR-47-SJ', name: '상주시', parent: 'KR-47' },{ code: 'KR-47-MG', name: '문경시', parent: 'KR-47' },{ code: 'KR-47-GS2', name: '경산시', parent: 'KR-47' },// 경상북도 군 (※ 군위군은 2023년 대구 편입으로 제외){ code: 'KR-47-US', name: '의성군', parent: 'KR-47' },{ code: 'KR-47-CS', name: '청송군', parent: 'KR-47' },{ code: 'KR-47-YY', name: '영양군', parent: 'KR-47' },{ code: 'KR-47-YD', name: '영덕군', parent: 'KR-47' },{ code: 'KR-47-CD', name: '청도군', parent: 'KR-47' },{ code: 'KR-47-GR', name: '고령군', parent: 'KR-47' },{ code: 'KR-47-SG', name: '성주군', parent: 'KR-47' },{ code: 'KR-47-CG', name: '칠곡군', parent: 'KR-47' },{ code: 'KR-47-YC2', name: '예천군', parent: 'KR-47' },{ code: 'KR-47-BH', name: '봉화군', parent: 'KR-47' },{ code: 'KR-47-UJ', name: '울진군', parent: 'KR-47' },{ code: 'KR-47-UL', name: '울릉군', parent: 'KR-47' },];// 데모용 품목const ITEMS: Item[] = [{ item_code: 'egg_30', name: '달걀 30구', unit: '판' },{ item_code: 'ramen_5', name: '라면 5개입', unit: '묶음' },{ item_code: 'apple_1kg', name: '사과 1kg', unit: 'kg' },{ item_code: 'milk_1L', name: '우유 1L', unit: 'L' },];// 로컬 스토리지 키const FAVORITES_KEY = 'ytnews_price_checker_favs';const REPORT_RATE_KEY = 'ytnews_price_checker_rate';// ----------------------------- 유틸리티 ------------------------------function fmtKRW(n: number) {return n.toLocaleString('ko-KR') + '원';}function pct(n: number) {const s = (n * 100).toFixed(1);return (n >= 0 ? '+' : '') + s + '%';}function median(values: number[]) {if (!values.length) return 0;const arr = [...values].sort((a,b)=>a-b);const mid = Math.floor(arr.length/2);return arr.length % 2 === 0 ? (arr[mid-1]+arr[mid])/2 : arr[mid];}function last7dISO() {const d = new Date();d.setDate(d.getDate()-7);return d.toISOString();}function hashUser(): string {// 간단 user hash: UA + day bucketconst ua = (typeof navigator !== 'undefined' ? navigator.userAgent : 'ua');const day = new Date().toISOString().slice(0,10);return btoa(ua + '|' + day).replace(/=/g,'');}function pushGA(event: string, params: Record = {}) {// GA4 dataLayer 푸시 (없으면 콘솔)// @ts-ignoreif (typeof window !== 'undefined' && (window as any).dataLayer) {// @ts-ignore(window as any).dataLayer.push({ event, ...params });} else {console.log('[GA4]', event, params);}}function zscore(x: number, arr: number[]) {if (arr.length < 2) return 0;const mean = arr.reduce((a,b)=>a+b,0)/arr.length;const sd = Math.sqrt(arr.map(v => (v-mean)**2).reduce((a,b)=>a+b,0)/(arr.length-1));if (sd === 0) return 0;return (x-mean)/sd;}// ----------------------------- 데이터/API 레이어 ------------------------------// ✅ 공공데이터(농산물 유통정보 KAMIS 등) + 내부/제보 혼합을 위한 어댑터// - 클라이언트는 /api/public/prices 엔드포인트로 조회를 위임합니다.// - CORS/보안 및 서비스키 보호를 위해 반드시 서버 프록시를 사용하세요.// - 실패 시(네트워크/쿼터) 안전하게 Mock으로 폴백합니다.const MOCK_STORES: Store[] = [{ store_id: 'S01', name: '하나마트 종로점', addr: '서울 종로구 세종대로 1', lat: 37.57, lng: 126.98, type: 'mart' },{ store_id: 'S02', name: '영등포시장 A', addr: '서울 영등포구 영등포로 123', lat: 37.52, lng: 126.90, type: 'market' },{ store_id: 'S03', name: '굿편의점 김해점', addr: '경남 김해시 가락로 12', lat: 35.23, lng: 128.88, type: 'convenience' },{ store_id: 'S04', name: '수성마트', addr: '대구 수성구 달구벌대로 100', lat: 35.84, lng: 128.62, type: 'mart' },// ✅ 경상북도 데모/실매장 (포항/경주/구미/안동){ store_id: 'S05', name: '포항 중앙시장', addr: '경북 포항시 북구 중앙상가길', lat: 36.0335, lng: 129.365, type: 'market' },{ store_id: 'S06', name: '경주 성동시장', addr: '경북 경주시 성동동 123', lat: 35.842, lng: 129.212, type: 'market' },// 구미 실매장(출처: 브뉴스 구미시 마트 총정리){ store_id: 'SGM01', name: '이마트 구미점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.119, lng: 128.344, type: 'mart' },{ store_id: 'SGM02', name: '롯데마트 구미점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.12, lng: 128.35, type: 'mart' },{ store_id: 'SGM03', name: '이마트 동구미점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.118, lng: 128.36, type: 'mart' },{ store_id: 'SGM04', name: '홈플러스 구미점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.125, lng: 128.34, type: 'mart' },{ store_id: 'SGM05', name: '하나로마트 선산농협중앙점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.23, lng: 128.3, type: 'mart' },{ store_id: 'SGM06', name: '하나로마트 인동농협 양포점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.09, lng: 128.42, type: 'mart' },{ store_id: 'SGM07', name: 'GS더프레시 구미신당점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.11, lng: 128.35, type: 'mart' },{ store_id: 'SGM08', name: '하나로마트 인동농협 인동점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.09, lng: 128.42, type: 'mart' },{ store_id: 'SGM09', name: '하나로마트 구미칠곡축협', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.08, lng: 128.4, type: 'mart' },{ store_id: 'SGM10', name: 'GS더프레시 구미인덕점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.12, lng: 128.36, type: 'mart' },{ store_id: 'SGM11', name: 'GS더프레시 구미구평점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.1, lng: 128.39, type: 'mart' },{ store_id: 'SGM12', name: '하나로마트 산동농협본점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.16, lng: 128.44, type: 'mart' },{ store_id: 'SGM13', name: '이마트에브리데이 구미봉곡점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.15, lng: 128.33, type: 'mart' },{ store_id: 'SGM14', name: '홈플러스익스프레스 구미인의점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.09, lng: 128.42, type: 'mart' },{ store_id: 'SGM15', name: '하나로마트 고아농협 원호점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.17, lng: 128.31, type: 'mart' },{ store_id: 'SGM16', name: '이마트에브리데이 구미구평점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.1, lng: 128.39, type: 'mart' },{ store_id: 'SGM17', name: '이마트에브리데이 형곡동점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.12, lng: 128.34, type: 'mart' },{ store_id: 'SGM18', name: '하나로마트 고아농협문성점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.19, lng: 128.3, type: 'mart' },{ store_id: 'SGM19', name: '하나로마트 옥성농협본점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.24, lng: 128.25, type: 'mart' },{ store_id: 'SGM20', name: '하나로마트 해평농협 본점', addr: '경북 구미시 (브뉴스 리스트)', lat: 36.22, lng: 128.5, type: 'mart' },// 안동 도매시장 (계속 사용){ store_id: 'S08', name: '안동 농수산물도매시장', addr: '경북 안동시 풍산읍 1', lat: 36.568, lng: 128.699, type: 'market' },// 공공데이터 표본을 가리키는 가상 스토어 (서버가 'PUBLIC-KAMIS'를 반환할 경우 대비){ store_id: 'PUBLIC-KAMIS', name: '공공데이터 표본', addr: '지역 평균', lat: 0, lng: 0, type: 'etc' },];// 최근 14일치 가상 가격표 (전주 대비 계산용) – 폴백 전용const MOCK_PRICES: PriceRow[] = (() => {const rows: PriceRow[] = [];const now = new Date();// ✅ 모든 하위 지역(광역 제외)에 대해 폴백 가격을 생성합니다 (경상북도 포함)const leafRegions = REGIONS.filter(r => !!r.parent).map(r => r.code);const itemsList = ['egg_30','ramen_5','apple_1kg','milk_1L'];for (let d = 0; d < 14; d++) {const ts = new Date(+now - d * 24 * 3600 * 1000).toISOString();for (const r of leafRegions) {// 지역별 기본가 작은 편차를 주어 지역감 부여const regionBias = r.startsWith('KR-47-') ? 120 : r.startsWith('KR-11-') ? 80 : 0; // 경북/서울 약간의 지역 편차// 경북이면 경북 매장 우선 사용 (구미시는 실매장 리스트 우선)const gbCommon = ['S05','S06','S08'];const gumiIds = ['SGM01','SGM02','SGM03','SGM04','SGM05','SGM06','SGM07','SGM08','SGM09','SGM10','SGM11','SGM12','SGM13','SGM14','SGM15','SGM16','SGM17','SGM18','SGM19','SGM20'];const candidates = r === 'KR-47-GM' ? gumiIds : (r.startsWith('KR-47-') ? [...gbCommon, ...gumiIds] : MOCK_STORES.map(s => s.store_id));for (const it of itemsList) {const base = it === 'egg_30' ? 7980 : it === 'ramen_5' ? 4380 : it === 'apple_1kg' ? 5980 : 2750;const jitter = Math.round((Math.random() - 0.5) * 600);const price = Math.max(900, base + regionBias + jitter);const store = candidates[Math.floor(Math.random() * candidates.length)] || 'PUBLIC-KAMIS';rows.push({ region_code: r, store_id: store, item_code: it, price, ts, source: 'public' });}}}return rows;})();// ▶ 실제 공공데이터 조회 – 서버 프록시(/api/public/prices) 호출 – 서버 프록시(/api/public/prices) 호출async function fetchPrices(params: { region_code: string; item_codes: string[]; sinceISO?: string }){const { region_code, item_codes, sinceISO } = params;try {const q = new URLSearchParams({region_code,item_codes: item_codes.join(','),sinceISO: sinceISO || ''});const res = await fetch(`/api/public/prices?${q.toString()}`, { method: 'GET' });if (!res.ok) throw new Error('PUBLIC_API_FAILED');const rows: PriceRow[] = await res.json();if (!Array.isArray(rows) || rows.length===0) throw new Error('EMPTY');return rows;} catch (e){console.warn('[prices] public adapter failed, fallback to mock.', e);// 폴백: 최근 14일 중 sinceISO 필터 적용await new Promise(r=>setTimeout(r, 120));const filtered = MOCK_PRICES.filter(p => p.region_code === region_code && item_codes.includes(p.item_code) && (!sinceISO || p.ts >= sinceISO));return filtered;}}// ✅ (수정) 누락/중복 문제를 해결한 스토어 조회 & 제보 제출 구현//- 중복 선언으로 인한 SyntaxError 방지를 위해 단일 정의만 제공//- 서버 부재 시 안전한 폴백 동작// TODO: 실제 API 연동 (있으면 이 함수 내부의 fetch 경로만 교체)async function fetchStoresByIds(ids: string[]): Promise {const uniq = Array.from(new Set(ids.filter(Boolean)));if (uniq.length === 0) return [];try {const q = new URLSearchParams({ ids: uniq.join(',') });const res = await fetch(`/api/public/stores?${q.toString()}`, { method: 'GET' });if (!res.ok) throw new Error('STORES_API_FAILED');const rows: Store[] = await res.json();if (!Array.isArray(rows)) throw new Error('STORES_EMPTY');// 서버가 일부만 반환할 수 있으므로 누락분은 폴백으로 보강const fallback = uniq.filter(id => !rows.find(r => r.store_id === id)).map(id => MOCK_STORES.find(s => s.store_id === id)).filter(Boolean) as Store[];return [...rows, ...fallback];} catch (e) {console.warn('[stores] public adapter failed, fallback to mock.', e);return uniq.map(id => MOCK_STORES.find(s => s.store_id === id)).filter(Boolean) as Store[];}}// TODO: 실제 API 연동 (있으면 이 함수 내부의 fetch 경로만 교체)async function submitReport(payload: SubmitReportPayload): Promise {// 사진 업로드를 고려한 FormData 사용try {const fd = new FormData();fd.append('user_hash', payload.user_hash);fd.append('item_code', payload.item_code);fd.append('price', String(payload.price));if (payload.store_id_opt) fd.append('store_id_opt', payload.store_id_opt);if (payload.photo) fd.append('photo', payload.photo);const res = await fetch('/api/public/report', { method: 'POST', body: fd });if (!res.ok) throw new Error('REPORT_API_FAILED');const json = await res.json();// 서버가 상태를 판정해서 내려주면 그대로 사용if (json && json.report_id) return json as SubmitReportResult;throw new Error('REPORT_BAD_RESPONSE');} catch (e) {console.warn('[report] public adapter failed, local simulate.', e);// 폴백: 로컬 시뮬레이션 (승인은 클라이언트 가드레일 이후 메시지로 전달)return {report_id: `local-${Date.now()}-${Math.random().toString(36).slice(2)}`,status: 'pending',ts: new Date().toISOString(),};}}// ----------------------------- 컴포넌트 ------------------------------export default function LocalPriceChecker({ articleId }: { articleId: string }){const [open, setOpen] = useState(false);const [regionTop, setRegionTop] = useState('KR-11');const districts = useMemo(()=>REGIONS.filter(r=>r.parent===regionTop), [regionTop]);const [region, setRegion] = useState('KR-11-JJ');const [selectedItems, setSelectedItems] = useState(['egg_30','ramen_5']);const [loading, setLoading] = useState(false);const [rows, setRows] = useState([]);const [summary, setSummary] = useState([]);const [topStores, setTopStores] = useState([]);const [favorites, setFavorites] = useState([]);// 보고서 폼const [reportOpen, setReportOpen] = useState(false);const [reportItem, setReportItem] = useState('egg_30');const [reportPrice, setReportPrice] = useState('');const [reportStore, setReportStore] = useState('');const [reportPhoto, setReportPhoto] = useState();const [reportMsg, setReportMsg] = useState('');// 초기 즐겨찾기 로드useEffect(()=>{try {const favs = localStorage.getItem(FAVORITES_KEY);if (favs) setFavorites(JSON.parse(favs));} catch {}},[]);function toggleFav(key: string){const next = favorites.includes(key) ? favorites.filter(k=>k!==key) : [...favorites, key];setFavorites(next);try { localStorage.setItem(FAVORITES_KEY, JSON.stringify(next)); } catch {}}async function runQuery(items = selectedItems){setLoading(true);pushGA('price_compare', { articleId, region, items });try {const data14 = await fetchPrices({ region_code: region, item_codes: items });const data7 = await fetchPrices({ region_code: region, item_codes: items, sinceISO: last7dISO() });setRows(data7);// 요약 계산 (최근7일)const byItem: Record = {};for (const r of data7){(byItem[r.item_code] ||= []).push(r);}const summaries = Object.entries(byItem).map(([item_code, arr])=>{const prices = arr.map(a=>a.price);const med = median(prices);const min = Math.min(...prices);const max = Math.max(...prices);// 전주(8~14일 전)const prev = data14.filter(p => p.item_code===item_code && p.ts < last7dISO()).map(p=>p.price);const prevMed = prev.length? median(prev): med;const diff = med - prevMed;const diffPct = prevMed? (diff/prevMed) : 0;// Top3 매장: 가장 낮은 가격 순 storeconst cheapest = [...arr].sort((a,b)=>a.price-b.price).slice(0,3);const storeIds = [...new Set(cheapest.map(c=>c.store_id))];return { item_code, med, min, max, diff, diffPct, storeIds };});setSummary(summaries);// 매장 정보 조회const uniqueStoreIds = Array.from(new Set(summaries.flatMap(s=>s.storeIds)));const stores = await fetchStoresByIds(uniqueStoreIds);setTopStores(stores);} finally {setLoading(false);}}useEffect(()=>{if (open) runQuery();// eslint-disable-next-line react-hooks/exhaustive-deps}, [open, region]);function copyShare(){const url = new URL(window.location.href);url.searchParams.set('pc_region', region);url.searchParams.set('pc_items', selectedItems.join(','));navigator.clipboard.writeText(url.toString());pushGA('price_share', { articleId, region, items: selectedItems });alert('링크가 복사되었습니다.');}function suggest(type: 'deals'|'risers'){if (type==='deals'){setSelectedItems(['ramen_5','milk_1L']);} else {setSelectedItems(['egg_30','apple_1kg']);}runQuery(type==='deals'? ['ramen_5','milk_1L'] : ['egg_30','apple_1kg']);}// 보고서 제출 (가드레일 내장)async function submitUserReport(){setReportMsg('');const user_hash = hashUser();const priceNum = Number(reportPrice);if (!reportItem || !priceNum || priceNum<=0) {setReportMsg('품목과 가격을 확인해주세요.');return;}// Rate limit: 동일 user_hash 1시간 3건const bucket = Math.floor(Date.now() / 3600_000);const key = `${REPORT_RATE_KEY}:${user_hash}:${bucket}`;const used = Number(localStorage.getItem(key) || '0');if (used >= 3){setReportMsg('제보 한도(시간당 3건)를 초과했습니다. 잠시 후 다시 시도해주세요.');return;}// 5σ 이상 보류 체크 (최근 7일 기준 같은 아이템/지역)const rowsForItem = rows.filter(r=>r.item_code===reportItem).map(r=>r.price);const z = zscore(priceNum, rowsForItem);const sigmaFlag = Math.abs(z) >= 5;// 간단 PII 유사 탐지: 파일명 키워드 (서버에서 추가 OCR 마스킹 권장)let piiFlag = false;if (reportPhoto){const name = reportPhoto.name.toLowerCase();piiFlag = /(receipt|영수증|phone|전화|번호|ssn|주민)/.test(name);}// 서버 제출 (폴백 포함)await submitReport({user_hash,item_code: reportItem,price: priceNum,store_id_opt: reportStore || undefined,photo: reportPhoto,});// 로컬 승인 큐 시뮬const status = sigmaFlag || piiFlag ? 'pending' : 'approved';setReportMsg(status === 'approved' ? '제보가 반영되었습니다. 감사합니다!' : '제보가 접수되어 검수 후 반영됩니다.');// 사용량 갱신localStorage.setItem(key, String(used+1));pushGA('price_report_submit', {articleId,region,item_code: reportItem,flagged_sigma: sigmaFlag,flagged_pii: piiFlag,});// 성공 후 폼 리셋setTimeout(()=>{setReportOpen(false);setReportItem('egg_30');setReportPrice('');setReportStore('');setReportPhoto(undefined);}, 800);}return (
{ setOpen(true); pushGA('price_widget_open', { articleId }); }}className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl bg-black text-white hover:bg-gray-800 shadow"> 대화로 물가 확인하기{open && (
우리동네 물가 체커
setOpen(false)}>
{/* 지역 선택 */}
지역 선택
{ setRegionTop(e.target.value); }} className="border rounded-xl px-3 py-2">{REGIONS.filter(r=>!r.parent).map(r=> ({r.name}))}setRegion(e.target.value)} className="border rounded-xl px-3 py-2">{REGIONS.filter(r=>r.parent===regionTop).map(r=> ({r.name}))}
{/* 품목 선택 */}
품목 선택
{ITEMS.map(it=>{const active = selectedItems.includes(it.item_code);return ({setSelectedItems(prev => prev.includes(it.item_code) ? prev.filter(i=>i!==it.item_code) : [...prev, it.item_code]);}}>{it.name});})}
{/* 추천 팔레트 */}
추천 팔레트
suggest('deals')} className="text-xs px-3 py-1.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">오늘 특가만 보여줘suggest('risers')} className="text-xs px-3 py-1.5 rounded-full bg-rose-50 text-rose-700 border border-rose-200">지난주 대비 많이 오른 품목
{/* 행동 버튼 */}
runQuery()} className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border bg-gray-900 text-white disabled:opacity-60"> 가격 조회copyShare()} className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border"> 공유toggleFav(`${region}|${selectedItems.sort().join(',')}`)} className={`inline-flex items-center gap-2 px-3 py-2 rounded-xl border ${favorites.includes(`${region}|${selectedItems.sort().join(',')}`)? 'bg-yellow-50 border-yellow-300' : ''}`}> 즐겨찾기
{/* 결과 */}
가격표 (최근 7일, 출처: 공공데이터/독자제보/자체취재 혼합)
{loading ? (
불러오는 중...
) : summary.length===0 ? (
조회 결과가 없습니다. 품목을 선택하고 가격 조회를 눌러주세요.
) : (
{summary.map(s=>{const item = ITEMS.find(i=>i.item_code===s.item_code)!;return (
{item.name}
=0? 'text-rose-600':'text-emerald-600'}`}>{pct(s.diffPct)} (전주 대비)
최저
{fmtKRW(s.min)}
중앙값
{fmtKRW(s.med)}
최고
{fmtKRW(s.max)}
{/* Top3 매장 */}
가까운 판매처
  • {s.storeIds.slice(0,3).map((sid: string)=>{const st = topStores.find(t=>t.store_id===sid);if (!st) return null;return
  • {st.name}{st.demo && 데모}· {st.addr};})}
);})}
)}{/* 제보 & 오류 신고 */}
가격 제보 · 오류 신고
setReportOpen(v=>!v)} className="text-sm px-3 py-1.5 rounded-full border">{reportOpen? '접기':'열기'}
{reportOpen && (
품목setReportItem(e.target.value)} className="mt-1 w-full border rounded-xl px-3 py-2">{ITEMS.map(i=> {i.name})}가격(원)setReportPrice(e.target.value)} inputMode="numeric" className="mt-1 w-full border rounded-xl px-3 py-2" placeholder="예: 5980"/>
판매처(선택)setReportStore(e.target.value)} className="mt-1 w-full border rounded-xl px-3 py-2" placeholder="예: 영등포시장 A"/>
사진 업로드(선택)setReportPhoto(e.target.files?.[0])}/>{reportPhoto && {reportPhoto.name}}
※ 영수증·개인정보가 보이는 사진은 업로드하지 말아 주세요. 시스템이 자동 탐지 시 수동 검수로 전환됩니다.
{reportMsg &&
{reportMsg}
}
제보 제출
)}{/* 푸터 고정 – 출처/고지 */}출처: 공공데이터/독자제보/자체취재 혼합 · 협찬 노출 시 "유료협찬" 배지 표시 · "데모" 표기된 매장은 예시 데이터입니다 · 가격은 점포/시간에 따라 상이할 수 있습니다.)}
);}// ----------------------------- 백엔드 API 예시 (프록시 구현) ------------------------------// ※ 서버(예: Node/Express)에서 공공데이터포털 API 키를 보관하고 프록시 처리하세요.//아래는 KAMIS(농산물 유통정보) + 내부 DB 혼합 예시입니다. 실제 스펙에 맞게 조정 필요./*import express from 'express';import fetch from 'node-fetch';import FormData from 'form-data';const app = express();// 지역/품목 매핑 예시 – 운영 DB 또는 환경설정으로 관리 권장const KAMIS_REGION_MAP: Record = {// 예) 서울 종로구'KR-11-JJ': { countycode: '1101' },// 경북 일부 샘플 – 실제 KAMIS 지역 체계에 맞춰 보완'KR-47-PH': { countycode: '4701' }, // 포항시'KR-47-GJ': { countycode: '4702' }, // 경주시};// 품목 매핑 – KAMIS p_code, p_name 등 실제 코드로 정교화 필요const KAMIS_ITEM_MAP: Record = {egg_30:{ p_code: '400', kind_code: '300', unit: '30구' },apple_1kg:{ p_code: '200', kind_code: '100', unit: '1kg' },milk_1L:{ p_code: '500', kind_code: '200', unit: '1L' },};app.get('/api/public/prices', async (req, res) => {try {const { region_code, item_codes = '', sinceISO } = req.query as any;const items = String(item_codes).split(',').filter(Boolean);// KAMIS는 일자별 가격 제공 – 최근 7~14일 루프 조회(간단 예시)const county = KAMIS_REGION_MAP[String(region_code)]?.countycode;if (!county) return res.json([]);const days = 7; // sinceISO 없을 때 기본 7일const rows: any[] = [];for (const ic of items){const map = KAMIS_ITEM_MAP[ic];if (!map) continue; // 라면 등은 별도 소스 권장for (let i=0; i{const ids = String(req.query.ids||'').split(',').filter(Boolean);const rows = await db.select('stores').whereIn('store_id', ids); // 예시res.json(rows);});// 제보 수신 – 가드레일/마스킹/승인큐 처리app.post('/api/public/report', upload.single('photo'), async (req, res)=>{const { user_hash, item_code, price, store_id_opt } = req.body;// ① 시간당 3건 레이트 – Redis INCR/EXPIRE// ② sigma/z-score 아웃라이어 pending// ③ 이미지 PII 마스킹(OCR) 실패 시 pendingconst report_id = nanoid();await db('user_reports').insert({ report_id, user_hash, item_code, price, store_id_opt, status: 'pending', ts: new Date().toISOString() });res.json({ report_id, status: 'pending', ts: new Date().toISOString() });});// 배포: Vercel/Cloud Run/EC2 등. 환경변수 KAMIS_API_KEY 설정 필수.*/// ----------------------------- 간단 자기진단 테스트 (콘솔) ------------------------------// 빌드/배포에 영향 없도록 콘솔 테스트만 수행. 필요시 true로 전환하여 확인하세요.const RUN_SELF_TEST = false;if (RUN_SELF_TEST) {try {// median 테스트console.assert(median([1,3,2]) === 2, 'median odd failed');console.assert(median([1,2,3,4]) === 2.5, 'median even failed');// zscore 테스트 (표준편차 0이면 0)console.assert(zscore(10, [10,10,10]) === 0, 'zscore sd0 failed');// 데모 매장 표기 테스트 (S07)const s07 = MOCK_STORES.find(s=>s.store_id==='S07');console.assert(!!s07 && s07.demo === true, 'S07 demo flag missing');// 스토어 폴백 테스트fetchStoresByIds(['S01','PUBLIC-KAMIS','NOPE']).then(list => {console.assert(list.find(s=>s.store_id==='S01'), 'store S01 missing');console.assert(list.find(s=>s.store_id==='PUBLIC-KAMIS'), 'store PUBLIC-KAMIS missing');console.assert(!list.find(s=>s.store_id==='NOPE'), 'unexpected NOPE store');console.log('[self-test] OK');});} catch (e) {console.warn('[self-test] FAILED', e);}}
  • 페이스북으로 보내기
  • 트위터로 보내기
  • 구글플러스로 보내기
  • 카카오스토리로 보내기
  • 카카오톡으로 보내기
  • 네이버밴드로 보내기

Comments