diff --git a/infra/images/scan-flow.svg b/infra/images/scan-flow.svg new file mode 100644 index 0000000..2542491 --- /dev/null +++ b/infra/images/scan-flow.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + 한스푼 · 이미지 업로드 → 스캔 결과 흐름 + 사용된 API 전체 · 실선=요청 · 점선=응답 + + 동기 (요청-응답) + + 비동기 · scanTaskExecutor (동시 2) + + 폴링 + + + 브라우저 + han-spoon.site + + + + backend + ECS 컨테이너 :8080 + + + + Amazon S3 + 이미지 버킷 + + + + ai + ECS 컨테이너 :8000 + + + + 외부 API + CLOVA · OpenAI + + + ① POST /api/v1/uploads/sas + { contentType: "image/jpeg" } + + 200 UploadTicketResponse + { storageKey, uploadUrl, expiresAt, uploadHeaders } + + ② PUT {uploadUrl} · presigned + uploadHeaders 그대로 — content-type · host · if-none-match 가 서명됨 + + 200 · If-None-Match 로 덮어쓰기 차단 + + ③ POST /api/v1/scans + { storageKey, source } + + resolveKey · 소유권 검증 + storageKey 로 멱등 조회 + + HeadObject + + VerifiedUpload · versionId · eTag + + 202 { scanId, status: "processing" } + + HTTP 응답 종료 — 이하 백그라운드 + + ④-1 POST /v1/ocr ⏱ 18s + { source, storage_key, image_url: null, version_id, expected_etag } + + GetObject · ECS task role + + CLOVA OCR POST {invokeUrl}/general + + OcrResponse + { scan_session, menu_image, scan_quality, menu_analyses[] } + + 짧은 TX + menu_image 저장 + menu_count 반영 + + scan_quality.status + = needs_retake → 여기서 종료 + + ④-2 POST /v1/ruleengine ⏱ 2s + { profile, ocr_result } · 프로필은 user_profiles + user_allergies + + RuleEngineResponse · risk_level 채움 + + ④-3 POST /v1/result ⏱ 7s + body = 룰엔진 응답 그대로 + + OpenAI gpt-4o-mini + + FinalResultResponse { menu_analyses[] } + + 머지 정합성 검증 → 짧은 TX + 개수 · 메뉴명 · display_order · risk_level → COMPLETED + + ⑤ GET /api/v1/scans/{scanId} + 완료까지 2초 간격 반복 + + 200 ScanResultResponse + { status, menuCount, riskyMenuCount, menus[], retakeReasons, failureCode } + + + 클라이언트 ↔ 백엔드 + + S3 직접 접근 + + 외부 API · 폴링 + + 응답 (점선) + ⏱ = 백엔드가 AI 호출에 적용하는 read timeout · 실패 시 scan_status = failed 이고 failureCode 로 원인 전달 + diff --git a/scripts/gen_store_erd.py b/scripts/gen_store_erd.py new file mode 100644 index 0000000..10ef267 --- /dev/null +++ b/scripts/gen_store_erd.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +"""가게 도메인 ERD SVG 생성기 — 속성 5열(Key/논리명/물리명/타입/Nullable)""" +from xml.sax.saxutils import escape as esc + +HDR_H, CHDR_H, ROW_H, PAD_B = 34, 21, 19, 12 +TW = 450 # 테이블 폭 +CX = (13, 46, 168, 312, 436) # key, 논리, 물리, 타입, null(우측정렬) +BADGE_C = {'PK':'#b25a12','FK':'#1e5aa8','UQ':'#2f7a5a','GEN':'#6b4ea8'} +KIND = {'ref':('#1e5aa8','pnl-ref'), 'core':('#b25a12','pnl-core'), 'sat':('#2f7a5a','pnl-sat'), + 'exist':('#5a616b','pnl-exist'), 'ai':('#6b4ea8','pnl-ai')} +out, R = [], {} + +def table(x, y, name, rows, kind, note=''): + hdr, _ = KIND[kind] + h = HDR_H + CHDR_H + ROW_H*len(rows) + PAD_B + (16 if note else 0) + out.append(f'') + out.append(f'') + out.append(f'{esc(name)}') + ch = y + HDR_H + out.append(f'') + for lbl, off, anc in (('KEY',CX[0],'start'),('논리명',CX[1],'start'),('물리명',CX[2],'start'), + ('데이터타입',CX[3],'start'),('NULL',CX[4],'end')): + out.append(f'{lbl}') + out.append(f'') + ty = y + HDR_H + CHDR_H + 14 + for r in rows: + if r[0] == 'SEC': + out.append(f'') + out.append(f'{esc(r[1])}') + else: + k, lo, ph, dt, nu = r + if k: + out.append(f'{k}') + out.append(f'{esc(lo)}') + out.append(f'{esc(ph)}') + out.append(f'{esc(dt)}') + cls = 'nuY' if nu == 'Y' else 'nuN' + out.append(f'{nu}') + ty += ROW_H + if note: + out.append(f'{esc(note)}') + out.append('') + R[name] = (x, y, TW, h) + return h + +def panel(x, y, w, h, title, kind, dashed=False): + _, cls = KIND[kind] + d = ' stroke-dasharray="7 5"' if dashed else '' + out.append(f'') + out.append(f'{esc(title)}') + +# 앵커 헬퍼 — 커넥터 좌표를 rect에서 계산 +def ry(n, i): x,y,w,h = R[n]; return y + HDR_H + CHDR_H + 14 + ROW_H*i - 1 +def rt(n): x,y,w,h = R[n]; return y +def rb(n): x,y,w,h = R[n]; return y + h +def rl(n): return R[n][0] +def rr(n): return R[n][0] + TW + +W, H = 2130, 1490 +TOP_Y, TOP_H, BOT_Y, BOT_H = 112, 680, 830, 546 +panel(40, TOP_Y, 490, TOP_H, '참조 마스터 · BIGINT PK', 'ref') +panel(560, TOP_Y, 490, TOP_H, '가게 마스터 · V5 예정 (미적용)', 'core') +panel(1080, TOP_Y, 490, TOP_H, '별칭 · 외부참조', 'sat') +panel(40, BOT_Y, 1530, BOT_H, '기존 백엔드 스키마 · UUID PK (V1 배포됨)', 'exist') +panel(1600, TOP_Y, 490, 1264, 'AI 연계 스키마 (catoin) · 백엔드 소유, 미구현', 'ai', dashed=True) + +y = TOP_Y + 50 +y += table(60, y, 'store_categories', [ + ('PK','분류 식별자','id','BIGINT','N'), + ('UQ','업종분류 코드','code','VARCHAR(6)','N'), + ('','분류명','name','VARCHAR(60)','N'), + ('','분류 단계','level','SMALLINT','N'), + ('FK','상위 분류','parent_id','BIGINT','Y'), +], 'ref', '247개 전체 적재 · store_cluster 축') + 22 +y += table(60, y, 'ksic_codes', [ + ('PK','표준산업분류 코드','code','VARCHAR(6)','N'), + ('','분류명','name','VARCHAR(120)','N'), +], 'ref') + 22 +table(60, y, 'store_import_batches', [ + ('PK','적재 배치 식별자','id','BIGINT','N'), + ('UQ','원천 구분','source','VARCHAR(20)','N'), + ('UQ','원천 버전','source_version','VARCHAR(10)','N'), + ('','적재 건수','row_count','INTEGER','N'), + ('','처리 상태','status','VARCHAR(20)','N'), + ('','시작 시각','started_at','TIMESTAMPTZ','N'), + ('','종료 시각','finished_at','TIMESTAMPTZ','Y'), +], 'ref', '분기 upsert 감사 · 롤백 기준점') + +table(580, TOP_Y+50, 'stores', [ + ('PK','가게 식별자','id','BIGINT','N'), + ('UQ','상가업소번호','sbiz_store_no','VARCHAR(24)','Y'), + ('SEC','표시 · 매칭'), + ('','상호명','name','VARCHAR(200)','N'), + ('','지점명','branch_name','VARCHAR(100)','N'), + ('GEN','정규화 상호명','name_normalized','VARCHAR(200)','N'), + ('SEC','분류'), + ('FK','업종 소분류','category_id','BIGINT','Y'), + ('FK','표준산업분류','ksic_code','VARCHAR(6)','Y'), + ('SEC','위치'), + ('','행정동 코드','admin_dong_code','VARCHAR(8)','N'), + ('','도로명주소','road_address','VARCHAR(300)','N'), + ('','층 정보','floor_info','VARCHAR(20)','N'), + ('','위도','lat','DOUBLE PREC.','N'), + ('','경도','lng','DOUBLE PREC.','N'), + ('SEC','상태 · 출처'), + ('','영업 상태','status','VARCHAR(20)','N'), + ('','레코드 출처','origin','VARCHAR(20)','N'), + ('','비활성 시각','inactive_at','TIMESTAMPTZ','Y'), + ('','검증 완료 시각','verified_at','TIMESTAMPTZ','Y'), + ('FK','제출 사용자','submitted_by','UUID','Y'), + ('FK','최종 적재 배치','last_batch_id','BIGINT','Y'), + ('SEC','감사'), + ('','생성 시각','created_at','TIMESTAMPTZ','N'), + ('','수정 시각','updated_at','TIMESTAMPTZ','N'), +], 'core', '359,832건 · 156MB · 반경검색 실측 1.87ms') + +y = TOP_Y + 50 +y += table(1100, y, 'store_aliases', [ + ('PK','별칭 식별자','id','BIGINT','N'), + ('FK','가게','store_id','BIGINT','N'), + ('','별칭','alias','VARCHAR(200)','N'), + ('GEN','정규화 별칭','alias_normalized','VARCHAR(200)','N'), + ('','별칭 출처','source','VARCHAR(20)','N'), +], 'sat', '가게별 별칭 (사용자 제보)') + 22 +y += table(1100, y, 'brand_aliases', [ + ('PK','변형 식별자','id','BIGINT','N'), + ('UQ','정규화 변형표기','variant_normalized','VARCHAR(200)','N'), + ('','정규화 대표표기','canonical_normalized','VARCHAR(200)','N'), + ('','비고','note','VARCHAR(200)','N'), +], 'sat', '전역 표기 변형 · store FK 없음 (앱 레벨 치환)') + 22 +table(1100, y, 'store_external_refs', [ + ('PK','참조 식별자','id','BIGINT','N'), + ('FK','가게','store_id','BIGINT','N'), + ('UQ','제공자','provider','VARCHAR(20)','N'), + ('UQ','외부 장소 ID','external_id','VARCHAR(64)','N'), + ('','외부 장소 URL','external_url','VARCHAR(512)','N'), + ('','연결 시각','linked_at','TIMESTAMPTZ','N'), +], 'sat', '⚠ 약관: ID/URL만 저장. 상호·주소·좌표 금지') + +table(60, BOT_Y+50, 'users', [ + ('PK','사용자 식별자','id','UUID','N'), + ('UQ','이메일','email','VARCHAR(255)','N'), + ('','닉네임','nickname','VARCHAR(100)','N'), + ('','표시 언어','language_code','VARCHAR(2)','N'), +], 'exist') + +table(580, BOT_Y+50, 'scan_sessions', [ + ('PK','스캔 식별자','id','UUID','N'), + ('FK','사용자','user_id','UUID','N'), + ('SEC','V5 가게 연결 (미적용)'), + ('FK','가게','store_id','BIGINT','Y'), + ('','가게명 스냅샷','store_name_snapshot','VARCHAR(200)','Y'), + ('','가게 매칭 방식','store_match_method','VARCHAR(20)','Y'), + ('SEC','V2 스캔 멱등 · 실패코드'), + ('UQ','S3 객체 키','storage_key','VARCHAR(512)','Y'), + ('','실패 코드','failure_code','VARCHAR(64)','Y'), + ('','낙관적 락','lock_version','BIGINT','N'), + ('SEC','기존 · V3 재촬영 안내'), + ('','스캔 상태','scan_status','VARCHAR(20)','N'), + ('','메뉴 수','menu_count','INTEGER','Y'), + ('','스캔 시각','scanned_at','TIMESTAMPTZ','Y'), + ('','재촬영 사유','retake_reasons','JSONB','Y'), + ('','재촬영 제안','retake_suggestions','JSONB','Y'), +], 'exist', 'store context 3개 all-or-none · 사용자 GPS 원본 없음') + +yy = BOT_Y + 50 +yy += table(1100, yy, 'menu_images', [ + ('PK','이미지 식별자','id','UUID','N'), + ('FK','스캔 세션','scan_session_id','UUID','N'), + ('','이미지 소스','source','VARCHAR(20)','Y'), + ('','저장 키','storage_key','VARCHAR(512)','Y'), + ('','S3 객체 버전','object_version_id','VARCHAR(1024)','Y'), + ('','S3 ETag','etag','VARCHAR(255)','Y'), +], 'exist', 'V4: OCR 분석 대상 객체 동일성 검증') + 22 +table(1100, yy, 'menu_analyses', [ + ('PK','분석 식별자','id','UUID','N'), + ('FK','스캔 세션','scan_session_id','UUID','N'), + ('','표시 순서','display_order','INTEGER','Y'), + ('','메뉴명(한/영)','menu_name_ko/_en','VARCHAR(255)','Y'), + ('','설명(한/영)','description_ko/_en','TEXT','Y'), + ('','가격 텍스트','price_text','VARCHAR(100)','Y'), + ('','위험도','risk_level','VARCHAR(20)','Y'), + ('','히트 태그','hit_tags','JSONB','Y'), + ('','다국어 안내문','message','JSONB','Y'), + ('','사장님 카드','owner_card','JSONB','Y'), +], 'exist') + +y = TOP_Y + 50 +y += table(1620, y, 'store_menus', [ + ('FK','가게','store_id','BIGINT','N'), + ('FK','메뉴','menu_id','BIGINT','N'), + ('','최초 확인 시각','first_seen_at','TIMESTAMPTZ','N'), +], 'ai', '가게 ↔ 메뉴 연결점') + 22 +y += table(1620, y, 'ingredient_risk_scores', [ + ('FK','가게','store_id','BIGINT','N'), + ('FK','메뉴','menu_id','BIGINT','N'), + ('FK','재료','ingredient_id','BIGINT','N'), + ('','알파','alpha','DOUBLE PREC.','N'), + ('','베타','beta','DOUBLE PREC.','N'), +], 'ai', 'store_cluster fallback 대상') + 22 +y += table(1620, y, 'ingredient_confirmations', [ + ('FK','가게','store_id','BIGINT','N'), + ('FK','메뉴','menu_id','BIGINT','N'), + ('FK','재료','ingredient_id','BIGINT','N'), + ('','존재 여부','present','BOOLEAN','N'), + ('','이상 플래그','flagged_anomaly','BOOLEAN','N'), +], 'ai', 'hard evidence override') + 22 +y += table(1620, y, 'ingredient_evidence_log', [ + ('PK','증거 식별자','id','BIGINT','N'), + ('FK','가게','store_id','BIGINT','N'), + ('','알파 증분','delta_alpha','DOUBLE PREC.','N'), + ('','증거 출처','source_type','VARCHAR(20)','N'), + ('','생성 시각','created_at','TIMESTAMPTZ','N'), +], 'ai', '시계열 · 파티셔닝 후보') + 22 +table(1620, y, 'owner_verification_requests', [ + ('PK','질의 식별자','id','BIGINT','N'), + ('FK','가게','store_id','BIGINT','N'), + ('FK','스캔 세션','scan_session_id','UUID','N'), + ('','답변 내용','answer_text','TEXT','Y'), +], 'ai') + +# ══ 커넥터 ══ +def link(d, dashed=False): + da = ' stroke-dasharray="6 4"' if dashed else '' + out.append(f'') +def lab(t, x, y): out.append(f'{esc(t)}') + +SC, KS, IB, ST = 'store_categories','ksic_codes','store_import_batches','stores' +AL, EX, US, SS = 'store_aliases','store_external_refs','users','scan_sessions' +MI, MA, SM, IRS = 'menu_images','menu_analyses','store_menus','ingredient_risk_scores' + +link(f'M {rr(SC)} {ry(SC,0)} H 534 V {ry(ST,7)} H {rl(ST)}'); lab('1:N', 516, ry(SC,0)-6) +link(f'M {rl(SC)} {ry(SC,4)} H 49 V {ry(SC,0)} H {rl(SC)}') # 자기참조 상위분류 +lab('self', 26, (ry(SC,0)+ry(SC,4))//2) +link(f'M {rr(KS)} {ry(KS,0)} H 542 V {ry(ST,8)} H {rl(ST)}') +link(f'M {rr(IB)} {ry(IB,0)} H 550 V {ry(ST,21)} H {rl(ST)}') +link(f'M {rr(ST)} {ry(ST,0)} H 1065 V {ry(EX,1)} H {rl(EX)}'); lab('1:N', 1036, ry(ST,0)-6) +link(f'M 1065 {ry(AL,1)} H {rl(AL)}') +link(f'M 640 {rb(ST)} V 812 H 285 V {rt(US)}'); lab('submitted_by', 300, 806) +link(f'M 805 {rb(ST)} V {rt(SS)}'); lab('1:N (NULL 허용)', 813, 790) +link(f'M {rr(US)} {ry(US,0)} H 545 V {ry(SS,1)} H {rl(SS)}'); lab('1:N', 516, ry(US,0)-6) +link(f'M {rr(SS)} {ry(SS,0)} H 1065 V {ry(MI,1)} H {rl(MI)}'); lab('1:1', 1036, ry(SS,0)-6) +link(f'M 1065 {ry(MI,1)} V {ry(MA,1)} H {rl(MA)}'); lab('1:N', 1036, ry(MA,1)-6) +link(f'M 805 {rt(ST)} V 96 H 1585 V {ry(IRS,0)} H {rl(IRS)}') +link(f'M 1585 {ry(SM,0)} H {rl(SM)}') +out.append('store_id — 모든 store-scoped 테이블에 NOT NULL 강제 (agent-1 §0-1)') + +for i, t in enumerate(['⚠ 이 패널 5개 테이블은 아직 구현되지 않았다.', + '물리 스키마와 마이그레이션은 백엔드가 단일 소유한다.', + 'AI는 구조화된 결과/저장 명령만 반환하고 백엔드가', + '권한·FK·멱등성을 검증한 뒤 트랜잭션으로 반영한다.', + '', + 'store_id 계약: PostgreSQL BIGINT · Java Long ·', + 'JSON 정수 · Python int. AI의 stores 직접 쓰기 금지.']): + out.append(f'{esc(t)}') + +# ══ 범례 ══ +LY = 1436 +out.append(f'') +out.append(f'속성 구성: KEY · 논리명 · 물리명 · 데이터타입 · NULL') +lx = 62 +for b, t in [('PK','기본키'),('FK','외래키'),('UQ','유니크'),('GEN','생성 컬럼')]: + out.append(f'{b}') + out.append(f'{esc(t)}') + lx += 122 +out.append(f'Y') +out.append(f'NULL 허용') +lx += 110 +out.append(f'N') +out.append(f'NOT NULL') +lx += 118 +out.append(f'') +out.append(f'DB FK 제약') +lx += 150 +for cx, cls, t in [(lx,'pnl-ref','참조 마스터'),(lx+140,'pnl-core','신규 핵심'),(lx+262,'pnl-sat','부속'), + (lx+352,'pnl-exist','기존 V1'),(lx+462,'pnl-ai','AI 연계')]: + out.append(f'') + out.append(f'{esc(t)}') +out.append(f'PK 이원화: 공개 마스터=BIGINT IDENTITY(좁은 FK·순차 적재) / 사용자 귀속 리소스=UUID(URL 열거 방어)') + +SVG = f''' + + + +한스푼 가게 도메인 ERD — V2 +상가정보 한식 359,832건 마스터 · 카카오 place_id 연결 · 상호명+좌표 동시 매칭 (실측 1.87ms / 156MB) +{chr(10).join(out)} +''' + +import io, sys +io.open(sys.argv[1],'w',encoding='utf-8').write(SVG) + +bad, items = [], list(R.items()) +for i in range(len(items)): + for j in range(i+1, len(items)): + (n1,(x1,y1,w1,h1)), (n2,(x2,y2,w2,h2)) = items[i], items[j] + if x1 < x2+w2 and x2 < x1+w1 and y1 < y2+h2 and y2 < y1+h1: bad.append(f'겹침 {n1}×{n2}') + n,(x,y,w,h) = items[i] + if x+w > W or y+h > H: bad.append(f'캔버스 초과 {n}') +PANELS = [(40,TOP_Y,490,TOP_H),(560,TOP_Y,490,TOP_H),(1080,TOP_Y,490,TOP_H),(40,BOT_Y,1530,BOT_H),(1600,TOP_Y,490,1076)] +for n,(x,y,w,h) in items: + if not any(px<=x and py<=y and x+w<=px+pw and y+h<=py+ph for px,py,pw,ph in PANELS): + bad.append(f'패널 이탈 {n}') +print(f'테이블 {len(R)}개 · 검증 ' + ('실패: '+'; '.join(bad) if bad else '통과 ✓'), file=sys.stderr) +print('written', sys.argv[1], len(SVG), 'bytes') diff --git a/scripts/load_stores.py b/scripts/load_stores.py new file mode 100755 index 0000000..e4edfdb --- /dev/null +++ b/scripts/load_stores.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +소상공인시장진흥공단 상가(상권)정보 CSV → stores 적재 + +설계 메모 + · 표준 라이브러리만 사용. + · 전체가 단일 트랜잭션. 중간 실패 시 부분 적재가 남지 않음. + · 사용자 제출 가게(sbiz_store_no IS NULL)는 덮어쓰지 않는다. + +사용 예 + # 전국 적재 (로컬 docker) + python3 scripts/load_stores.py --csv-dir ~/Downloads/소상공인..._20260630 --sweep-inactive + + # 개발용 일부 지역만 + python3 scripts/load_stores.py --csv-dir ... --regions 경북,서울 + + # 실행 없이 SQL 만 확인 + python3 scripts/load_stores.py --csv-dir ... --out /tmp/load.sql +""" +from __future__ import annotations + +import argparse +from collections import Counter +import csv +import io +import shutil +import subprocess +import unicodedata +import sys +from pathlib import Path + +MIDDLE_CATEGORY = "I201" # 한식. MVP 범위 +SOURCE = "sbiz" +FULL_DATASET_REGIONS = frozenset({ + "강원", "경기", "경남", "경북", "대구", "대전", "부산", "서울", + "세종", "울산", "인천", "전남광주", "전북", "제주", "충남", "충북", +}) + +COL = { # CSV 헤더 → 내부 키 + "no": "상가업소번호", "name": "상호명", "branch": "지점명", + "l1c": "상권업종대분류코드", "l1n": "상권업종대분류명", + "l2c": "상권업종중분류코드", "l2n": "상권업종중분류명", + "l3c": "상권업종소분류코드", "l3n": "상권업종소분류명", + "ksicc": "표준산업분류코드", "ksicn": "표준산업분류명", + "dong": "행정동코드", "addr": "도로명주소", "floor": "층정보", + "lng": "경도", "lat": "위도", +} + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--csv-dir", required=True, type=Path, help="지역별 CSV 가 들어있는 디렉터리") + p.add_argument("--source-version", help="스냅샷 버전. 미지정 시 파일명에서 추출 (예: 202606)") + p.add_argument("--regions", help="쉼표 구분 지역 필터 (예: 경북,서울). 미지정 시 전체") + p.add_argument("--category", default=MIDDLE_CATEGORY, help=f"적재할 상권업종 중분류 코드 (기본 {MIDDLE_CATEGORY})") + p.add_argument("--sweep-inactive", "--sweep-closed", dest="sweep_inactive", action="store_true", + help="이번 배치에 없는 sbiz 가게를 비활성 처리. 전국 16개·기본 업종의 완전한 적재에서만 허용") + p.add_argument("--psql", help="psql 실행 명령. 미지정 시 자동 탐지") + p.add_argument("--dsn", default="postgresql://hanspoon:hanspoon@localhost:5432/hanspoon", + help="로컬 psql 사용 시 접속 문자열") + p.add_argument("--container", default="hanspoon-postgres", help="docker 폴백에 사용할 컨테이너 이름") + p.add_argument("--out", type=Path, help="실행하지 않고 SQL 을 이 파일에 기록") + return p.parse_args(argv) + + +def resolve_psql(a: argparse.Namespace) -> list[str]: + if a.psql: + return a.psql.split() + if shutil.which("psql"): + return ["psql", a.dsn] + if shutil.which("docker"): + running = subprocess.run(["docker", "ps", "--format", "{{.Names}}"], + capture_output=True, text=True).stdout.split() + if a.container in running: + return ["docker", "exec", "-i", a.container, "psql", "-U", "hanspoon", "-d", "hanspoon"] + sys.exit("psql 을 찾지 못했습니다. --psql 로 실행 명령을 직접 지정하세요.") + + +def file_metadata(f: Path) -> tuple[str, str]: + """파일명 끝의 지역·스냅샷 버전을 읽는다. + + 접두부에 밑줄이 추가돼도 영향을 받지 않도록 오른쪽에서 두 토큰만 분리한다. + macOS가 한글 파일명을 NFD로 저장할 수 있어 비교 전 NFC로 맞춘다. + """ + parts = unicodedata.normalize("NFC", f.stem).rsplit("_", 2) + if len(parts) != 3 or len(parts[2]) != 6 or not parts[2].isdigit(): + sys.exit(f"예상한 CSV 파일명이 아닙니다: {f.name}") + return parts[1], parts[2] + + +def discover(csv_dir: Path, regions: str | None) -> list[Path]: + files = sorted(f for f in csv_dir.glob("*.csv")) + if not files: + sys.exit(f"CSV 를 찾을 수 없습니다: {csv_dir}") + if regions: + want = {unicodedata.normalize("NFC", r.strip()) for r in regions.split(",")} + files = [f for f in files if file_metadata(f)[0] in want] + if not files: + sys.exit(f"지역 필터에 맞는 파일이 없습니다: {regions}") + return files + + +def version_of(files: list[Path]) -> str: + vs = {file_metadata(f)[1] for f in files} + if len(vs) != 1: + sys.exit(f"파일들의 스냅샷 버전이 섞여 있습니다: {sorted(vs)}") + return vs.pop() + + +def resolve_version(files: list[Path], override: str | None) -> str: + """파일명 버전을 기준으로 배치 버전을 확정한다. + + 사용자가 잘못된 버전을 강제로 지정하면 동일 스냅샷의 감사 기록과 비활성 스윕 범위가 + 어긋날 수 있으므로, override는 파일명에서 확인한 버전과 같을 때만 허용한다. + """ + detected = version_of(files) + if override and override != detected: + sys.exit(f"--source-version({override})이 파일 버전({detected})과 다릅니다.") + return detected + + +def validate_sweep_scope(a: argparse.Namespace, files: list[Path]) -> None: + """비활성 스윕이 전국·기본 업종의 완전한 스냅샷에서만 실행되도록 강제한다.""" + if not a.sweep_inactive: + return + if a.regions: + sys.exit("--sweep-inactive는 --regions와 함께 사용할 수 없습니다.") + if a.category != MIDDLE_CATEGORY: + sys.exit( + f"--sweep-inactive는 기본 적재 범위({MIDDLE_CATEGORY})에서만 사용할 수 있습니다. " + f"현재 범위: {a.category}" + ) + + regions = [file_metadata(f)[0] for f in files] + counts = Counter(regions) + duplicates = sorted(region for region, count in counts.items() if count > 1) + missing = sorted(FULL_DATASET_REGIONS - counts.keys()) + unexpected = sorted(counts.keys() - FULL_DATASET_REGIONS) + if missing or unexpected or duplicates: + sys.exit( + "--sweep-inactive에는 전국 전체 스냅샷이 필요합니다. " + f"누락={missing or '없음'}, 예상외={unexpected or '없음'}, 중복={duplicates or '없음'}" + ) + + +def emit(w: io.TextIOBase, files: list[Path], category: str, sweep: bool, version: str) -> dict: + """SQL 전문을 w 에 스트리밍하고 집계를 돌려준다.""" + stat = {"rows": 0, "cats": 0, "ksic": 0, "skipped": 0} + out = csv.writer(w, lineterminator="\n") + + w.write(f"""\ +\\set ON_ERROR_STOP on +BEGIN; + +INSERT INTO store_import_batches (source, source_version, status, started_at) +VALUES ('{SOURCE}', '{version}', 'running', now()) +ON CONFLICT (source, source_version) +DO UPDATE SET status = 'running', started_at = now(), finished_at = NULL +RETURNING id AS batch_id +\\gset + +CREATE TEMP TABLE stg_category (code text, name text, level smallint, parent_code text) ON COMMIT DROP; +CREATE TEMP TABLE stg_ksic (code text, name text) ON COMMIT DROP; +CREATE TEMP TABLE stg_store ( + sbiz_store_no text, name text, branch_name text, category_code text, ksic_code text, + admin_dong_code text, road_address text, floor_info text, lat float8, lng float8 +) ON COMMIT DROP; + +""") + + def copy_block(table: str, rows) -> int: + w.write(f"\\copy {table} FROM STDIN WITH (FORMAT csv)\n") + n = 0 + for row in rows: + out.writerow(row) + n += 1 + w.write("\\.\n\n") + return n + + # 업종/KSIC 는 음식 외 대분류까지 전부 모은다 — 참조 데이터는 비용이 없고, + # 일식·중식 확장 시 재적재 없이 stores 적재 범위만 넓히면 된다. + cats: dict[str, tuple[str, int, str]] = {} + ksic: dict[str, str] = {} + + def store_rows(): + """CSV 를 한 행씩 흘려보내며 분류/KSIC 를 부수적으로 수집한다. + 전량을 메모리에 쌓지 않으므로 적재 범위를 전체 업종으로 넓혀도 견딘다.""" + for f in files: + print(f" 읽는 중 {f.name}", file=sys.stderr) + with f.open(encoding="utf-8", newline="") as fh: + for r in csv.DictReader(fh): + if r[COL["l1c"]]: + cats.setdefault(r[COL["l1c"]], (r[COL["l1n"]], 1, "")) + if r[COL["l2c"]]: + cats.setdefault(r[COL["l2c"]], (r[COL["l2n"]], 2, r[COL["l1c"]])) + if r[COL["l3c"]]: + cats.setdefault(r[COL["l3c"]], (r[COL["l3n"]], 3, r[COL["l2c"]])) + if r[COL["ksicc"]].strip(): + ksic.setdefault(r[COL["ksicc"]].strip(), r[COL["ksicn"]].strip()) + + if r[COL["l2c"]] != category: + continue + # sbiz_store_no 는 멱등 upsert 의 충돌 키다. 비면 중복 행이 쌓이므로 버린다. + if not r[COL["no"]].strip() or not r[COL["name"]].strip(): + stat["skipped"] += 1 + continue + try: + lat, lng = float(r[COL["lat"]]), float(r[COL["lng"]]) + except ValueError: + stat["skipped"] += 1 + continue + # 스키마 CHECK 와 같은 범위. 여기서 거르면 트랜잭션 전체가 죽는 일이 없다. + if not (33 <= lat <= 39 and 124 <= lng <= 132): + stat["skipped"] += 1 + continue + yield [ + r[COL["no"]].strip(), r[COL["name"]].strip(), r[COL["branch"]].strip(), + r[COL["l3c"]].strip(), r[COL["ksicc"]].strip(), + r[COL["dong"]].strip(), r[COL["addr"]].strip(), r[COL["floor"]].strip(), + lat, lng, + ] + + stat["rows"] = copy_block("stg_store", store_rows()) + stat["cats"] = copy_block("stg_category", ([c, v[0], v[1], v[2]] for c, v in sorted(cats.items()))) + stat["ksic"] = copy_block("stg_ksic", ([c, n] for c, n in sorted(ksic.items()))) + + w.write("""\ +INSERT INTO store_categories (code, name, level) +SELECT DISTINCT ON (code) code, name, 1 FROM stg_category WHERE level = 1 ORDER BY code +ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, updated_at = now(); + +INSERT INTO store_categories (code, name, level, parent_id) +SELECT DISTINCT ON (s.code) s.code, s.name, 2, p.id +FROM stg_category s JOIN store_categories p ON p.code = s.parent_code +WHERE s.level = 2 ORDER BY s.code +ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, parent_id = EXCLUDED.parent_id, updated_at = now(); + +INSERT INTO store_categories (code, name, level, parent_id) +SELECT DISTINCT ON (s.code) s.code, s.name, 3, p.id +FROM stg_category s JOIN store_categories p ON p.code = s.parent_code +WHERE s.level = 3 ORDER BY s.code +ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, parent_id = EXCLUDED.parent_id, updated_at = now(); + +INSERT INTO ksic_codes (code, name) +SELECT DISTINCT ON (code) code, name FROM stg_ksic ORDER BY code +ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name; + +INSERT INTO stores ( + sbiz_store_no, name, branch_name, category_id, ksic_code, + admin_dong_code, road_address, floor_info, lat, lng, + status, origin, last_batch_id, created_at, updated_at) +SELECT s.sbiz_store_no, s.name, coalesce(s.branch_name, ''), c.id, + (SELECT k.code FROM ksic_codes k WHERE k.code = NULLIF(s.ksic_code, '')), + coalesce(s.admin_dong_code, ''), coalesce(s.road_address, ''), coalesce(s.floor_info, ''), + s.lat, s.lng, + 'active', 'sbiz', :batch_id, now(), now() +FROM stg_store s +JOIN store_categories c ON c.code = s.category_code +ON CONFLICT (sbiz_store_no) DO UPDATE SET + name = EXCLUDED.name, + branch_name = EXCLUDED.branch_name, -- EXCLUDED 는 위 SELECT 의 coalesce 결과라 NULL 이 아니다 + category_id = EXCLUDED.category_id, + ksic_code = EXCLUDED.ksic_code, + admin_dong_code = EXCLUDED.admin_dong_code, + road_address = EXCLUDED.road_address, + floor_info = EXCLUDED.floor_info, + lat = EXCLUDED.lat, + lng = EXCLUDED.lng, + last_batch_id = EXCLUDED.last_batch_id, + updated_at = now(), + -- 이전 스냅샷에서 빠졌던 가게가 다시 나타나면 활성 상태로 되돌린다. + status = CASE WHEN stores.status = 'inactive' THEN 'active' ELSE stores.status END, + inactive_at = CASE WHEN stores.status = 'inactive' THEN NULL ELSE stores.inactive_at END; + +""") + + if sweep: + w.write("""\ +-- 이번 스냅샷에 없는 sbiz 가게를 비활성 처리. 실제 폐업 확정으로 해석하지 않는다. +-- Python 사전 검증을 통과한 전국 16개·기본 업종의 완전한 스냅샷에서만 실행된다. +UPDATE stores SET status = 'inactive', inactive_at = now(), updated_at = now() +WHERE origin = 'sbiz' AND status = 'active' AND last_batch_id IS DISTINCT FROM :batch_id; + +""") + + w.write("""\ +UPDATE store_import_batches + SET status = 'completed', finished_at = now(), row_count = (SELECT count(*) FROM stg_store) + WHERE id = :batch_id; + +COMMIT; + +\\echo '── 적재 결과 ──' +SELECT (SELECT count(*) FROM store_categories) AS categories, + (SELECT count(*) FROM ksic_codes) AS ksic_codes, + (SELECT count(*) FROM stores WHERE status = 'active') AS active_stores, + (SELECT count(*) FROM stores WHERE status = 'inactive') AS inactive_stores; +ANALYZE stores; +""") + return stat + + +def main() -> None: + a = parse_args() + files = discover(a.csv_dir, a.regions) + version = resolve_version(files, a.source_version) + validate_sweep_scope(a, files) + print(f"대상 파일 {len(files)}개 · 스냅샷 {version} · 중분류 {a.category}" + f"{' · 비활성 스윕 ON' if a.sweep_inactive else ''}", file=sys.stderr) + + if a.out: + with a.out.open("w", encoding="utf-8") as fh: + stat = emit(fh, files, a.category, a.sweep_inactive, version) + print(f"SQL 기록: {a.out} ({a.out.stat().st_size / 1e6:.1f} MB)", file=sys.stderr) + else: + cmd = resolve_psql(a) + print(f"실행: {' '.join(cmd[:3])} …", file=sys.stderr) + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, text=True, encoding="utf-8") + assert proc.stdin is not None + try: + stat = emit(proc.stdin, files, a.category, a.sweep_inactive, version) + finally: + proc.stdin.close() + if proc.wait() != 0: + sys.exit(f"psql 실패 (exit {proc.returncode}) — 트랜잭션은 롤백되었습니다.") + + print(f"분류 {stat['cats']:,} · KSIC {stat['ksic']:,} · 가게 {stat['rows']:,}" + f" · 좌표 이상으로 제외 {stat['skipped']:,}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/scripts/store_domain.sql b/scripts/store_domain.sql new file mode 100644 index 0000000..50556a7 --- /dev/null +++ b/scripts/store_domain.sql @@ -0,0 +1,290 @@ +-- 가게 도메인 +-- 마스터: 소상공인시장진흥공단 상가(상권)정보 한식(I201) — 2026-06 기준 전국 359,832건 +-- 설계 문서: docs/12-store-domain-erd.svg +-- +-- PK 이원화 원칙 +-- · 공개 참조 마스터(stores/categories/ksic/batches) = BIGINT IDENTITY +-- → FK 폭을 좁혀 store-scoped 대용량 테이블의 인덱스 비용을 줄이고, 벌크 적재 시 순차 삽입 이점을 얻는다. +-- 상가정보는 공개 데이터라 순차 ID 노출로 잃을 것이 없다. +-- · 사용자 귀속 리소스(users/scan_sessions/…) = UUID (V1 그대로) +-- → URL 노출 시 열거 공격 방어. +-- +-- 데이터 타입 근거: 전국 CSV 실측 최대 길이 +-- 상가업소번호 20 · 상호명 32 · 지점명 9 · 도로명주소 34 · 층정보 4 · 행정동코드 8 +-- (사용자 제출 가게를 감안해 여유를 둔 값으로 지정) + +-- 반경 검색(GiST) / 상호명 유사도(GIN trigram)에 필요. +-- AWS RDS PostgreSQL 16. 세 확장 모두 RDS 지원 목록에 있고, 접속 계정(hanspoon_app)이 +-- 마스터 사용자라 rds_superuser 권한으로 CREATE EXTENSION 이 가능하다. shared_preload_libraries 변경 불필요. +CREATE EXTENSION IF NOT EXISTS cube; +CREATE EXTENSION IF NOT EXISTS earthdistance; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- 상호명 매칭용 정규화. DB 와 애플리케이션이 반드시 같은 규칙을 써야 하므로 함수로 고정한다. +-- 검색어도 이 함수를 거쳐야 한다: WHERE s.name_normalized % normalize_store_name(:q) +-- +-- · NFKC 정규화로 전각 문자를 반각으로 접는다. 실측에서 'CU 마트 B1' 이 '마트' 로, +-- '369' 이 빈 문자열로 뭉개지는 사례가 나왔다(전국 3건). 간판·OCR 에 전각이 흔하다. +-- · POSIX alnum 문자군으로 모든 유니코드 문자·숫자를 보존한다. 한글 호환 자모는 NFKC 후 +-- 현대 한글 자모로 바뀌므로 '가-힣ㄱ-ㅎ' 같은 고정 범위만 허용하면 일부 글자가 유실된다. +-- 한자·일본어 가나·CJK 확장 문자도 음식점 상호 검색을 위해 보존한다. +-- +-- ⚠ 이 함수를 CREATE OR REPLACE 로 바꾸면 기존 생성 컬럼 값과 새 값의 규칙이 어긋나고 +-- trigram 인덱스가 실제 데이터와 불일치한다. 변경 시 컬럼 재계산 + REINDEX 가 함께 필요하다. +CREATE FUNCTION normalize_store_name(src text) RETURNS text + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE + RETURN regexp_replace(lower(normalize(src, NFKC)), '[^[:alnum:]]', '', 'g'); + +-- 운영 DB의 인코딩·locale 차이로 정규화 결과가 달라지면 마이그레이션 단계에서 즉시 실패시킨다. +DO $normalization_contract$ +BEGIN + IF normalize_store_name('CU 마트 B1') IS DISTINCT FROM 'cu마트b1' THEN + RAISE EXCEPTION 'normalize_store_name contract failed: full-width characters'; + END IF; + IF normalize_store_name('369') IS DISTINCT FROM '369' THEN + RAISE EXCEPTION 'normalize_store_name contract failed: full-width digits'; + END IF; + IF normalize_store_name('竹田家') IS DISTINCT FROM '竹田家' THEN + RAISE EXCEPTION 'normalize_store_name contract failed: CJK characters'; + END IF; + IF normalize_store_name('스시 さくら') IS DISTINCT FROM '스시さくら' THEN + RAISE EXCEPTION 'normalize_store_name contract failed: Japanese characters'; + END IF; + IF length(normalize_store_name('ㄱㅎ')) IS DISTINCT FROM 2 THEN + RAISE EXCEPTION 'normalize_store_name contract failed: Hangul Jamo'; + END IF; + IF normalize_store_name('한 스푼! @강남점') IS DISTINCT FROM '한스푼강남점' THEN + RAISE EXCEPTION 'normalize_store_name contract failed: separators'; + END IF; +END +$normalization_contract$; + +-- ───────────────────────────────────────────────────────────── +-- 참조 마스터 +-- ───────────────────────────────────────────────────────────── + +-- 상권업종 분류 (대2 / 중4 / 소6자리) 자기참조 3계층. +-- 음식 외 대분류까지 247개 전체를 적재한다 — 참조 데이터는 비용이 없고, +-- 일식·중식 확장 시 스키마 변경 없이 stores 적재 범위만 넓히면 되기 때문. +CREATE TABLE store_categories ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + code VARCHAR(6) NOT NULL, -- I2 / I201 / I20101 + name VARCHAR(60) NOT NULL, + level SMALLINT NOT NULL, -- 1=대분류 2=중분류 3=소분류 + parent_id BIGINT NULL, -- level 1 은 NULL + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_store_categories PRIMARY KEY (id), + CONSTRAINT uq_store_categories_code UNIQUE (code), + CONSTRAINT fk_store_categories_parent FOREIGN KEY (parent_id) + REFERENCES store_categories (id), + CONSTRAINT ck_store_categories_level CHECK (level BETWEEN 1 AND 3), + CONSTRAINT ck_store_categories_root CHECK ((level = 1) = (parent_id IS NULL)) +); +CREATE INDEX idx_store_categories_parent ON store_categories (parent_id); + +COMMENT ON TABLE store_categories IS '상권업종 분류 3계층. 소분류(level 3)가 Bayesian store_cluster prior 의 축이 된다.'; +COMMENT ON COLUMN store_categories.level IS '1=대분류(2자리) 2=중분류(4자리) 3=소분류(6자리)'; + +-- 한국표준산업분류(KSIC 10차). 상권업종분류와 독립된 축이라 별도 테이블로 둔다. +CREATE TABLE ksic_codes ( + code VARCHAR(6) NOT NULL, + name VARCHAR(120) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_ksic_codes PRIMARY KEY (code) +); + +-- 분기 갱신 적재 이력. "이 행이 어느 스냅샷에서 왔는가"를 추적해 롤백 판단 근거로 쓴다. +CREATE TABLE store_import_batches ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + source VARCHAR(20) NOT NULL, -- sbiz | localdata + source_version VARCHAR(10) NOT NULL, -- '202606' + row_count INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'running', + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ NULL, + CONSTRAINT pk_store_import_batches PRIMARY KEY (id), + CONSTRAINT uq_store_import_batches UNIQUE (source, source_version), + CONSTRAINT ck_store_import_batches_source CHECK (source IN ('sbiz', 'localdata')), + CONSTRAINT ck_store_import_batches_status CHECK (status IN ('running', 'completed', 'failed')) +); + +-- ───────────────────────────────────────────────────────────── +-- 가게 마스터 +-- ───────────────────────────────────────────────────────────── +CREATE TABLE stores ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + + -- 원천 식별자. 멱등 upsert 키. localdata/user_submitted 출처면 NULL + -- (PostgreSQL UNIQUE 는 NULL 을 서로 다른 값으로 보므로 다중 NULL 허용). + sbiz_store_no VARCHAR(24) NULL, + + -- 표시 · 매칭 + name VARCHAR(200) NOT NULL, + branch_name VARCHAR(100) NOT NULL DEFAULT '', + -- 매칭용 정규형. 애플리케이션이 따로 채우지 않도록 생성 컬럼으로 둔다. + name_normalized VARCHAR(200) GENERATED ALWAYS AS (normalize_store_name(name)) STORED, + + -- 분류 + -- 공공데이터 행은 항상 분류가 있지만, 신규 사용자 제보는 검증 전까지 분류를 모를 수 있다. + category_id BIGINT NULL, + ksic_code VARCHAR(6) NULL, -- 원천 결측 존재(전국 한식 343건) + + -- 위치. 행정동은 코드만 보존한다 — 행정동'명'을 함께 저장하지 않으므로 이행 종속이 없고, + -- 나중에 regions 테이블이 필요해지면 재적재 없이 조인만 붙이면 된다. + admin_dong_code VARCHAR(8) NOT NULL DEFAULT '', + road_address VARCHAR(300) NOT NULL DEFAULT '', + floor_info VARCHAR(20) NOT NULL DEFAULT '', -- 결측 48% 이나 동일좌표 다중매장 구분 단서 + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + + -- 상태 · 출처. 분기 스냅샷에서 사라졌다는 사실만으로 실제 폐업을 단정하지 않는다. + status VARCHAR(20) NOT NULL DEFAULT 'active', -- active | inactive + origin VARCHAR(20) NOT NULL, + inactive_at TIMESTAMPTZ NULL, + -- 공공데이터 수록 여부와 서비스의 검증 완료는 다른 개념이다. 실제 검증 전에는 NULL. + verified_at TIMESTAMPTZ NULL, + submitted_by UUID NULL, -- origin='user_submitted' 인 경우의 제보자 + last_batch_id BIGINT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT pk_stores PRIMARY KEY (id), + CONSTRAINT uq_stores_sbiz_no UNIQUE (sbiz_store_no), + CONSTRAINT fk_stores_category FOREIGN KEY (category_id) + REFERENCES store_categories (id), + CONSTRAINT fk_stores_ksic FOREIGN KEY (ksic_code) + REFERENCES ksic_codes (code), + CONSTRAINT fk_stores_batch FOREIGN KEY (last_batch_id) + REFERENCES store_import_batches (id), + CONSTRAINT fk_stores_submitter FOREIGN KEY (submitted_by) + REFERENCES users (id) ON DELETE SET NULL, + -- 원천 실측 이상치 0건. 사용자 제출 가게의 오입력을 막는 방어선. + CONSTRAINT ck_stores_lat CHECK (lat BETWEEN 33 AND 39), + CONSTRAINT ck_stores_lng CHECK (lng BETWEEN 124 AND 132), + CONSTRAINT ck_stores_name CHECK (btrim(name) <> ''), + CONSTRAINT ck_stores_status CHECK (status IN ('active', 'inactive')), + CONSTRAINT ck_stores_origin CHECK (origin IN ('sbiz', 'localdata', 'user_submitted')), + CONSTRAINT ck_stores_inactive_at CHECK ((status = 'inactive') = (inactive_at IS NOT NULL)), + -- 출처별 식별자·배치 관계를 DB에서도 강제해 잘못 조합된 가게 행을 막는다. + CONSTRAINT ck_stores_sbiz_identity CHECK ((origin = 'sbiz') = (sbiz_store_no IS NOT NULL)), + CONSTRAINT ck_stores_batch_origin CHECK ( + (origin IN ('sbiz', 'localdata')) = (last_batch_id IS NOT NULL) + ), + CONSTRAINT ck_stores_submitter_origin CHECK (submitted_by IS NULL OR origin = 'user_submitted'), + CONSTRAINT ck_stores_category_origin CHECK (category_id IS NOT NULL OR origin = 'user_submitted') +); + +-- 반경 후보 검색. status 동등조건을 부분 인덱스 조건으로 흡수해 스캔 대상을 영업중 행으로 한정한다. +CREATE INDEX idx_stores_geo_active ON stores USING gist (ll_to_earth(lat, lng)) + WHERE status = 'active'; +-- 상호명 유사도 매칭(실측: 상호명 단독으로는 고유율 83% 라 좌표와 병행 필수). +CREATE INDEX idx_stores_name_trgm ON stores USING gin (name_normalized gin_trgm_ops); +CREATE INDEX idx_stores_category ON stores (category_id) WHERE status = 'active'; +CREATE INDEX idx_stores_batch ON stores (last_batch_id); + +COMMENT ON TABLE stores IS '가게 마스터. 상가정보 한식(I201) 기반, 분기 스냅샷을 sbiz_store_no 기준으로 멱등 upsert.'; +COMMENT ON COLUMN stores.status IS '데이터 소스 기준 노출 상태. inactive는 실제 폐업 확정이 아니라 최신 스냅샷 미수록을 뜻한다.'; +COMMENT ON COLUMN stores.verified_at IS '서비스가 사업자·관리자 검증을 완료한 시각. 공공데이터 수록만으로 채우지 않는다.'; +COMMENT ON COLUMN stores.origin IS '레코드 출처. 이 스캔에서 어떻게 식별했는지(match_method)와는 다른 축이다.'; +COMMENT ON COLUMN stores.name_normalized IS '매칭 전용 정규형(생성 컬럼). 표시에는 name 을 쓸 것. 검색어도 normalize_store_name() 을 거쳐야 한다.'; + +-- ───────────────────────────────────────────────────────────── +-- 별칭 — 두 종류를 분리한다. +-- 가게별 별칭은 store 에 종속되지만, 브랜드 표기 변형(서브웨이↔써브웨이)은 특정 가게와 무관하다. +-- 후자를 store_aliases 에 넣으면 같은 브랜드 지점 수만큼 행이 복제되어 삽입·수정 이상이 생긴다. +-- ───────────────────────────────────────────────────────────── +CREATE TABLE store_aliases ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + store_id BIGINT NOT NULL, + alias VARCHAR(200) NOT NULL, + alias_normalized VARCHAR(200) GENERATED ALWAYS AS (normalize_store_name(alias)) STORED, + source VARCHAR(20) NOT NULL DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_store_aliases PRIMARY KEY (id), + CONSTRAINT fk_store_aliases_store FOREIGN KEY (store_id) + REFERENCES stores (id) ON DELETE CASCADE, + CONSTRAINT ck_store_aliases_source CHECK (source IN ('manual', 'user_reported')) +); +CREATE UNIQUE INDEX uq_store_aliases ON store_aliases (store_id, alias_normalized); +CREATE INDEX idx_store_aliases_trgm ON store_aliases USING gin (alias_normalized gin_trgm_ops); + +-- 전역 표기 변형 사전. 검색어를 대표표기로 치환한 뒤 stores 를 조회한다. store FK 없음(앱 레벨 조회). +CREATE TABLE brand_aliases ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + variant_normalized VARCHAR(200) NOT NULL, + canonical_normalized VARCHAR(200) NOT NULL, + note VARCHAR(200) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_brand_aliases PRIMARY KEY (id), + CONSTRAINT uq_brand_aliases_variant UNIQUE (variant_normalized) +); + +-- ───────────────────────────────────────────────────────────── +-- 외부 지도 서비스 참조 +-- 컬럼이 아니라 테이블로 분리한 이유: stores 에 kakao_place_id 컬럼을 두면 +-- 그 옆에 kakao_name / kakao_address 를 추가하는 것이 한 줄 ALTER 로 가능해진다. +-- 저장이 허용되는 필드만 담는 테이블로 격리해 약관 경계를 스키마에 남긴다. +-- ───────────────────────────────────────────────────────────── +CREATE TABLE store_external_refs ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + store_id BIGINT NOT NULL, + provider VARCHAR(20) NOT NULL, + external_id VARCHAR(64) NOT NULL, -- 카카오 place_id + external_url VARCHAR(512) NOT NULL DEFAULT '', -- 카카오 place_url + linked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pk_store_external_refs PRIMARY KEY (id), + -- 양방향 1:1 — 같은 외부 장소가 두 가게에 붙거나, 한 가게에 같은 제공자가 둘 붙는 것을 막는다. + CONSTRAINT uq_store_external_refs_ext UNIQUE (provider, external_id), + CONSTRAINT uq_store_external_refs_store UNIQUE (store_id, provider), + CONSTRAINT fk_store_external_refs_store FOREIGN KEY (store_id) + REFERENCES stores (id) ON DELETE CASCADE, + CONSTRAINT ck_store_external_refs_provider CHECK (provider IN ('kakao')) +); + +COMMENT ON TABLE store_external_refs IS + '외부 지도 서비스 참조. 카카오 약관상 place_id/place_url 만 저장 허용 — 상호명·주소·좌표·전화번호 저장 금지.'; + +-- ───────────────────────────────────────────────────────────── +-- 스캔 세션 연결 +-- 기존 운영 스캔은 가게 정보 없이 생성됐으므로 세 컬럼을 NULL 허용한다. +-- 신규 스캔은 애플리케이션이 가게 선택 후 시작하고, store-scoped AI(③ 이후)는 store_id가 +-- 확정된 세션만 호출한다. 현재 OCR·정규화 경로와 과거 이력 조회는 NULL이어도 유지한다. +-- +-- 사용자 GPS 원본 컬럼은 의도적으로 두지 않는다. 개인위치정보(위치정보법)에 해당해 +-- 저장 시 동의·보관기간·파기 의무가 발생하고, 스캔 이력과 결합되면 동선이 된다. +-- 후보 조회에만 쓰고 결과(store_id)만 남긴다. +-- ───────────────────────────────────────────────────────────── +ALTER TABLE scan_sessions + ADD COLUMN store_id BIGINT NULL, + ADD COLUMN store_name_snapshot VARCHAR(200) NULL, + ADD COLUMN store_match_method VARCHAR(20) NULL; + +ALTER TABLE scan_sessions + -- RESTRICT: 스캔 이력이 참조하는 가게는 삭제 불가. 검색 제외는 stores.status 비활성 전이로 표현한다. + ADD CONSTRAINT fk_scan_sessions_store FOREIGN KEY (store_id) + REFERENCES stores (id) ON DELETE RESTRICT, + ADD CONSTRAINT ck_scan_sessions_match_method CHECK (store_match_method IS NULL OR store_match_method IN + ('gps_candidate', 'name_search', 'kakao_fallback', 'user_created')), + ADD CONSTRAINT ck_scan_sessions_store_context CHECK ( + (store_id IS NULL AND store_name_snapshot IS NULL AND store_match_method IS NULL) + OR + (store_id IS NOT NULL + AND store_name_snapshot IS NOT NULL + AND btrim(store_name_snapshot) <> '' + AND store_match_method IS NOT NULL) + ); + +CREATE INDEX idx_scan_sessions_store ON scan_sessions (store_id) WHERE store_id IS NOT NULL; + +COMMENT ON COLUMN scan_sessions.store_name_snapshot IS + '스캔 시점 상호명 동결. 서버가 stores.name에서 복사하며 클라이언트 입력을 신뢰하지 않는다.'; +COMMENT ON COLUMN scan_sessions.store_id IS + '가게 도입 전 레거시 스캔만 NULL. 신규 store-scoped AI 호출은 값이 확정된 세션에만 허용한다.'; +COMMENT ON COLUMN scan_sessions.store_match_method IS + '가게 식별 경로. 의사결정 근거로 사용하지 않는 관측용 메타데이터이며 store context와 함께 저장한다.'; diff --git a/scripts/test_load_stores.py b/scripts/test_load_stores.py new file mode 100644 index 0000000..b9e6130 --- /dev/null +++ b/scripts/test_load_stores.py @@ -0,0 +1,89 @@ +import argparse +import io +import unittest +from pathlib import Path + +from scripts import load_stores + + +class LoadStoresSafetyTest(unittest.TestCase): + + def test_full_default_snapshot_allows_sweep(self): + args = self.args(sweep_inactive=True) + + load_stores.validate_sweep_scope(args, self.files()) + + def test_region_filter_rejects_sweep(self): + args = self.args(sweep_inactive=True, regions="서울") + + with self.assertRaisesRegex(SystemExit, "--regions"): + load_stores.validate_sweep_scope(args, self.files(["서울"])) + + def test_non_default_category_rejects_sweep(self): + args = self.args(sweep_inactive=True, category="I202") + + with self.assertRaisesRegex(SystemExit, "기본 적재 범위"): + load_stores.validate_sweep_scope(args, self.files()) + + def test_missing_region_rejects_sweep(self): + regions = load_stores.FULL_DATASET_REGIONS - {"서울"} + + with self.assertRaisesRegex(SystemExit, "서울"): + load_stores.validate_sweep_scope(self.args(sweep_inactive=True), self.files(regions)) + + def test_duplicate_region_rejects_sweep(self): + files = self.files() + [Path("상가_정보_서울_202606.csv")] + + with self.assertRaisesRegex(SystemExit, r"중복=\['서울'\]"): + load_stores.validate_sweep_scope(self.args(sweep_inactive=True), files) + + def test_partial_load_without_sweep_is_allowed(self): + args = self.args(sweep_inactive=False, regions="서울", category="I202") + + load_stores.validate_sweep_scope(args, self.files(["서울"])) + + def test_source_version_must_match_file_version(self): + with self.assertRaisesRegex(SystemExit, "파일 버전"): + load_stores.resolve_version(self.files(), "202603") + + def test_detected_source_version_is_used(self): + self.assertEqual("202606", load_stores.resolve_version(self.files(), None)) + + def test_unexpected_filename_is_rejected(self): + with self.assertRaisesRegex(SystemExit, "예상한 CSV 파일명"): + load_stores.resolve_version([Path("stores.csv")], None) + + def test_inactive_sweep_sql_does_not_claim_store_is_closed(self): + output = io.StringIO() + + load_stores.emit(output, [], load_stores.MIDDLE_CATEGORY, True, "202606") + + sql = output.getvalue() + self.assertIn("status = 'inactive'", sql) + self.assertIn("inactive_at = now()", sql) + self.assertNotIn("is_verified", sql) + self.assertNotIn("status = 'closed'", sql) + + def test_legacy_sweep_option_maps_to_inactive_sweep(self): + args = load_stores.parse_args(["--csv-dir", "/tmp", "--sweep-closed"]) + + self.assertTrue(args.sweep_inactive) + + @staticmethod + def args(**overrides) -> argparse.Namespace: + values = { + "sweep_inactive": False, + "regions": None, + "category": load_stores.MIDDLE_CATEGORY, + } + values.update(overrides) + return argparse.Namespace(**values) + + @staticmethod + def files(regions=None) -> list[Path]: + selected = regions or load_stores.FULL_DATASET_REGIONS + return [Path(f"상가_정보_{region}_202606.csv") for region in sorted(selected)] + + +if __name__ == "__main__": + unittest.main() diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ImageQuality.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ImageQuality.java index 92e1da1..c4a19e5 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ImageQuality.java +++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ImageQuality.java @@ -10,6 +10,7 @@ * 알려진 필드만 매핑하고 나머지는 무시한다. * * @param available 분석 가능 여부 (PIL/cv2 미존재 시 false) + * @param error 분석 불가 사유 * @param score 이미지 품질 점수 * @param blurScore 흐림/초점 상태 * @param brightness 밝기 @@ -23,6 +24,7 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record ImageQuality( Boolean available, + String error, Integer score, Double blurScore, Double brightness, diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/MenuAnalysis.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/MenuAnalysis.java index bef1a1b..046389a 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/MenuAnalysis.java +++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/MenuAnalysis.java @@ -15,7 +15,7 @@ * @param priceText 가격 문자열 (보정된 원 단위) * @param riskLevel 위험도 (OCR 단계 null) * @param isSpicy 매움 여부 - * @param imageUrl 개별 메뉴 이미지 URL + * @param imageUrl 개별 메뉴 이미지 URL. 현재 AI 크롭 미구현이며, 향후에도 백엔드 검증 전에는 저장·공개하지 않음. * @param displayOrder 화면 표시 순서 */ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java index 016adce..5e7a03b 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java +++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java @@ -13,6 +13,10 @@ * @param rawLineCount OCR 원본 라인 수 * @param priceMatchCount 가격 매칭된 메뉴 수 * @param priceMatchRatio 가격 매칭 비율 + * @param priceAnchorCount OCR에서 감지된 가격 후보 수 + * @param pairCoverage 감지된 가격 후보 중 메뉴와 매칭된 비율 + * @param meanOcrConfidence OCR 라인 평균 신뢰도 + * @param meanPairConfidence 메뉴명-가격 쌍 평균 신뢰도 * @param imageWidth 이미지 가로 px * @param imageHeight 이미지 세로 px * @param imageQuality 이미지 품질 세부 분석 @@ -36,6 +40,10 @@ public record ScanQuality( Integer rawLineCount, Integer priceMatchCount, Double priceMatchRatio, + Integer priceAnchorCount, + Double pairCoverage, + Double meanOcrConfidence, + Double meanPairConfidence, Integer imageWidth, Integer imageHeight, ImageQuality imageQuality, diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/MenuResult.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/MenuResult.java index 0958e41..76cd86c 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/MenuResult.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/MenuResult.java @@ -8,10 +8,13 @@ /** * 스캔 결과의 메뉴 1건 (OCR + ai_result 최종). 사용자 표시용. + * AI가 반환한 메뉴별 이미지 URL은 신뢰하지 않으며, 백엔드 소유의 크롭·서명 체계가 생기기 전까지 응답하지 않는다. * * @param displayOrder 표시 순서 * @param menuNameKo 한국어 메뉴명 * @param menuNameEn 영어 메뉴명 + * @param descriptionKo 한국어 메뉴 설명 + * @param descriptionEn 영어 메뉴 설명 * @param priceText 가격 문자열 * @param isSpicy 매움 여부 * @param riskLevel 위험도 (danger | caution | safe) @@ -24,6 +27,8 @@ public record MenuResult( Integer displayOrder, String menuNameKo, String menuNameEn, + String descriptionKo, + String descriptionEn, String priceText, Boolean isSpicy, RiskLevel riskLevel, diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java index 851b4eb..15e6744 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java @@ -17,6 +17,7 @@ * @param scannedAt 스캔 시각 * @param menus 메뉴별 분석 결과 * @param retakeReasons 재촬영 사유 (status 가 NEEDS_RETAKE 일 때만 채워짐, 그 외 null). OCR 이 제공한 문자열 그대로(언어 혼재 가능, i18n 키 아님) + * @param retakeSuggestions 재촬영 방법 안내 (status 가 NEEDS_RETAKE 일 때만 채워짐, 그 외 null) * @param failureCode 실패 원인 코드 (status 가 FAILED 일 때만 채워짐) */ @Schema(description = "스캔 결과 조회 응답") @@ -29,4 +30,5 @@ public record ScanResultResponse( Instant scannedAt, List menus, List retakeReasons, + List retakeSuggestions, String failureCode) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/MenuImage.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/MenuImage.java index af3d562..17e2001 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/MenuImage.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/MenuImage.java @@ -35,6 +35,14 @@ public class MenuImage extends BaseEntity { @Column(name = "storage_key", length = 512) private String storageKey; + /** 검증 시점의 S3 객체 버전. 같은 키의 객체가 변경돼도 실제 분석 대상을 식별한다. */ + @Column(name = "object_version_id", length = 1024) + private String objectVersionId; + + /** 검증 시점의 S3 ETag. AI가 읽은 객체와 업로드 검증 결과의 동일성 확인에 사용한다. */ + @Column(name = "etag", length = 255) + private String eTag; + @Column(name = "image_url", length = 1024) private String imageUrl; @@ -45,18 +53,34 @@ public class MenuImage extends BaseEntity { private Long fileSize; private MenuImage( - UUID scanSessionId, String source, String storageKey, String imageUrl, String mimeType, Long fileSize) { + UUID scanSessionId, + String source, + String storageKey, + String imageUrl, + String mimeType, + Long fileSize, + String objectVersionId, + String eTag) { this.id = UUID.randomUUID(); this.scanSessionId = scanSessionId; this.source = source; this.storageKey = storageKey; + this.objectVersionId = objectVersionId; + this.eTag = eTag; this.imageUrl = imageUrl; this.mimeType = mimeType; this.fileSize = fileSize; } public static MenuImage create( - UUID scanSessionId, String source, String storageKey, String imageUrl, String mimeType, Long fileSize) { - return new MenuImage(scanSessionId, source, storageKey, imageUrl, mimeType, fileSize); + UUID scanSessionId, + String source, + String storageKey, + String imageUrl, + String mimeType, + Long fileSize, + String objectVersionId, + String eTag) { + return new MenuImage(scanSessionId, source, storageKey, imageUrl, mimeType, fileSize, objectVersionId, eTag); } } diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java index a3f73b7..85301d4 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java @@ -63,6 +63,10 @@ public class ScanSession extends BaseEntity { @Column(name = "retake_reasons", columnDefinition = "jsonb") private List retakeReasons; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "retake_suggestions", columnDefinition = "jsonb") + private List retakeSuggestions; + private ScanSession( UUID userId, String storageKey, @@ -124,11 +128,12 @@ public void changeTitle(String title) { this.title = title; } - /** 재촬영 필요 시 상태 + OCR 이 제공한 사유를 반영. */ - public void applyNeedsRetake(List retakeReasons) { + /** 재촬영 필요 시 상태와 OCR 이 제공한 사유·개선 안내를 반영. */ + public void applyNeedsRetake(List retakeReasons, List retakeSuggestions) { ensureProcessing(); this.scanStatus = ScanStatus.NEEDS_RETAKE; this.retakeReasons = retakeReasons; + this.retakeSuggestions = retakeSuggestions; this.failureCode = null; } diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java index 91549f9..e1700b7 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java @@ -94,13 +94,13 @@ public void process(UUID scanId, UUID userId, VerifiedUpload upload, String sour scanStateWriter.applyOcrResult( scanId, - toMenuImage(scanId, source, storageKey, ocr), + toMenuImage(scanId, source, upload, ocr), ocr.scanSession() != null ? ocr.scanSession().menuCount() : null, parseScannedAt(ocr.scanSession() != null ? ocr.scanSession().scannedAt() : null)); if (isNeedsRetake(ocr)) { scanStateWriter.applyNeedsRetake( - scanId, ocr.scanQuality() != null ? ocr.scanQuality().reasons() : null); + scanId, ocr.scanQuality().reasons(), ocr.scanQuality().retakeSuggestions()); log.info("Scan needs retake: {}", scanId); return; } @@ -149,19 +149,20 @@ private void markFailedQuietly(UUID scanId, Exception cause) { } } - private MenuImage toMenuImage(UUID scanId, String source, String storageKey, OcrResponse ocr) { + private MenuImage toMenuImage(UUID scanId, String source, VerifiedUpload upload, OcrResponse ocr) { String resolvedSource = source; - String mimeType = null; - Long fileSize = null; - if (ocr.menuImage() != null) { - if (resolvedSource == null) { - resolvedSource = ocr.menuImage().source(); - } - mimeType = ocr.menuImage().mimeType(); - fileSize = ocr.menuImage().fileSize(); + if (resolvedSource == null && ocr.menuImage() != null) { + resolvedSource = ocr.menuImage().source(); } return MenuImage.create( - scanId, resolvedSource, storageKey, s3StorageService.objectUri(storageKey), mimeType, fileSize); + scanId, + resolvedSource, + upload.storageKey(), + s3StorageService.objectUri(upload.storageKey()), + upload.contentType(), + upload.contentLength(), + upload.versionId(), + upload.eTag()); } private boolean isNeedsRetake(OcrResponse ocr) { @@ -212,7 +213,8 @@ private List merge(UUID scanId, OcrResponse ocr, FinalResultRespon o.descriptionEn(), o.priceText(), o.isSpicy(), - o.imageUrl(), + // AI 제공 URL은 출처·만료를 보장할 수 없다. 백엔드 소유의 크롭·서명 체계 도입 전에는 저장하지 않는다. + null, f.riskLevel(), f.hits(), f.message(), @@ -240,12 +242,36 @@ private String failureCode(Exception exception) { private void logOcrCompleted(UUID scanId, long backendDurationMs, OcrResponse ocr) { var quality = ocr.scanQuality(); + if (quality != null + && quality.imageQuality() != null + && Boolean.FALSE.equals(quality.imageQuality().available()) + && quality.imageQuality().error() != null) { + log.warn( + "Image quality analysis unavailable: {} (reason={})", + scanId, + quality.imageQuality().error()); + } log.info( - "OCR completed: {} (backendMs={}, aiMs={}, attempts={}, preprocessingApplied={}, selectedAttempt={}, " - + "retrySkippedReason={}, fetchSource={}, aiQueueMs={})", + "OCR completed: {} (backendMs={}, aiMs={}, qualityStatus={}, score={}, rawLines={}, priceMatches={}, " + + "priceAnchors={}, pairCoverage={}, meanOcrConfidence={}, meanPairConfidence={}, " + + "imageWidth={}, imageHeight={}, imageQualityScore={}, attempts={}, preprocessingApplied={}, " + + "selectedAttempt={}, retrySkippedReason={}, fetchSource={}, aiQueueMs={})", scanId, backendDurationMs, quality != null ? quality.ocrProcessingTimeMs() : null, + quality != null ? quality.status() : null, + quality != null ? quality.score() : null, + quality != null ? quality.rawLineCount() : null, + quality != null ? quality.priceMatchCount() : null, + quality != null ? quality.priceAnchorCount() : null, + quality != null ? quality.pairCoverage() : null, + quality != null ? quality.meanOcrConfidence() : null, + quality != null ? quality.meanPairConfidence() : null, + quality != null ? quality.imageWidth() : null, + quality != null ? quality.imageHeight() : null, + quality != null && quality.imageQuality() != null + ? quality.imageQuality().score() + : null, quality != null ? quality.ocrAttemptCount() : null, quality != null ? quality.preprocessingApplied() : null, quality != null ? quality.selectedOcrAttempt() : null, diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java index 8d7e9bf..b7f4c62 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java @@ -105,6 +105,7 @@ public ScanResultResponse getScan(UUID userId, UUID scanId) { session.getScannedAt(), menus, session.getRetakeReasons(), + session.getRetakeSuggestions(), session.getFailureCode()); } @@ -140,6 +141,8 @@ private static MenuResult toMenuResult(MenuAnalysis m) { m.getDisplayOrder(), m.getMenuNameKo(), m.getMenuNameEn(), + m.getDescriptionKo(), + m.getDescriptionEn(), m.getPriceText(), m.getIsSpicy(), m.getRiskLevel(), diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java index 1811ca5..c209763 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java @@ -47,8 +47,8 @@ public void applyOcrResult(UUID scanId, MenuImage menuImage, Integer menuCount, } @Transactional - public void applyNeedsRetake(UUID scanId, List retakeReasons) { - session(scanId).ifPresent(session -> session.applyNeedsRetake(retakeReasons)); + public void applyNeedsRetake(UUID scanId, List retakeReasons, List retakeSuggestions) { + session(scanId).ifPresent(session -> session.applyNeedsRetake(retakeReasons, retakeSuggestions)); } @Transactional diff --git a/src/main/java/com/hanspoon/backend_api/global/config/DevDataSeeder.java b/src/main/java/com/hanspoon/backend_api/global/config/DevDataSeeder.java index a82228b..2005070 100644 --- a/src/main/java/com/hanspoon/backend_api/global/config/DevDataSeeder.java +++ b/src/main/java/com/hanspoon/backend_api/global/config/DevDataSeeder.java @@ -174,7 +174,7 @@ private void seedScans(UUID userId) { // 3) NEEDS_RETAKE · 재촬영 사유 ScanSession s3 = ScanSession.create(userId, null, 0, null, ScanStatus.PROCESSING, now.minus(Duration.ofDays(2))); - s3.applyNeedsRetake(List.of("too blurry", "low light")); + s3.applyNeedsRetake(List.of("too blurry", "low light"), List.of("카메라를 메뉴판과 평행하게 두고 다시 촬영해 주세요.")); scanSessionRepository.save(s3); // 4) PROCESSING · 진행 중 diff --git a/src/main/resources/db/migration/V3__add_scan_retake_suggestions.sql b/src/main/resources/db/migration/V3__add_scan_retake_suggestions.sql new file mode 100644 index 0000000..adceb1a --- /dev/null +++ b/src/main/resources/db/migration/V3__add_scan_retake_suggestions.sql @@ -0,0 +1,2 @@ +ALTER TABLE scan_sessions + ADD COLUMN retake_suggestions JSONB NULL; diff --git a/src/main/resources/db/migration/V4__add_menu_image_provenance.sql b/src/main/resources/db/migration/V4__add_menu_image_provenance.sql new file mode 100644 index 0000000..988ce26 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_menu_image_provenance.sql @@ -0,0 +1,8 @@ +ALTER TABLE menu_images + ADD COLUMN object_version_id VARCHAR(1024) NULL, + ADD COLUMN etag VARCHAR(255) NULL; + +COMMENT ON COLUMN menu_images.object_version_id IS + 'S3 HeadObject 검증 시점의 객체 버전 ID. 실제 OCR 분석 대상을 식별한다.'; +COMMENT ON COLUMN menu_images.etag IS + 'S3 HeadObject 검증 시점의 ETag. 업로드 검증 객체의 동일성 확인에 사용한다.'; diff --git a/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java b/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java index 2ba8044..a5e7085 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java @@ -4,6 +4,7 @@ import com.hanspoon.backend_api.domain.ai.dto.common.EscalationCase; import com.hanspoon.backend_api.domain.ai.dto.common.RiskLevel; +import com.hanspoon.backend_api.domain.ai.dto.ocr.ImageQuality; import com.hanspoon.backend_api.domain.ai.dto.ocr.OcrRequest; import com.hanspoon.backend_api.domain.ai.dto.ocr.OcrResponse; import com.hanspoon.backend_api.domain.ai.dto.ruleengine.RuleMenuAnalysis; @@ -17,6 +18,25 @@ class AiDtoSerializationTest { private final JsonMapper objectMapper = JsonMapper.builder().build(); + @Test + void deserializesImageQualityAnalysisError() throws Exception { + String json = + """ + { + "available": false, + "error": "이미지 품질 분석 패키지를 불러오지 못했습니다.", + "score": null, + "reasons": [], + "suggestions": [] + } + """; + + ImageQuality result = objectMapper.readValue(json, ImageQuality.class); + + assertThat(result.available()).isFalse(); + assertThat(result.error()).isEqualTo("이미지 품질 분석 패키지를 불러오지 못했습니다."); + } + @Test void deserializesDangerMenu() throws Exception { String json = @@ -142,6 +162,10 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception { "raw_line_count": 20, "price_match_count": 2, "price_match_ratio": 1.0, + "price_anchor_count": 3, + "pair_coverage": 0.67, + "mean_ocr_confidence": 0.91, + "mean_pair_confidence": 0.88, "image_width": 1280, "image_height": 960, "image_quality": { @@ -156,7 +180,7 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception { "suggestions": [], "future_unknown_field": "ignored" }, - "retake_suggestions": [], + "retake_suggestions": ["카메라의 초점을 맞춰 다시 촬영해 주세요."], "reasons": [], "preprocessing_attempted": true, "preprocessing_applied": true, @@ -191,7 +215,12 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception { assertThat(result.scanSession().riskyMenuCount()).isNull(); assertThat(result.menuImage().storageKey()).isEqualTo("scans/menu_001.jpg"); assertThat(result.scanQuality().status()).isEqualTo("usable"); + assertThat(result.scanQuality().priceAnchorCount()).isEqualTo(3); + assertThat(result.scanQuality().pairCoverage()).isEqualTo(0.67); + assertThat(result.scanQuality().meanOcrConfidence()).isEqualTo(0.91); + assertThat(result.scanQuality().meanPairConfidence()).isEqualTo(0.88); assertThat(result.scanQuality().imageQuality().glareRatio()).isEqualTo(0.02); + assertThat(result.scanQuality().retakeSuggestions()).containsExactly("카메라의 초점을 맞춰 다시 촬영해 주세요."); assertThat(result.scanQuality().preprocessingApplied()).isTrue(); assertThat(result.scanQuality().ocrAttemptCount()).isEqualTo(2); assertThat(result.scanQuality().ocrProcessingTimeMs()).isEqualTo(842L); diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/ScanPersistenceIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/ScanPersistenceIntegrationTest.java index 22e1b79..a38a399 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/ScanPersistenceIntegrationTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/ScanPersistenceIntegrationTest.java @@ -66,7 +66,9 @@ void persistsAndReloadsScanGraphWithJsonbFields() { "scans/menu_001.jpg", "https://example.com/scans/menu_001.jpg", "image/jpeg", - 123456L)); + 123456L, + "version-1", + "\"etag-1\"")); menuAnalysisRepository.save(MenuAnalysis.create( sessionId, @@ -96,6 +98,8 @@ void persistsAndReloadsScanGraphWithJsonbFields() { menuImageRepository.findByScanSessionId(sessionId).orElseThrow(); assertThat(reloadedImage.getStorageKey()).isEqualTo("scans/menu_001.jpg"); assertThat(reloadedImage.getSource()).isEqualTo("upload"); + assertThat(reloadedImage.getObjectVersionId()).isEqualTo("version-1"); + assertThat(reloadedImage.getETag()).isEqualTo("\"etag-1\""); List analyses = menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(sessionId); assertThat(analyses).hasSize(1); @@ -129,7 +133,7 @@ void appliesNeedsRetakePersistsReasonsAsJsonb() { ScanSession session = scanSessionRepository.save( ScanSession.create(user.getId(), "blur.jpg", null, null, ScanStatus.PROCESSING, Instant.now())); - session.applyNeedsRetake(List.of("too blurry", "low light")); + session.applyNeedsRetake(List.of("too blurry", "low light"), List.of("카메라의 초점을 맞춰 다시 촬영해 주세요.")); scanSessionRepository.save(session); entityManager.flush(); entityManager.clear(); @@ -137,6 +141,7 @@ void appliesNeedsRetakePersistsReasonsAsJsonb() { ScanSession reloaded = scanSessionRepository.findById(session.getId()).orElseThrow(); assertThat(reloaded.getScanStatus()).isEqualTo(ScanStatus.NEEDS_RETAKE); assertThat(reloaded.getRetakeReasons()).containsExactly("too blurry", "low light"); + assertThat(reloaded.getRetakeSuggestions()).containsExactly("카메라의 초점을 맞춰 다시 촬영해 주세요."); } @Test @@ -146,7 +151,8 @@ void deletingScanSessionCascadesToImageAndAnalyses() { ScanSession session = scanSessionRepository.save( ScanSession.create(userId, "del.jpg", 1, 0, ScanStatus.COMPLETED, Instant.now())); UUID sessionId = session.getId(); - menuImageRepository.save(MenuImage.create(sessionId, "upload", "scans/del.jpg", "u", "image/jpeg", 1L)); + menuImageRepository.save( + MenuImage.create(sessionId, "upload", "scans/del.jpg", "u", "image/jpeg", 1L, null, null)); menuAnalysisRepository.save(MenuAnalysis.create( sessionId, 1, diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java index df7048d..20d0360 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java @@ -113,8 +113,8 @@ void startScanReturns503WhenScanCapacityIsExhausted() throws Exception { void getScanReturnsResult() throws Exception { UUID scanId = UUID.randomUUID(); when(scanService.getScan(eq(USER_ID), eq(scanId))) - .thenReturn( - new ScanResultResponse(scanId, ScanStatus.COMPLETED, null, 2, 1, null, List.of(), null, null)); + .thenReturn(new ScanResultResponse( + scanId, ScanStatus.COMPLETED, null, 2, 1, null, List.of(), null, null, null)); mockMvc.perform(get("/api/v1/scans/{scanId}", scanId)) .andExpect(status().isOk()) diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java index 7328565..880a54c 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java @@ -93,9 +93,9 @@ void setUp() { } private static com.hanspoon.backend_api.domain.ai.dto.ocr.MenuAnalysis ocrMenu( - String name, String price, boolean spicy, int order) { + String name, String price, boolean spicy, int order, String imageUrl) { return new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuAnalysis( - name, null, "", null, price, null, spicy, null, order); + name, null, "", null, price, null, spicy, imageUrl, order); } private OcrResponse usableOcr() { @@ -103,13 +103,17 @@ private OcrResponse usableOcr() { new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession( "menu.jpg", 2, null, "completed", "2026-06-05T00:00:00Z"), new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage( - "upload", STORAGE_KEY, "https://s3/presigned", "image/jpeg", 123L), + "upload", STORAGE_KEY, "https://s3/presigned", "image/png", 999L), new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality( "usable", 80, 20, 2, 1.0, + 2, + 1.0, + 0.91, + 0.88, 1280, 960, null, @@ -124,7 +128,9 @@ private OcrResponse usableOcr() { 16_000L, "s3_iam", 0L), - List.of(ocrMenu("samgyeopsal", "9000", false, 1), ocrMenu("doenjang", "8000", true, 2)), + List.of( + ocrMenu("samgyeopsal", "9000", false, 1, "https://ai.invalid/crop.jpg"), + ocrMenu("doenjang", "8000", true, 2, null)), null); } @@ -198,7 +204,16 @@ void completesScanAndMergesOcrWithRuleEngine() { assertThat(session.getScanStatus()).isEqualTo(ScanStatus.COMPLETED); assertThat(session.getMenuCount()).isEqualTo(2); assertThat(session.getRiskyMenuCount()).isEqualTo(1); - verify(menuImageRepository).save(any()); + ArgumentCaptor imageCaptor = + ArgumentCaptor.forClass(com.hanspoon.backend_api.domain.scan.entity.MenuImage.class); + verify(menuImageRepository).save(imageCaptor.capture()); + var savedImage = imageCaptor.getValue(); + assertThat(savedImage.getStorageKey()).isEqualTo(STORAGE_KEY); + assertThat(savedImage.getImageUrl()).isEqualTo("s3://test-bucket/" + STORAGE_KEY); + assertThat(savedImage.getMimeType()).isEqualTo(VERIFIED_UPLOAD.contentType()); + assertThat(savedImage.getFileSize()).isEqualTo(VERIFIED_UPLOAD.contentLength()); + assertThat(savedImage.getObjectVersionId()).isEqualTo(VERSION_ID); + assertThat(savedImage.getETag()).isEqualTo(ETAG); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); @@ -208,6 +223,7 @@ void completesScanAndMergesOcrWithRuleEngine() { // OCR 가격 + FinalOutput 위험도·태그의 동일 행 머지 확인 assertThat(saved.get(0).getMenuNameKo()).isEqualTo("samgyeopsal"); assertThat(saved.get(0).getPriceText()).isEqualTo("9000"); + assertThat(saved.get(0).getImageUrl()).isNull(); assertThat(saved.get(0).getRiskLevel()).isEqualTo(RiskLevel.DANGER); assertThat(saved.get(0).getHitTags()).containsExactly("is_pork"); assertThat(saved.get(0).getDisplayOrder()).isEqualTo(1); @@ -259,10 +275,14 @@ void marksNeedsRetakeAndSkipsRuleEngine() { 1, 0, 0.0, + 1, + 0.0, + 0.42, + null, 100, 100, null, - List.of(), + List.of("카메라의 초점을 맞춰 다시 촬영해 주세요."), List.of("too blurry"), true, true, @@ -285,6 +305,7 @@ void marksNeedsRetakeAndSkipsRuleEngine() { assertThat(session.getScanStatus()).isEqualTo(ScanStatus.NEEDS_RETAKE); assertThat(session.getRetakeReasons()).containsExactly("too blurry"); + assertThat(session.getRetakeSuggestions()).containsExactly("카메라의 초점을 맞춰 다시 촬영해 주세요."); verify(aiClient, never()).judge(any()); verify(menuAnalysisRepository, never()).saveAll(any()); } diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java index 7cebe91..ef3176c 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java @@ -13,6 +13,7 @@ import com.hanspoon.backend_api.domain.scan.dto.ScanHistoryItem; import com.hanspoon.backend_api.domain.scan.dto.StartScanRequest; import com.hanspoon.backend_api.domain.scan.dto.UpdateScanTitleRequest; +import com.hanspoon.backend_api.domain.scan.entity.MenuAnalysis; import com.hanspoon.backend_api.domain.scan.entity.ScanSession; import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; @@ -159,6 +160,37 @@ void getScanReturnsResultForOwner() { assertThat(response.menus()).isEmpty(); } + @Test + void getScanReturnsPersistedMenuDescriptions() { + UUID userId = UUID.randomUUID(); + ScanSession session = ScanSession.create(userId, null, 1, 0, ScanStatus.COMPLETED, Instant.now()); + UUID scanId = session.getId(); + MenuAnalysis menu = MenuAnalysis.create( + scanId, + 1, + "파돈불고기", + "Green Onion Bulgogi", + "파돈불고기 200g과 된장찌개", + "200g green onion bulgogi with soybean paste stew", + "14,000", + false, + null, + null, + List.of(), + null, + null); + when(scanSessionRepository.findByIdAndUserId(scanId, userId)).thenReturn(Optional.of(session)); + when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(scanId)) + .thenReturn(List.of(menu)); + + var response = scanService.getScan(userId, scanId); + + assertThat(response.menus()).singleElement().satisfies(result -> { + assertThat(result.descriptionKo()).isEqualTo("파돈불고기 200g과 된장찌개"); + assertThat(result.descriptionEn()).isEqualTo("200g green onion bulgogi with soybean paste stew"); + }); + } + @Test void getScanReturnsFailureCodeWithoutInternalExceptionDetails() { UUID userId = UUID.randomUUID(); @@ -174,6 +206,22 @@ void getScanReturnsFailureCodeWithoutInternalExceptionDetails() { assertThat(response.failureCode()).isEqualTo("AI_SERVICE_OVERLOADED"); } + @Test + void getScanReturnsRetakeReasonsAndSuggestions() { + UUID userId = UUID.randomUUID(); + ScanSession session = ScanSession.start(userId, "scans/" + userId + "/blurred.jpg"); + session.applyNeedsRetake(List.of("이미지가 흐려 메뉴판 판독이 어렵습니다."), List.of("카메라의 초점을 맞춰 다시 촬영해 주세요.")); + when(scanSessionRepository.findByIdAndUserId(session.getId(), userId)).thenReturn(Optional.of(session)); + when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(session.getId())) + .thenReturn(List.of()); + + var response = scanService.getScan(userId, session.getId()); + + assertThat(response.status()).isEqualTo(ScanStatus.NEEDS_RETAKE); + assertThat(response.retakeReasons()).containsExactly("이미지가 흐려 메뉴판 판독이 어렵습니다."); + assertThat(response.retakeSuggestions()).containsExactly("카메라의 초점을 맞춰 다시 촬영해 주세요."); + } + @Test void getScanReturnsNullTitleWhenNotEditedAndRawScannedAt() { UUID userId = UUID.randomUUID(); diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java index aeba410..2728b3e 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java @@ -52,7 +52,15 @@ void commitsOcrResultAndFailureInIndependentTransactions() { scanStateWriter.applyOcrResult( session.getId(), - MenuImage.create(session.getId(), "upload", storageKey, "s3://test/" + storageKey, "image/jpeg", 123L), + MenuImage.create( + session.getId(), + "upload", + storageKey, + "s3://test/" + storageKey, + "image/jpeg", + 123L, + "version-1", + "\"etag-1\""), 2, Instant.parse("2026-09-11T00:00:00Z"));