diff --git a/scripts/gen_store_erd.py b/scripts/gen_store_erd.py index 10ef267..f1a6bd8 100644 --- a/scripts/gen_store_erd.py +++ b/scripts/gen_store_erd.py @@ -60,7 +60,7 @@ def rr(n): return R[n][0] + TW 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(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) @@ -94,7 +94,7 @@ def rr(n): return R[n][0] + TW ('','지점명','branch_name','VARCHAR(100)','N'), ('GEN','정규화 상호명','name_normalized','VARCHAR(200)','N'), ('SEC','분류'), - ('FK','업종 소분류','category_id','BIGINT','Y'), + ('FK','업종 소분류','category_id','BIGINT','N'), ('FK','표준산업분류','ksic_code','VARCHAR(6)','Y'), ('SEC','위치'), ('','행정동 코드','admin_dong_code','VARCHAR(8)','N'), @@ -107,21 +107,13 @@ def rr(n): return R[n][0] + TW ('','레코드 출처','origin','VARCHAR(20)','N'), ('','비활성 시각','inactive_at','TIMESTAMPTZ','Y'), ('','검증 완료 시각','verified_at','TIMESTAMPTZ','Y'), - ('FK','제출 사용자','submitted_by','UUID','Y'), - ('FK','최종 적재 배치','last_batch_id','BIGINT','Y'), + ('FK','최종 적재 배치','last_batch_id','BIGINT','N'), ('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'), @@ -226,17 +218,15 @@ def link(d, dashed=False): 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' +EX, US, SS = '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(IB)} {ry(IB,0)} H 550 V {ry(ST,20)} 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) diff --git a/scripts/load_stores.py b/scripts/load_stores.py index e4edfdb..1607ec3 100755 --- a/scripts/load_stores.py +++ b/scripts/load_stores.py @@ -5,12 +5,14 @@ 설계 메모 · 표준 라이브러리만 사용. · 전체가 단일 트랜잭션. 중간 실패 시 부분 적재가 남지 않음. - · 사용자 제출 가게(sbiz_store_no IS NULL)는 덮어쓰지 않는다. 사용 예 # 전국 적재 (로컬 docker) python3 scripts/load_stores.py --csv-dir ~/Downloads/소상공인..._20260630 --sweep-inactive + # DB 연결 없이 전국 원본 검증만 수행 + python3 scripts/load_stores.py --csv-dir ... --sweep-inactive --validate-only + # 개발용 일부 지역만 python3 scripts/load_stores.py --csv-dir ... --regions 경북,서울 @@ -23,6 +25,7 @@ from collections import Counter import csv import io +import shlex import shutil import subprocess import unicodedata @@ -45,6 +48,17 @@ "dong": "행정동코드", "addr": "도로명주소", "floor": "층정보", "lng": "경도", "lat": "위도", } +REQUIRED_HEADERS = frozenset(COL.values()) +STORE_FIELD_LIMITS = { + "상가업소번호": 24, + "상호명": 200, + "지점명": 100, + "상권업종소분류코드": 6, + "표준산업분류코드": 6, + "행정동코드": 8, + "도로명주소": 300, + "층정보": 20, +} def parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -60,12 +74,14 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help="로컬 psql 사용 시 접속 문자열") p.add_argument("--container", default="hanspoon-postgres", help="docker 폴백에 사용할 컨테이너 이름") p.add_argument("--out", type=Path, help="실행하지 않고 SQL 을 이 파일에 기록") + p.add_argument("--validate-only", action="store_true", + help="CSV 전체를 검증하고 DB 연결이나 SQL 생성 없이 종료") return p.parse_args(argv) def resolve_psql(a: argparse.Namespace) -> list[str]: if a.psql: - return a.psql.split() + return shlex.split(a.psql) if shutil.which("psql"): return ["psql", a.dsn] if shutil.which("docker"): @@ -76,6 +92,33 @@ def resolve_psql(a: argparse.Namespace) -> list[str]: sys.exit("psql 을 찾지 못했습니다. --psql 로 실행 명령을 직접 지정하세요.") +def record_failed_batch(cmd: list[str], version: str) -> bool: + """본 적재 트랜잭션이 롤백된 뒤 실패 감사 기록을 별도 트랜잭션으로 남긴다. + + 같은 버전의 성공 이력이 이미 있으면 실패한 재실행이 완료 상태를 덮어쓰지 않는다. + DB 자체가 연결 불가한 경우에는 기록도 실패할 수 있으므로 원래 오류를 가리지 않고 False를 반환한다. + """ + sql = f""" +INSERT INTO store_import_batches + (source, source_version, row_count, status, started_at, finished_at) +VALUES ('{SOURCE}', '{version}', 0, 'failed', now(), now()) +ON CONFLICT (source, source_version) DO UPDATE +SET row_count = 0, + status = 'failed', + finished_at = now() +WHERE store_import_batches.status <> 'completed'; +""" + try: + result = subprocess.run( + [*cmd, "-v", "ON_ERROR_STOP=1", "-c", sql], + capture_output=True, + text=True, + ) + except OSError: + return False + return result.returncode == 0 + + def file_metadata(f: Path) -> tuple[str, str]: """파일명 끝의 지역·스냅샷 버전을 읽는다. @@ -143,6 +186,174 @@ def validate_sweep_scope(a: argparse.Namespace, files: list[Path]) -> None: ) +def file_signature(path: Path) -> tuple[int, int]: + stat = path.stat() + return stat.st_size, stat.st_mtime_ns + + +def iter_source_rows(files: list[Path], phase: str): + """모든 CSV를 엄격 모드로 읽고 파일별 헤더·행 구조·읽는 중 변경을 검증한다.""" + for path in files: + before = file_signature(path) + print(f" {phase} {path.name}", file=sys.stderr) + try: + with path.open(encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh, strict=True) + headers = set(reader.fieldnames or []) + missing = sorted(REQUIRED_HEADERS - headers) + if missing: + sys.exit(f"필수 CSV 헤더가 없습니다: {path.name} · {missing}") + + for row in reader: + if None in row or any(value is None for value in row.values()): + sys.exit(f"CSV 열 개수가 맞지 않습니다: {path.name}:{reader.line_num}") + yield path, reader.line_num, row + except UnicodeDecodeError as exc: + raise SystemExit(f"UTF-8 CSV가 아닙니다: {path.name}:{exc.start}") from exc + except csv.Error as exc: + raise SystemExit(f"CSV 형식 오류: {path.name}:{reader.line_num} · {exc}") from exc + + if file_signature(path) != before: + sys.exit(f"검증 중 CSV 파일이 변경되었습니다: {path.name}") + + +def clean(row: dict[str, str], key: str) -> str: + return row[COL[key]].strip() + + +def validate_value(value: str, label: str, limit: int, path: Path, line: int) -> None: + if "\x00" in value: + sys.exit(f"NUL 문자가 포함돼 있습니다: {path.name}:{line} · {label}") + if len(value) > limit: + sys.exit( + f"DB 컬럼 길이를 초과했습니다: {path.name}:{line} · " + f"{label}={len(value)}자(최대 {limit}자)" + ) + + +def prepare_store_row(row: dict[str, str], category: str) -> tuple[list[object] | None, str | None]: + """대상 업종 행을 DB 적재 형태로 바꾸고, 제외 시 정형화된 원인을 반환한다.""" + if clean(row, "l2c") != category: + return None, None + + no = clean(row, "no") + name = clean(row, "name") + category_code = clean(row, "l3c") + if not no: + return None, "missing_id" + if not name: + return None, "missing_name" + if not category_code: + return None, "missing_category" + if not any(char.isalnum() for char in unicodedata.normalize("NFKC", name)): + return None, "empty_normalized_name" + + try: + lat, lng = float(clean(row, "lat")), float(clean(row, "lng")) + except ValueError: + return None, "invalid_coordinate" + if not (33 <= lat <= 39 and 124 <= lng <= 132): + return None, "invalid_coordinate" + + values = { + COL["no"]: no, + COL["name"]: name, + COL["branch"]: clean(row, "branch"), + COL["l3c"]: category_code, + COL["ksicc"]: clean(row, "ksicc"), + COL["dong"]: clean(row, "dong"), + COL["addr"]: clean(row, "addr"), + COL["floor"]: clean(row, "floor"), + } + return [ + no, name, values[COL["branch"]], category_code, values[COL["ksicc"]], + values[COL["dong"]], values[COL["addr"]], values[COL["floor"]], lat, lng, + ], None + + +def validate_source(files: list[Path], category: str, require_lossless: bool) -> dict[str, int]: + """DB 접속 전에 원본 전체를 검증한다. + + 비활성 스윕은 한 행만 제외돼도 정상 가게를 비활성화할 수 있으므로 무손실일 때만 허용한다. + 중복 충돌 키는 PostgreSQL upsert 자체가 실패하므로 적재 모드와 무관하게 차단한다. + """ + stats: Counter[str] = Counter() + seen_ids: set[str] = set() + duplicate_examples: list[str] = [] + categories: dict[str, tuple[str, int, str]] = {} + ksic_codes: dict[str, str] = {} + + for path, line, row in iter_source_rows(files, "검증 중"): + stats["source_rows"] += 1 + + for code_key, name_key, level, parent_key in ( + ("l1c", "l1n", 1, None), + ("l2c", "l2n", 2, "l1c"), + ("l3c", "l3n", 3, "l2c"), + ): + code = clean(row, code_key) + if not code: + continue + name = clean(row, name_key) + parent = clean(row, parent_key) if parent_key else "" + validate_value(code, COL[code_key], 6, path, line) + validate_value(name, COL[name_key], 60, path, line) + definition = (name, level, parent) + if code in categories and categories[code] != definition: + sys.exit(f"업종 코드 정의가 충돌합니다: {path.name}:{line} · {code}") + categories.setdefault(code, definition) + + ksic_code = clean(row, "ksicc") + if ksic_code: + ksic_name = clean(row, "ksicn") + validate_value(ksic_code, COL["ksicc"], 6, path, line) + validate_value(ksic_name, COL["ksicn"], 120, path, line) + if ksic_code in ksic_codes and ksic_codes[ksic_code] != ksic_name: + sys.exit(f"KSIC 코드 정의가 충돌합니다: {path.name}:{line} · {ksic_code}") + ksic_codes.setdefault(ksic_code, ksic_name) + + prepared, reason = prepare_store_row(row, category) + if prepared is None and reason is None: + continue + stats["target_rows"] += 1 + if reason: + stats["skipped"] += 1 + stats[reason] += 1 + continue + + assert prepared is not None + for label, value in zip(STORE_FIELD_LIMITS, prepared[:8], strict=True): + validate_value(str(value), label, STORE_FIELD_LIMITS[label], path, line) + + store_no = str(prepared[0]) + if store_no in seen_ids: + stats["duplicate_ids"] += 1 + if len(duplicate_examples) < 5: + duplicate_examples.append(store_no) + continue + seen_ids.add(store_no) + stats["valid_rows"] += 1 + + if stats["target_rows"] == 0: + sys.exit(f"적재 대상 업종({category}) 행이 없습니다.") + if stats["duplicate_ids"]: + sys.exit( + f"중복 상가업소번호 {stats['duplicate_ids']:,}건이 발견됐습니다. " + f"예시={duplicate_examples}" + ) + if require_lossless and stats["skipped"]: + reasons = {key: value for key, value in stats.items() + if key not in {"source_rows", "target_rows", "valid_rows", "skipped"}} + sys.exit( + f"--sweep-inactive는 무손실 원본에서만 허용됩니다. " + f"제외={stats['skipped']:,}건 · 원인={reasons}" + ) + + stats["categories"] = len(categories) + stats["ksic_codes"] = len(ksic_codes) + return dict(stats) + + 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} @@ -151,6 +362,10 @@ def emit(w: io.TextIOBase, files: list[Path], category: str, sweep: bool, versio w.write(f"""\ \\set ON_ERROR_STOP on BEGIN; +SET LOCAL lock_timeout = '30s'; +SET LOCAL statement_timeout = '30min'; +-- 백엔드 서비스 읽기는 막지 않고, 가게 적재 작업끼리만 직렬화한다. +SELECT pg_advisory_xact_lock(hashtextextended('hanspoon:store-import:sbiz', 0)); INSERT INTO store_import_batches (source, source_version, status, started_at) VALUES ('{SOURCE}', '{version}', 'running', now()) @@ -185,40 +400,24 @@ def copy_block(table: str, rows) -> int: 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, - ] + for _path, _line, r in iter_source_rows(files, "적재 중"): + if clean(r, "l1c"): + cats.setdefault(clean(r, "l1c"), (clean(r, "l1n"), 1, "")) + if clean(r, "l2c"): + cats.setdefault(clean(r, "l2c"), (clean(r, "l2n"), 2, clean(r, "l1c"))) + if clean(r, "l3c"): + cats.setdefault(clean(r, "l3c"), (clean(r, "l3n"), 3, clean(r, "l2c"))) + if clean(r, "ksicc"): + ksic.setdefault(clean(r, "ksicc"), clean(r, "ksicn")) + + prepared, reason = prepare_store_row(r, category) + if prepared is None and reason is None: + continue + if reason: + stat["skipped"] += 1 + continue + assert prepared is not None + yield prepared 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()))) @@ -245,7 +444,7 @@ def store_rows(): 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; +ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, updated_at = now(); INSERT INTO stores ( sbiz_store_no, name, branch_name, category_id, ksic_code, @@ -274,6 +473,22 @@ def store_rows(): 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; +-- JOIN 누락 등으로 staging보다 적게 반영됐는데도 배치가 성공 처리되는 일을 막는다. +SELECT set_config('hanspoon.store_import_batch_id', :'batch_id', true); +DO $store_count_contract$ +DECLARE + expected_count BIGINT := (SELECT count(*) FROM stg_store); + loaded_count BIGINT := ( + SELECT count(*) FROM stores + WHERE last_batch_id = current_setting('hanspoon.store_import_batch_id')::BIGINT + ); +BEGIN + IF loaded_count IS DISTINCT FROM expected_count THEN + RAISE EXCEPTION 'store import count mismatch: expected %, loaded %', expected_count, loaded_count; + END IF; +END +$store_count_contract$; + """) if sweep: @@ -281,7 +496,15 @@ def store_rows(): -- 이번 스냅샷에 없는 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; +WHERE origin = 'sbiz' + AND status = 'active' + AND last_batch_id IS DISTINCT FROM :batch_id + AND category_id IN ( + SELECT child.id + FROM store_categories child + JOIN store_categories parent ON parent.id = child.parent_id + WHERE parent.code = 'I201' + ); """) @@ -290,6 +513,9 @@ def store_rows(): SET status = 'completed', finished_at = now(), row_count = (SELECT count(*) FROM stg_store) WHERE id = :batch_id; +-- ANALYZE까지 성공해야 배치를 완료한다. COMMIT 뒤 실행하면 통계 갱신 실패를 +-- "적재 롤백"으로 오인할 수 있으므로 트랜잭션 안에서 수행한다. +ANALYZE stores; COMMIT; \\echo '── 적재 결과 ──' @@ -297,7 +523,6 @@ def store_rows(): (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 @@ -310,13 +535,24 @@ def main() -> None: print(f"대상 파일 {len(files)}개 · 스냅샷 {version} · 중분류 {a.category}" f"{' · 비활성 스윕 ON' if a.sweep_inactive else ''}", file=sys.stderr) + validation = validate_source(files, a.category, a.sweep_inactive) + print( + f"검증 완료 · 전체 {validation['source_rows']:,} · 대상 {validation['target_rows']:,} " + f"· 유효 {validation['valid_rows']:,} · 제외 {validation.get('skipped', 0):,} " + f"· 분류 {validation['categories']:,} · KSIC {validation['ksic_codes']:,}", + file=sys.stderr, + ) + if a.validate_only: + return + 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) + # DSN에는 비밀번호가 포함될 수 있으므로 실행 파일 이름 외에는 로그에 남기지 않는다. + print(f"실행: {cmd[0]} …", file=sys.stderr) proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, text=True, encoding="utf-8") assert proc.stdin is not None try: @@ -324,10 +560,12 @@ def main() -> None: finally: proc.stdin.close() if proc.wait() != 0: + if not record_failed_batch(cmd, version): + print("경고: 적재 실패 감사 기록을 DB에 남기지 못했습니다.", file=sys.stderr) sys.exit(f"psql 실패 (exit {proc.returncode}) — 트랜잭션은 롤백되었습니다.") print(f"분류 {stat['cats']:,} · KSIC {stat['ksic']:,} · 가게 {stat['rows']:,}" - f" · 좌표 이상으로 제외 {stat['skipped']:,}", file=sys.stderr) + f" · 제외 {stat['skipped']:,}", file=sys.stderr) if __name__ == "__main__": diff --git a/scripts/store_domain.sql b/scripts/store_domain.sql index 50556a7..bb84f96 100644 --- a/scripts/store_domain.sql +++ b/scripts/store_domain.sql @@ -11,7 +11,7 @@ -- -- 데이터 타입 근거: 전국 CSV 실측 최대 길이 -- 상가업소번호 20 · 상호명 32 · 지점명 9 · 도로명주소 34 · 층정보 4 · 행정동코드 8 --- (사용자 제출 가게를 감안해 여유를 둔 값으로 지정) +-- 향후 다른 공공데이터 원천을 수용할 여유를 둔 값으로 지정. -- 반경 검색(GiST) / 상호명 유사도(GIN trigram)에 필요. -- AWS RDS PostgreSQL 16. 세 확장 모두 RDS 지원 목록에 있고, 접속 계정(hanspoon_app)이 @@ -107,7 +107,13 @@ CREATE TABLE store_import_batches ( 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')) + CONSTRAINT ck_store_import_batches_version CHECK (source_version ~ '^[0-9]{6}$'), + CONSTRAINT ck_store_import_batches_status CHECK (status IN ('running', 'completed', 'failed')), + CONSTRAINT ck_store_import_batches_row_count CHECK (row_count >= 0), + CONSTRAINT ck_store_import_batches_finished_at CHECK ( + (status = 'running' AND finished_at IS NULL) + OR (status IN ('completed', 'failed') AND finished_at IS NOT NULL) + ) ); -- ───────────────────────────────────────────────────────────── @@ -116,7 +122,7 @@ CREATE TABLE store_import_batches ( CREATE TABLE stores ( id BIGINT GENERATED ALWAYS AS IDENTITY, - -- 원천 식별자. 멱등 upsert 키. localdata/user_submitted 출처면 NULL + -- 원천 식별자. 멱등 upsert 키. localdata 출처면 NULL -- (PostgreSQL UNIQUE 는 NULL 을 서로 다른 값으로 보므로 다중 NULL 허용). sbiz_store_no VARCHAR(24) NULL, @@ -126,9 +132,8 @@ CREATE TABLE stores ( -- 매칭용 정규형. 애플리케이션이 따로 채우지 않도록 생성 컬럼으로 둔다. name_normalized VARCHAR(200) GENERATED ALWAYS AS (normalize_store_name(name)) STORED, - -- 분류 - -- 공공데이터 행은 항상 분류가 있지만, 신규 사용자 제보는 검증 전까지 분류를 모를 수 있다. - category_id BIGINT NULL, + -- 분류. 공공데이터 원천만 수용하므로 업종 분류와 적재 배치를 반드시 추적한다. + category_id BIGINT NOT NULL, ksic_code VARCHAR(6) NULL, -- 원천 결측 존재(전국 한식 343건) -- 위치. 행정동은 코드만 보존한다 — 행정동'명'을 함께 저장하지 않으므로 이행 종속이 없고, @@ -145,8 +150,7 @@ CREATE TABLE stores ( inactive_at TIMESTAMPTZ NULL, -- 공공데이터 수록 여부와 서비스의 검증 완료는 다른 개념이다. 실제 검증 전에는 NULL. verified_at TIMESTAMPTZ NULL, - submitted_by UUID NULL, -- origin='user_submitted' 인 경우의 제보자 - last_batch_id BIGINT NULL, + last_batch_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -159,29 +163,24 @@ CREATE TABLE stores ( 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건. 사용자 제출 가게의 오입력을 막는 방어선. + -- 원천 실측 이상치 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_name CHECK (btrim(name) <> '' AND name = btrim(name)), + CONSTRAINT ck_stores_name_normalized CHECK (name_normalized <> ''), CONSTRAINT ck_stores_status CHECK (status IN ('active', 'inactive')), - CONSTRAINT ck_stores_origin CHECK (origin IN ('sbiz', 'localdata', 'user_submitted')), + CONSTRAINT ck_stores_origin CHECK (origin IN ('sbiz', 'localdata')), 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') + CONSTRAINT ck_stores_sbiz_identity CHECK ((origin = 'sbiz') = (sbiz_store_no IS NOT NULL)) ); -- 반경 후보 검색. 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_name_trgm ON stores USING gin (name_normalized gin_trgm_ops) + WHERE status = 'active'; CREATE INDEX idx_stores_category ON stores (category_id) WHERE status = 'active'; CREATE INDEX idx_stores_batch ON stores (last_batch_id); @@ -191,27 +190,6 @@ 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, @@ -221,7 +199,14 @@ CREATE TABLE brand_aliases ( 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) + CONSTRAINT uq_brand_aliases_variant UNIQUE (variant_normalized), + CONSTRAINT ck_brand_aliases_values CHECK ( + variant_normalized <> '' + AND canonical_normalized <> '' + AND variant_normalized = normalize_store_name(variant_normalized) + AND canonical_normalized = normalize_store_name(canonical_normalized) + AND variant_normalized <> canonical_normalized + ) ); -- ───────────────────────────────────────────────────────────── @@ -244,7 +229,10 @@ CREATE TABLE store_external_refs ( 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')) + CONSTRAINT ck_store_external_refs_provider CHECK (provider IN ('kakao')), + CONSTRAINT ck_store_external_refs_external_id CHECK ( + btrim(external_id) <> '' AND external_id = btrim(external_id) + ) ); COMMENT ON TABLE store_external_refs IS @@ -270,7 +258,7 @@ ALTER TABLE scan_sessions 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')), + ('gps_candidate', 'name_search', 'kakao_fallback')), 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 diff --git a/scripts/test_load_stores.py b/scripts/test_load_stores.py index b9e6130..3645e2a 100644 --- a/scripts/test_load_stores.py +++ b/scripts/test_load_stores.py @@ -1,7 +1,11 @@ import argparse +import csv import io +import tempfile import unittest from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch from scripts import load_stores @@ -63,12 +67,83 @@ def test_inactive_sweep_sql_does_not_claim_store_is_closed(self): self.assertIn("inactive_at = now()", sql) self.assertNotIn("is_verified", sql) self.assertNotIn("status = 'closed'", sql) + self.assertIn("parent.code = 'I201'", sql) + + def test_import_sql_serializes_loaders_and_analyzes_before_commit(self): + output = io.StringIO() + + load_stores.emit(output, [], load_stores.MIDDLE_CATEGORY, False, "202606") + + sql = output.getvalue() + self.assertIn("pg_advisory_xact_lock", sql) + self.assertIn("store import count mismatch", sql) + self.assertLess(sql.index("ANALYZE stores;"), sql.index("COMMIT;")) 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) + def test_custom_psql_command_preserves_quoted_arguments(self): + args = SimpleNamespace(psql='psql "postgresql://user:p w@localhost/db"') + + self.assertEqual( + ["psql", "postgresql://user:p w@localhost/db"], + load_stores.resolve_psql(args), + ) + + @patch("scripts.load_stores.subprocess.run") + def test_failed_batch_is_recorded_without_overwriting_completed_batch(self, run): + run.return_value = SimpleNamespace(returncode=0) + + recorded = load_stores.record_failed_batch(["psql", "postgresql://secret-dsn"], "202606") + + self.assertTrue(recorded) + command = run.call_args.args[0] + self.assertEqual("psql", command[0]) + self.assertIn("ON_ERROR_STOP=1", command) + sql = command[-1] + self.assertIn("status = 'failed'", sql) + self.assertIn("status <> 'completed'", sql) + + @patch("scripts.load_stores.subprocess.run") + def test_failed_batch_recording_failure_does_not_raise(self, run): + run.return_value = SimpleNamespace(returncode=1) + + self.assertFalse(load_stores.record_failed_batch(["psql"], "202606")) + + @patch("scripts.load_stores.subprocess.run", side_effect=OSError("psql disappeared")) + def test_failed_batch_recording_process_error_does_not_mask_original_failure(self, run): + self.assertFalse(load_stores.record_failed_batch(["psql"], "202606")) + + def test_source_validation_accepts_valid_target_row(self): + with self.csv_file([self.valid_row()]) as path: + stats = load_stores.validate_source([path], load_stores.MIDDLE_CATEGORY, True) + + self.assertEqual(1, stats["source_rows"]) + self.assertEqual(1, stats["target_rows"]) + self.assertEqual(1, stats["valid_rows"]) + + def test_source_validation_rejects_duplicate_store_ids(self): + row = self.valid_row() + with self.csv_file([row, row]) as path: + with self.assertRaisesRegex(SystemExit, "중복 상가업소번호"): + load_stores.validate_source([path], load_stores.MIDDLE_CATEGORY, False) + + def test_lossless_validation_rejects_invalid_coordinate(self): + row = self.valid_row() + row[load_stores.COL["lat"]] = "" + with self.csv_file([row]) as path: + with self.assertRaisesRegex(SystemExit, "무손실 원본"): + load_stores.validate_source([path], load_stores.MIDDLE_CATEGORY, True) + + def test_source_validation_rejects_missing_header(self): + row = self.valid_row() + del row[load_stores.COL["lat"]] + with self.csv_file([row]) as path: + with self.assertRaisesRegex(SystemExit, "필수 CSV 헤더"): + load_stores.validate_source([path], load_stores.MIDDLE_CATEGORY, False) + @staticmethod def args(**overrides) -> argparse.Namespace: values = { @@ -84,6 +159,44 @@ 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)] + @staticmethod + def valid_row() -> dict[str, str]: + return { + load_stores.COL["no"]: "MA010120220800000001", + load_stores.COL["name"]: "한스푼", + load_stores.COL["branch"]: "강남점", + load_stores.COL["l1c"]: "I2", + load_stores.COL["l1n"]: "음식", + load_stores.COL["l2c"]: "I201", + load_stores.COL["l2n"]: "한식", + load_stores.COL["l3c"]: "I20101", + load_stores.COL["l3n"]: "백반/한정식", + load_stores.COL["ksicc"]: "I56111", + load_stores.COL["ksicn"]: "한식 일반 음식점업", + load_stores.COL["dong"]: "11680640", + load_stores.COL["addr"]: "서울특별시 강남구 테헤란로 1", + load_stores.COL["floor"]: "1", + load_stores.COL["lng"]: "127.0276", + load_stores.COL["lat"]: "37.4979", + } + + @staticmethod + def csv_file(rows: list[dict[str, str]]): + class CsvFixture: + def __enter__(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.path = Path(self.temp_dir.name) / "상가_정보_서울_202606.csv" + with self.path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + return self.path + + def __exit__(self, exc_type, exc_value, traceback): + self.temp_dir.cleanup() + + return CsvFixture() + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_store_domain_migration.py b/scripts/test_store_domain_migration.py new file mode 100644 index 0000000..b49836a --- /dev/null +++ b/scripts/test_store_domain_migration.py @@ -0,0 +1,16 @@ +import unittest +from pathlib import Path + + +class StoreDomainMigrationSyncTest(unittest.TestCase): + + def test_staging_ddl_matches_final_flyway_migration(self): + project_root = Path(__file__).resolve().parents[1] + staging = project_root / "scripts" / "store_domain.sql" + migration = project_root / "src" / "main" / "resources" / "db" / "migration" / "V5__store_domain.sql" + + self.assertEqual(staging.read_text(encoding="utf-8"), migration.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/OcrRequest.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/OcrRequest.java index 4724a93..9c4f6f7 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/OcrRequest.java +++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/OcrRequest.java @@ -6,6 +6,7 @@ /** * OCR 요청 (백엔드 → AI 서비스). 검증된 S3 객체 식별 정보를 전달한다. * + * @param storeId 스캔 세션에 고정된 가게 ID * @param source 이미지 소스 ("camera" | "upload") * @param storageKey 스토리지 키 (예: "scans/menu_003.jpg") * @param imageUrl 레거시 호환 필드. 운영 S3 IAM 경로에서는 null @@ -13,9 +14,15 @@ * @param expectedEtag 검증 시점의 S3 객체 ETag */ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public record OcrRequest(String source, String storageKey, String imageUrl, String versionId, String expectedEtag) { +public record OcrRequest( + Long storeId, String source, String storageKey, String imageUrl, String versionId, String expectedEtag) { public static OcrRequest forS3( - String source, String storageKey, String fallbackImageUrl, String versionId, String expectedEtag) { - return new OcrRequest(source, storageKey, fallbackImageUrl, versionId, expectedEtag); + Long storeId, + String source, + String storageKey, + String fallbackImageUrl, + String versionId, + String expectedEtag) { + return new OcrRequest(storeId, source, storageKey, fallbackImageUrl, versionId, expectedEtag); } } diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanSession.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanSession.java index 85e5b06..d1c2f7c 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanSession.java +++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanSession.java @@ -7,6 +7,7 @@ /** * scan_sessions 테이블 매핑. riskyMenuCount 는 OCR 단계에선 null, 룰엔진이 채운다. * + * @param storeId 가게 ID * @param title 원본 이미지 파일명 * @param menuCount 추출된 메뉴 수 * @param riskyMenuCount 위험/주의 메뉴 수 (OCR 단계 null) @@ -16,4 +17,4 @@ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) public record ScanSession( - String title, Integer menuCount, Integer riskyMenuCount, String scanStatus, String scannedAt) {} + Long storeId, String title, Integer menuCount, Integer riskyMenuCount, String scanStatus, String scannedAt) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/result/FinalResultResponse.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/result/FinalResultResponse.java index beb0cdb..50e8546 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/result/FinalResultResponse.java +++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/result/FinalResultResponse.java @@ -1,16 +1,18 @@ package com.hanspoon.backend_api.domain.ai.dto.result; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession; import java.util.List; import tools.jackson.databind.PropertyNamingStrategies; import tools.jackson.databind.annotation.JsonNaming; /** * ai_result(`POST /v1/result`) 응답. AI 는 judged_result 전체(scan_session/menu_image/scan_quality 포함)를 돌려주지만, - * 백엔드는 menu_analyses(=FinalMenu) 만 소비하므로 그 외 키는 {@code @JsonIgnoreProperties} 로 무시한다. + * 백엔드는 가게 컨텍스트 동일성을 확인한 뒤 menu_analyses(=FinalMenu)를 소비한다. * + * @param scanSession 전체 파이프라인에서 보존된 가게 컨텍스트 * @param menuAnalyses 최종 표시용 메뉴 목록 */ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) -public record FinalResultResponse(List menuAnalyses) {} +public record FinalResultResponse(ScanSession scanSession, List menuAnalyses) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ruleengine/RuleEngineRequest.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ruleengine/RuleEngineRequest.java index a71da56..d2abada 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ruleengine/RuleEngineRequest.java +++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ruleengine/RuleEngineRequest.java @@ -8,8 +8,9 @@ * 룰엔진 요청 (백엔드 → AI 서비스). {@code engine.analyze_all(ocr_result, profile)} 가 OCR 결과 * 전체를 입력으로 받으므로 OCR 응답을 그대로 실어 전달한다. * + * @param storeId 스캔 세션과 일치해야 하는 가게 ID * @param profile 사용자 프로필 * @param ocrResult OCR 응답 전체 */ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public record RuleEngineRequest(RuleProfile profile, OcrResponse ocrResult) {} +public record RuleEngineRequest(Long storeId, RuleProfile profile, OcrResponse ocrResult) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanHistoryItem.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanHistoryItem.java index 907c7e2..0a9c5dc 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanHistoryItem.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanHistoryItem.java @@ -11,6 +11,7 @@ * * @param scanId 스캔 세션 id * @param title 유저 편집 제목(미편집이면 null → FE 가 scannedAt 로케일 포맷) + * @param store 스캔 시점 가게 정보. 가게 도입 전 레거시 스캔은 null * @param status 스캔 상태 * @param menuCount 추출 메뉴 수 * @param riskyMenuCount 위험/주의 메뉴 수 @@ -18,15 +19,28 @@ */ @Schema(description = "스캔 이력 항목") public record ScanHistoryItem( - UUID scanId, String title, ScanStatus status, Integer menuCount, Integer riskyMenuCount, Instant scannedAt) { + UUID scanId, + String title, + ScanStoreSummary store, + ScanStatus status, + Integer menuCount, + Integer riskyMenuCount, + Instant scannedAt) { public static ScanHistoryItem from(ScanSession session) { return new ScanHistoryItem( session.getId(), session.getTitle(), + toStoreSummary(session), session.getScanStatus(), session.getMenuCount(), session.getRiskyMenuCount(), session.getScannedAt()); } + + private static ScanStoreSummary toStoreSummary(ScanSession session) { + return session.getStoreId() == null + ? null + : new ScanStoreSummary(session.getStoreId(), session.getStoreNameSnapshot()); + } } 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 15e6744..adb059e 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 @@ -12,6 +12,7 @@ * @param scanId 스캔 세션 id * @param status 스캔 상태 (processing | completed | failed | needs_retake) * @param title 유저가 수정한 제목(미수정이면 null). 기본 제목은 FE 가 scannedAt 을 로케일로 포맷해 표시 + * @param store 스캔 시점 가게 정보. 가게 도입 전 레거시 스캔은 null * @param menuCount 추출된 메뉴 수 * @param riskyMenuCount 위험/주의 메뉴 수 * @param scannedAt 스캔 시각 @@ -25,6 +26,7 @@ public record ScanResultResponse( UUID scanId, ScanStatus status, String title, + ScanStoreSummary store, Integer menuCount, Integer riskyMenuCount, Instant scannedAt, diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanStoreSummary.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanStoreSummary.java new file mode 100644 index 0000000..adaf2cf --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanStoreSummary.java @@ -0,0 +1,9 @@ +package com.hanspoon.backend_api.domain.scan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** 스캔 시점에 동결된 가게 정보. 가게명이 바뀌어도 과거 이력은 변하지 않는다. */ +@Schema(description = "스캔 가게 정보") +public record ScanStoreSummary( + @Schema(description = "가게 ID", example = "10342") Long storeId, + @Schema(description = "스캔 시점 상호명", example = "한스푼") String name) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java index 040c07b..e0b7c2d 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java @@ -1,8 +1,12 @@ package com.hanspoon.backend_api.domain.scan.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; /** * 스캔 시작 요청. 이미지는 presigned URL 로 S3 에 업로드된 상태이고, 그 객체 키를 넘긴다. @@ -11,9 +15,19 @@ * * @param storageKey 업로드된 S3 객체 키 (예: scans/{userId}/{uuid}.jpg) * @param source 이미지 소스 (camera | upload), 선택 + * @param storeId 사용자가 선택한 가게 ID. 가게를 연결하지 않으면 생략 + * @param storeMatchMethod 가게 후보를 찾은 경로. storeId와 함께 입력하거나 함께 생략 */ @Schema(description = "스캔 시작 요청") public record StartScanRequest( @Schema(description = "업로드된 S3 객체 키", example = "scans/3f2a.../9f3c....jpg") @NotBlank String storageKey, @Schema(description = "이미지 소스", example = "upload") - @Pattern(regexp = "camera|upload", message = "source must be camera or upload") String source) {} + @Pattern(regexp = "camera|upload", message = "source must be camera or upload") String source, + @Schema(description = "선택한 가게 ID(선택)", example = "10342") @Positive Long storeId, + @Schema(description = "가게 후보 검색 경로(선택)", example = "gps_candidate") StoreMatchMethod storeMatchMethod) { + + @AssertTrue(message = "storeId and storeMatchMethod must be provided together") @JsonIgnore + public boolean isStoreContextComplete() { + return (storeId == null) == (storeMatchMethod == null); + } +} 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 85301d4..3d4d2aa 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 @@ -1,5 +1,6 @@ package com.hanspoon.backend_api.domain.scan.entity; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; import com.hanspoon.backend_api.global.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -8,6 +9,7 @@ import jakarta.persistence.Version; import java.time.Instant; import java.util.List; +import java.util.Objects; import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; @@ -36,6 +38,16 @@ public class ScanSession extends BaseEntity { @Column(name = "storage_key", length = 512) private String storageKey; + @Column(name = "store_id") + private Long storeId; + + /** 가게명이 변경되거나 비활성화돼도 과거 스캔 이력을 보존하는 서버 생성 스냅샷. */ + @Column(name = "store_name_snapshot", length = 200) + private String storeNameSnapshot; + + @Column(name = "store_match_method", length = 20) + private StoreMatchMethod storeMatchMethod; + @Column(name = "title", length = 255) private String title; @@ -70,6 +82,9 @@ public class ScanSession extends BaseEntity { private ScanSession( UUID userId, String storageKey, + Long storeId, + String storeNameSnapshot, + StoreMatchMethod storeMatchMethod, String title, Integer menuCount, Integer riskyMenuCount, @@ -78,6 +93,9 @@ private ScanSession( this.id = UUID.randomUUID(); this.userId = userId; this.storageKey = storageKey; + this.storeId = storeId; + this.storeNameSnapshot = storeNameSnapshot; + this.storeMatchMethod = storeMatchMethod; this.title = title; this.menuCount = menuCount; this.riskyMenuCount = riskyMenuCount; @@ -92,11 +110,36 @@ public static ScanSession create( Integer riskyMenuCount, ScanStatus scanStatus, Instant scannedAt) { - return new ScanSession(userId, null, title, menuCount, riskyMenuCount, scanStatus, scannedAt); + return new ScanSession(userId, null, null, null, null, title, menuCount, riskyMenuCount, scanStatus, scannedAt); + } + + public static ScanSession start( + UUID userId, String storageKey, Long storeId, String storeNameSnapshot, StoreMatchMethod storeMatchMethod) { + return new ScanSession( + userId, + storageKey, + storeId, + storeNameSnapshot, + storeMatchMethod, + null, + null, + null, + ScanStatus.PROCESSING, + null); + } + + /** 사용자가 가게 연결을 건너뛴 신규 스캔. 세 가게 컨텍스트 컬럼은 모두 null로 유지한다. */ + public static ScanSession startWithoutStore(UUID userId, String storageKey) { + return new ScanSession(userId, storageKey, null, null, null, null, null, null, ScanStatus.PROCESSING, null); + } + + /** 가게 도입 전 세션을 재현하는 테스트·마이그레이션 전용 팩토리. 신규 스캔에서 사용하지 않는다. */ + public static ScanSession startLegacy(UUID userId, String storageKey) { + return new ScanSession(userId, storageKey, null, null, null, null, null, null, ScanStatus.PROCESSING, null); } - public static ScanSession start(UUID userId, String storageKey) { - return new ScanSession(userId, storageKey, null, null, null, ScanStatus.PROCESSING, null); + public boolean hasSameStoreContext(Long storeId, StoreMatchMethod storeMatchMethod) { + return Objects.equals(this.storeId, storeId) && this.storeMatchMethod == storeMatchMethod; } /** OCR 완료 후 메뉴 수/스캔 시각 반영. */ 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 e1700b7..1cc48f2 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 @@ -11,6 +11,7 @@ import com.hanspoon.backend_api.domain.ai.mapper.AiProfileMapper; import com.hanspoon.backend_api.domain.scan.entity.MenuAnalysis; import com.hanspoon.backend_api.domain.scan.entity.MenuImage; +import com.hanspoon.backend_api.domain.scan.entity.ScanSession; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload; import com.hanspoon.backend_api.domain.upload.service.S3StorageService; @@ -76,20 +77,23 @@ public ScanProcessor( public void process(UUID scanId, UUID userId, VerifiedUpload upload, String source) { long processingStartedAt = System.nanoTime(); try { - if (!scanSessionRepository.existsById(scanId)) { + ScanSession session = scanSessionRepository.findById(scanId).orElse(null); + if (session == null) { log.warn("Scan session not found, skip processing: {}", scanId); return; } + Long storeId = session.getStoreId(); String storageKey = upload.storageKey(); String fallbackImageUrl = presignedFallbackEnabled ? s3StorageService.createReadUrl(storageKey, upload.versionId()) : null; long ocrStartedAt = System.nanoTime(); OcrResponse ocr = aiClient.requestOcr( - OcrRequest.forS3(source, storageKey, fallbackImageUrl, upload.versionId(), upload.eTag())); + OcrRequest.forS3(storeId, source, storageKey, fallbackImageUrl, upload.versionId(), upload.eTag())); if (ocr == null) { throw new BusinessException(ErrorCode.OCR_SERVICE_ERROR, "OCR service returned an empty response."); } + validateStoreContext(storeId, ocr.scanSession(), "ocr"); logOcrCompleted(scanId, elapsedMillis(ocrStartedAt), ocr); scanStateWriter.applyOcrResult( @@ -107,14 +111,20 @@ public void process(UUID scanId, UUID userId, VerifiedUpload upload, String sour RuleProfile ruleProfile = loadRuleProfile(userId); long ruleEngineStartedAt = System.nanoTime(); - RuleEngineResponse judged = aiClient.judge(new RuleEngineRequest(ruleProfile, ocr)); + RuleEngineResponse judged = aiClient.judge(new RuleEngineRequest(storeId, ruleProfile, ocr)); if (judged == null) { throw new BusinessException(ErrorCode.RULE_ENGINE_ERROR, "Rule engine returned an empty response."); } + validateStoreContext(storeId, judged.scanSession(), "rule_engine"); logStageCompleted(scanId, "rule_engine", ruleEngineStartedAt); long resultStartedAt = System.nanoTime(); FinalResultResponse finalResult = aiClient.result(judged); + if (finalResult == null) { + throw new BusinessException( + ErrorCode.RESULT_SERVICE_ERROR, "Result service returned an empty response."); + } + validateStoreContext(storeId, finalResult.scanSession(), "result"); logStageCompleted(scanId, "result", resultStartedAt); Integer riskyCount = @@ -229,6 +239,14 @@ private BusinessException resultMismatch(String field, Object ocrValue, Object f "AI result mismatch: " + field + " (ocr=" + ocrValue + ", final=" + finalValue + ")."); } + private void validateStoreContext( + Long expectedStoreId, com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession aiSession, String stage) { + Long actualStoreId = aiSession == null ? null : aiSession.storeId(); + if (!Objects.equals(expectedStoreId, actualStoreId)) { + throw resultMismatch(stage + " store_id", expectedStoreId, actualStoreId); + } + } + private String normalizeName(String value) { return value == null ? "" : value.trim(); } 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 b7f4c62..1209e08 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 @@ -4,6 +4,7 @@ import com.hanspoon.backend_api.domain.scan.dto.ScanCreatedResponse; import com.hanspoon.backend_api.domain.scan.dto.ScanHistoryItem; import com.hanspoon.backend_api.domain.scan.dto.ScanResultResponse; +import com.hanspoon.backend_api.domain.scan.dto.ScanStoreSummary; 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; @@ -11,6 +12,9 @@ import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; +import com.hanspoon.backend_api.domain.store.entity.Store; +import com.hanspoon.backend_api.domain.store.entity.StoreStatus; +import com.hanspoon.backend_api.domain.store.repository.StoreRepository; import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload; import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.global.common.PageResponse; @@ -34,6 +38,7 @@ public class ScanService { private final S3StorageService s3StorageService; private final ScanSessionRepository scanSessionRepository; private final MenuAnalysisRepository menuAnalysisRepository; + private final StoreRepository storeRepository; private final ScanProcessor scanProcessor; private final ScanStateWriter scanStateWriter; @@ -41,11 +46,13 @@ public ScanService( S3StorageService s3StorageService, ScanSessionRepository scanSessionRepository, MenuAnalysisRepository menuAnalysisRepository, + StoreRepository storeRepository, ScanProcessor scanProcessor, ScanStateWriter scanStateWriter) { this.s3StorageService = s3StorageService; this.scanSessionRepository = scanSessionRepository; this.menuAnalysisRepository = menuAnalysisRepository; + this.storeRepository = storeRepository; this.scanProcessor = scanProcessor; this.scanStateWriter = scanStateWriter; } @@ -57,9 +64,15 @@ public ScanCreatedResponse startScan(UUID userId, StartScanRequest request) { // storageKey는 서버가 발급하고 덮어쓰기가 금지된 객체 키이므로 스캔 멱등 키로 사용할 수 있다. var existing = scanSessionRepository.findByUserIdAndStorageKey(userId, storageKey); if (existing.isPresent()) { - return toCreatedResponse(existing.get()); + return existingResponse(existing.get(), request); } + Store store = request.storeId() == null + ? null + : storeRepository + .findByIdAndStatus(request.storeId(), StoreStatus.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + // presigned PUT 은 서버가 내용을 모르므로 실제 업로드 여부·크기·타입을 여기서 확인한다. // 비동기로 넘긴 뒤 실패하면 사용자는 폴링만 하다 FAILED 를 받게 된다. VerifiedUpload verifiedUpload = s3StorageService.verifyUploadObject(storageKey); @@ -68,11 +81,15 @@ public ScanCreatedResponse startScan(UUID userId, StartScanRequest request) { ScanSession session; try { // DB 고유 인덱스가 동시에 들어온 동일 요청까지 방어한다. - session = scanSessionRepository.saveAndFlush(ScanSession.start(userId, storageKey)); + session = scanSessionRepository.saveAndFlush( + store == null + ? ScanSession.startWithoutStore(userId, storageKey) + : ScanSession.start( + userId, storageKey, store.getId(), store.getName(), request.storeMatchMethod())); } catch (DataIntegrityViolationException exception) { return scanSessionRepository .findByUserIdAndStorageKey(userId, storageKey) - .map(ScanService::toCreatedResponse) + .map(winner -> existingResponse(winner, request)) .orElseThrow(() -> exception); } @@ -100,6 +117,7 @@ public ScanResultResponse getScan(UUID userId, UUID scanId) { session.getId(), session.getScanStatus(), session.getTitle(), + toStoreSummary(session), session.getMenuCount(), session.getRiskyMenuCount(), session.getScannedAt(), @@ -154,4 +172,17 @@ private static MenuResult toMenuResult(MenuAnalysis m) { private static ScanCreatedResponse toCreatedResponse(ScanSession session) { return new ScanCreatedResponse(session.getId(), session.getScanStatus()); } + + private static ScanCreatedResponse existingResponse(ScanSession existing, StartScanRequest request) { + if (!existing.hasSameStoreContext(request.storeId(), request.storeMatchMethod())) { + throw new BusinessException(ErrorCode.SCAN_REQUEST_CONFLICT); + } + return toCreatedResponse(existing); + } + + private static ScanStoreSummary toStoreSummary(ScanSession session) { + return session.getStoreId() == null + ? null + : new ScanStoreSummary(session.getStoreId(), session.getStoreNameSnapshot()); + } } diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/controller/StoreController.java b/src/main/java/com/hanspoon/backend_api/domain/store/controller/StoreController.java new file mode 100644 index 0000000..a2140d9 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/controller/StoreController.java @@ -0,0 +1,30 @@ +package com.hanspoon.backend_api.domain.store.controller; + +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateListResponse; +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateSearchRequest; +import com.hanspoon.backend_api.domain.store.service.StoreSearchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Store", description = "현재 위치 기반 가게 후보 검색 API") +@RestController +@RequestMapping("/api/v1/stores") +public class StoreController { + + private final StoreSearchService storeSearchService; + + public StoreController(StoreSearchService storeSearchService) { + this.storeSearchService = storeSearchService; + } + + @Operation(summary = "자체 DB에서 현재 위치 주변의 가게 후보 조회. GPS 로그 노출 방지를 위해 JSON 본문 사용") + @PostMapping("/candidates") + public StoreCandidateListResponse findCandidates(@Valid @RequestBody StoreCandidateSearchRequest request) { + return storeSearchService.findCandidates(request); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateListResponse.java b/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateListResponse.java new file mode 100644 index 0000000..95a6e0a --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateListResponse.java @@ -0,0 +1,13 @@ +package com.hanspoon.backend_api.domain.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** 자체 DB 가게 후보 목록. 빈 목록이면 클라이언트가 Kakao fallback을 요청할 수 있다. */ +@Schema(description = "가게 후보 목록") +public record StoreCandidateListResponse(List items) { + + public StoreCandidateListResponse { + items = List.copyOf(items); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateResponse.java b/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateResponse.java new file mode 100644 index 0000000..5d3aa59 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateResponse.java @@ -0,0 +1,19 @@ +package com.hanspoon.backend_api.domain.store.dto; + +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; +import io.swagger.v3.oas.annotations.media.Schema; + +/** 사용자가 선택할 수 있는 가게 후보. 내부 검색 점수는 API 계약에 노출하지 않는다. */ +@Schema(description = "가게 후보") +public record StoreCandidateResponse( + @Schema(description = "가게 ID", example = "10342") Long storeId, + @Schema(description = "상호명", example = "한스푼") String name, + @Schema(description = "지점명", example = "강남점") String branchName, + @Schema(description = "도로명 주소", example = "서울특별시 강남구 테헤란로 1") String roadAddress, + @Schema(description = "가게 위도", example = "37.4978") double latitude, + @Schema(description = "가게 경도", example = "127.0275") double longitude, + @Schema(description = "사용자 위치와의 거리(m)", example = "42") int distanceMeters, + @Schema(description = "상권업종 소분류 코드", example = "I20101") String categoryCode, + @Schema(description = "상권업종 소분류명", example = "한식 일반 음식점업") String categoryName, + @Schema(description = "서비스 검증 완료 여부", example = "true") boolean verified, + @Schema(description = "후보 검색 경로", example = "gps_candidate") StoreMatchMethod matchMethod) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateSearchRequest.java b/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateSearchRequest.java new file mode 100644 index 0000000..88c004b --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/dto/StoreCandidateSearchRequest.java @@ -0,0 +1,38 @@ +package com.hanspoon.backend_api.domain.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** 사용자 위치 주변의 가게 후보 검색 조건. 위치값은 검색에만 사용하고 저장하지 않는다. */ +@Schema(description = "가게 후보 검색 조건") +public record StoreCandidateSearchRequest( + @Schema(description = "사용자 현재 위도", example = "37.4979") + @NotNull @DecimalMin(value = "33.0", message = "latitude must be at least 33.0") @DecimalMax(value = "39.0", message = "latitude must be at most 39.0") Double latitude, + @Schema(description = "사용자 현재 경도", example = "127.0276") + @NotNull @DecimalMin(value = "124.0", message = "longitude must be at least 124.0") @DecimalMax(value = "132.0", message = "longitude must be at most 132.0") Double longitude, + @Schema(description = "선택적 상호명 검색어", example = "한스푼") @Size(max = 100) String query, + @Schema(description = "검색 반경(m), 기본 100", example = "100") + @Min(value = 10, message = "radiusMeters must be at least 10") @Max(value = 1000, message = "radiusMeters must be at most 1000") Integer radiusMeters, + @Schema(description = "최대 후보 수, 기본·최대 20", example = "20") + @Min(value = 1, message = "limit must be at least 1") @Max(value = 20, message = "limit must be at most 20") Integer limit) { + + private static final int DEFAULT_RADIUS_METERS = 100; + private static final int DEFAULT_LIMIT = 20; + + public int resolvedRadiusMeters() { + return radiusMeters == null ? DEFAULT_RADIUS_METERS : radiusMeters; + } + + public int resolvedLimit() { + return limit == null ? DEFAULT_LIMIT : limit; + } + + public String trimmedQuery() { + return query == null || query.isBlank() ? null : query.strip(); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/entity/Store.java b/src/main/java/com/hanspoon/backend_api/domain/store/entity/Store.java new file mode 100644 index 0000000..945acb6 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/entity/Store.java @@ -0,0 +1,83 @@ +package com.hanspoon.backend_api.domain.store.entity; + +import com.hanspoon.backend_api.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 상가정보와 LOCALDATA 등 공공 원천을 통합한 가게 마스터. */ +@Entity +@Table(name = "stores") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Store extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "sbiz_store_no", length = 24, unique = true) + private String sbizStoreNo; + + @Column(name = "name", length = 200, nullable = false) + private String name; + + @Column(name = "branch_name", length = 100, nullable = false) + private String branchName; + + /** DB 생성 컬럼이므로 애플리케이션에서는 읽기만 한다. 사용자 화면에는 {@link #name}을 사용한다. */ + @Column(name = "name_normalized", length = 200, nullable = false, insertable = false, updatable = false) + private String nameNormalized; + + // 대량 후보 조회에서 불필요한 연관 엔티티 로딩을 막기 위해 FK를 값으로 매핑한다. + @Column(name = "category_id", nullable = false) + private Long categoryId; + + @Column(name = "ksic_code", length = 6) + private String ksicCode; + + @Column(name = "admin_dong_code", length = 8, nullable = false) + private String adminDongCode; + + @Column(name = "road_address", length = 300, nullable = false) + private String roadAddress; + + @Column(name = "floor_info", length = 20, nullable = false) + private String floorInfo; + + @Column(name = "lat", nullable = false) + private double latitude; + + @Column(name = "lng", nullable = false) + private double longitude; + + @Column(name = "status", length = 20, nullable = false) + private StoreStatus status; + + @Column(name = "origin", length = 20, nullable = false) + private StoreOrigin origin; + + @Column(name = "inactive_at") + private Instant inactiveAt; + + @Column(name = "verified_at") + private Instant verifiedAt; + + @Column(name = "last_batch_id", nullable = false) + private Long lastBatchId; + + public boolean isActive() { + return status == StoreStatus.ACTIVE; + } + + public boolean isVerified() { + return verifiedAt != null; + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreMatchMethod.java b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreMatchMethod.java new file mode 100644 index 0000000..fbafcca --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreMatchMethod.java @@ -0,0 +1,31 @@ +package com.hanspoon.backend_api.domain.store.entity; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; + +/** 사용자가 가게 후보를 찾은 경로. 판정 근거가 아니라 검색 품질 관측용 메타데이터다. */ +public enum StoreMatchMethod { + GPS_CANDIDATE("gps_candidate"), + NAME_SEARCH("name_search"), + KAKAO_FALLBACK("kakao_fallback"); + + private final String code; + + StoreMatchMethod(String code) { + this.code = code; + } + + @JsonValue + public String getCode() { + return code; + } + + @JsonCreator + public static StoreMatchMethod fromCode(String code) { + return Arrays.stream(values()) + .filter(value -> value.code.equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown store match method: " + code)); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreMatchMethodConverter.java b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreMatchMethodConverter.java new file mode 100644 index 0000000..1c21462 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreMatchMethodConverter.java @@ -0,0 +1,19 @@ +package com.hanspoon.backend_api.domain.store.entity; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** {@link StoreMatchMethod}와 DB 소문자 코드 간 변환기. */ +@Converter(autoApply = true) +public class StoreMatchMethodConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(StoreMatchMethod attribute) { + return attribute == null ? null : attribute.getCode(); + } + + @Override + public StoreMatchMethod convertToEntityAttribute(String dbData) { + return dbData == null ? null : StoreMatchMethod.fromCode(dbData); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreOrigin.java b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreOrigin.java new file mode 100644 index 0000000..ba2cc6e --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreOrigin.java @@ -0,0 +1,29 @@ +package com.hanspoon.backend_api.domain.store.entity; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; + +public enum StoreOrigin { + SBIZ("sbiz"), + LOCAL_DATA("localdata"); + + private final String code; + + StoreOrigin(String code) { + this.code = code; + } + + @JsonValue + public String getCode() { + return code; + } + + @JsonCreator + public static StoreOrigin fromCode(String code) { + return Arrays.stream(values()) + .filter(value -> value.code.equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown store origin: " + code)); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreOriginConverter.java b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreOriginConverter.java new file mode 100644 index 0000000..c361a12 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreOriginConverter.java @@ -0,0 +1,19 @@ +package com.hanspoon.backend_api.domain.store.entity; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** {@link StoreOrigin}과 DB 소문자 코드 간 변환기. */ +@Converter(autoApply = true) +public class StoreOriginConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(StoreOrigin attribute) { + return attribute == null ? null : attribute.getCode(); + } + + @Override + public StoreOrigin convertToEntityAttribute(String dbData) { + return dbData == null ? null : StoreOrigin.fromCode(dbData); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreStatus.java b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreStatus.java new file mode 100644 index 0000000..538cd83 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreStatus.java @@ -0,0 +1,30 @@ +package com.hanspoon.backend_api.domain.store.entity; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; + +/** 가게 노출 상태. */ +public enum StoreStatus { + ACTIVE("active"), + INACTIVE("inactive"); + + private final String code; + + StoreStatus(String code) { + this.code = code; + } + + @JsonValue + public String getCode() { + return code; + } + + @JsonCreator + public static StoreStatus fromCode(String code) { + return Arrays.stream(values()) + .filter(value -> value.code.equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown store status: " + code)); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreStatusConverter.java b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreStatusConverter.java new file mode 100644 index 0000000..70469bb --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/entity/StoreStatusConverter.java @@ -0,0 +1,19 @@ +package com.hanspoon.backend_api.domain.store.entity; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** {@link StoreStatus}와 DB 소문자 코드 간 변환기. */ +@Converter(autoApply = true) +public class StoreStatusConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(StoreStatus attribute) { + return attribute == null ? null : attribute.getCode(); + } + + @Override + public StoreStatus convertToEntityAttribute(String dbData) { + return dbData == null ? null : StoreStatus.fromCode(dbData); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/repository/StoreCandidateProjection.java b/src/main/java/com/hanspoon/backend_api/domain/store/repository/StoreCandidateProjection.java new file mode 100644 index 0000000..fcb90b2 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/repository/StoreCandidateProjection.java @@ -0,0 +1,27 @@ +package com.hanspoon.backend_api.domain.store.repository; + +/** 반경·상호명 검색에 필요한 값만 조회하는 읽기 전용 후보 Projection. */ +public interface StoreCandidateProjection { + + Long getStoreId(); + + String getName(); + + String getBranchName(); + + String getRoadAddress(); + + Double getLatitude(); + + Double getLongitude(); + + Integer getDistanceMeters(); + + Double getNameSimilarity(); + + String getCategoryCode(); + + String getCategoryName(); + + Boolean getVerified(); +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/repository/StoreRepository.java b/src/main/java/com/hanspoon/backend_api/domain/store/repository/StoreRepository.java new file mode 100644 index 0000000..1b3ef58 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/repository/StoreRepository.java @@ -0,0 +1,109 @@ +package com.hanspoon.backend_api.domain.store.repository; + +import com.hanspoon.backend_api.domain.store.entity.Store; +import com.hanspoon.backend_api.domain.store.entity.StoreStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StoreRepository extends JpaRepository { + + Optional findByIdAndStatus(Long id, StoreStatus status); + + /** 현재 위치 주변의 영업 중 가게를 검증 여부·공공데이터 출처·거리 순으로 조회. */ + @Query( + value = + """ + select s.id as "storeId", + s.name as "name", + s.branch_name as "branchName", + s.road_address as "roadAddress", + s.lat as "latitude", + s.lng as "longitude", + cast(round(earth_distance( + ll_to_earth(:latitude, :longitude), + ll_to_earth(s.lat, s.lng) + )) as integer) as "distanceMeters", + cast(null as double precision) as "nameSimilarity", + c.code as "categoryCode", + c.name as "categoryName", + (s.verified_at is not null) as "verified" + from stores s + left join store_categories c on c.id = s.category_id + where s.status = 'active' + and earth_box( + ll_to_earth(:latitude, :longitude), + :radiusMeters + ) @> ll_to_earth(s.lat, s.lng) + and earth_distance( + ll_to_earth(:latitude, :longitude), + ll_to_earth(s.lat, s.lng) + ) <= :radiusMeters + order by "verified" desc, + case s.origin + when 'sbiz' then 0 + when 'localdata' then 1 + else 2 + end, + "distanceMeters", + s.id + limit :limit + """, + nativeQuery = true) + List findNearbyCandidates( + @Param("latitude") double latitude, + @Param("longitude") double longitude, + @Param("radiusMeters") int radiusMeters, + @Param("limit") int limit); + + /** 정규화된 상호명 trigram 후보를 현재 위치 반경 안에서 조회한다. */ + @Query( + value = + """ + select s.id as "storeId", + s.name as "name", + s.branch_name as "branchName", + s.road_address as "roadAddress", + s.lat as "latitude", + s.lng as "longitude", + cast(round(earth_distance( + ll_to_earth(:latitude, :longitude), + ll_to_earth(s.lat, s.lng) + )) as integer) as "distanceMeters", + similarity(s.name_normalized, normalize_store_name(:query)) as "nameSimilarity", + c.code as "categoryCode", + c.name as "categoryName", + (s.verified_at is not null) as "verified" + from stores s + left join store_categories c on c.id = s.category_id + where s.status = 'active' + and earth_box( + ll_to_earth(:latitude, :longitude), + :radiusMeters + ) @> ll_to_earth(s.lat, s.lng) + and earth_distance( + ll_to_earth(:latitude, :longitude), + ll_to_earth(s.lat, s.lng) + ) <= :radiusMeters + and s.name_normalized % normalize_store_name(:query) + order by "verified" desc, + case s.origin + when 'sbiz' then 0 + when 'localdata' then 1 + else 2 + end, + "nameSimilarity" desc, + "distanceMeters", + s.id + limit :limit + """, + nativeQuery = true) + List findNearbyCandidatesByName( + @Param("query") String query, + @Param("latitude") double latitude, + @Param("longitude") double longitude, + @Param("radiusMeters") int radiusMeters, + @Param("limit") int limit); +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/store/service/StoreSearchService.java b/src/main/java/com/hanspoon/backend_api/domain/store/service/StoreSearchService.java new file mode 100644 index 0000000..0842f03 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/store/service/StoreSearchService.java @@ -0,0 +1,58 @@ +package com.hanspoon.backend_api.domain.store.service; + +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateListResponse; +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateResponse; +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateSearchRequest; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; +import com.hanspoon.backend_api.domain.store.repository.StoreCandidateProjection; +import com.hanspoon.backend_api.domain.store.repository.StoreRepository; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class StoreSearchService { + + private final StoreRepository storeRepository; + + public StoreSearchService(StoreRepository storeRepository) { + this.storeRepository = storeRepository; + } + + @Transactional(readOnly = true) + public StoreCandidateListResponse findCandidates(StoreCandidateSearchRequest request) { + String query = request.trimmedQuery(); + List candidates = query == null + ? storeRepository.findNearbyCandidates( + request.latitude(), + request.longitude(), + request.resolvedRadiusMeters(), + request.resolvedLimit()) + : storeRepository.findNearbyCandidatesByName( + query, + request.latitude(), + request.longitude(), + request.resolvedRadiusMeters(), + request.resolvedLimit()); + + StoreMatchMethod matchMethod = query == null ? StoreMatchMethod.GPS_CANDIDATE : StoreMatchMethod.NAME_SEARCH; + return new StoreCandidateListResponse(candidates.stream() + .map(candidate -> toResponse(candidate, matchMethod)) + .toList()); + } + + private static StoreCandidateResponse toResponse(StoreCandidateProjection candidate, StoreMatchMethod matchMethod) { + return new StoreCandidateResponse( + candidate.getStoreId(), + candidate.getName(), + candidate.getBranchName(), + candidate.getRoadAddress(), + candidate.getLatitude(), + candidate.getLongitude(), + candidate.getDistanceMeters(), + candidate.getCategoryCode(), + candidate.getCategoryName(), + Boolean.TRUE.equals(candidate.getVerified()), + matchMethod); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java index 5811dba..b695c9b 100644 --- a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java +++ b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java @@ -37,10 +37,17 @@ public enum ErrorCode { // ── 스캔 ──────────────────────────────────────────────────────────── SCAN_NOT_FOUND(HttpStatus.NOT_FOUND, "SCAN_NOT_FOUND", "Scan not found."), + SCAN_REQUEST_CONFLICT( + HttpStatus.CONFLICT, + "SCAN_REQUEST_CONFLICT", + "The uploaded image is already linked to a different scan request."), SCAN_CAPACITY_EXCEEDED( HttpStatus.SERVICE_UNAVAILABLE, "SCAN_CAPACITY_EXCEEDED", "Scan capacity is temporarily exhausted."), SCAN_PROCESSING_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "SCAN_PROCESSING_TIMEOUT", "Scan processing timed out."), + // ── 가게 ──────────────────────────────────────────────────────────── + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_NOT_FOUND", "Active store not found."), + // ── 소통 카드 ─────────────────────────────────────────────────────── CARD_NOT_FOUND(HttpStatus.NOT_FOUND, "CARD_NOT_FOUND", "Saved card not found."), diff --git a/src/main/resources/db/migration/V5__store_domain.sql b/src/main/resources/db/migration/V5__store_domain.sql new file mode 100644 index 0000000..bb84f96 --- /dev/null +++ b/src/main/resources/db/migration/V5__store_domain.sql @@ -0,0 +1,278 @@ +-- 가게 도메인 +-- 마스터: 소상공인시장진흥공단 상가(상권)정보 한식(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_version CHECK (source_version ~ '^[0-9]{6}$'), + CONSTRAINT ck_store_import_batches_status CHECK (status IN ('running', 'completed', 'failed')), + CONSTRAINT ck_store_import_batches_row_count CHECK (row_count >= 0), + CONSTRAINT ck_store_import_batches_finished_at CHECK ( + (status = 'running' AND finished_at IS NULL) + OR (status IN ('completed', 'failed') AND finished_at IS NOT NULL) + ) +); + +-- ───────────────────────────────────────────────────────────── +-- 가게 마스터 +-- ───────────────────────────────────────────────────────────── +CREATE TABLE stores ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + + -- 원천 식별자. 멱등 upsert 키. localdata 출처면 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 NOT 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, + last_batch_id BIGINT NOT 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), + -- 원천 실측 이상치 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) <> '' AND name = btrim(name)), + CONSTRAINT ck_stores_name_normalized CHECK (name_normalized <> ''), + CONSTRAINT ck_stores_status CHECK (status IN ('active', 'inactive')), + CONSTRAINT ck_stores_origin CHECK (origin IN ('sbiz', 'localdata')), + 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)) +); + +-- 반경 후보 검색. 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) + WHERE status = 'active'; +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() 을 거쳐야 한다.'; + +-- 전역 표기 변형 사전. 검색어를 대표표기로 치환한 뒤 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), + CONSTRAINT ck_brand_aliases_values CHECK ( + variant_normalized <> '' + AND canonical_normalized <> '' + AND variant_normalized = normalize_store_name(variant_normalized) + AND canonical_normalized = normalize_store_name(canonical_normalized) + AND variant_normalized <> canonical_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')), + CONSTRAINT ck_store_external_refs_external_id CHECK ( + btrim(external_id) <> '' AND external_id = btrim(external_id) + ) +); + +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')), + 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/src/test/java/com/hanspoon/backend_api/domain/ai/client/AiClientTest.java b/src/test/java/com/hanspoon/backend_api/domain/ai/client/AiClientTest.java index 1af4c83..e73df56 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/ai/client/AiClientTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/ai/client/AiClientTest.java @@ -47,7 +47,7 @@ void requestOcrParsesSuccessResponse() { String body = """ { - "scan_session": {"title": "m.jpg", "menu_count": 1, "scan_status": "completed"}, + "scan_session": {"store_id": 42, "title": "m.jpg", "menu_count": 1, "scan_status": "completed"}, "menu_image": {"source": "upload"}, "scan_quality": {"status": "usable", "score": 80}, "menu_analyses": [{"menu_name_ko": "x", "is_spicy": false, "display_order": 1}] @@ -57,8 +57,9 @@ void requestOcrParsesSuccessResponse() { .andExpect(method(HttpMethod.POST)) .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); - OcrResponse response = aiClient.requestOcr(new OcrRequest("upload", "k", "u", "v1", "etag-1")); + OcrResponse response = aiClient.requestOcr(new OcrRequest(42L, "upload", "k", "u", "v1", "etag-1")); + assertThat(response.scanSession().storeId()).isEqualTo(42L); assertThat(response.scanSession().menuCount()).isEqualTo(1); assertThat(response.menuAnalyses()).hasSize(1); server.verify(); @@ -68,7 +69,7 @@ void requestOcrParsesSuccessResponse() { void requestOcrMapsErrorStatusToBusinessException() { server.expect(requestTo(BASE_URL + "/v1/ocr")).andRespond(withStatus(HttpStatus.BAD_GATEWAY)); - assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest("upload", "k", "u", "v1", "etag-1"))) + assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest(42L, "upload", "k", "u", "v1", "etag-1"))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getErrorCode()) .isEqualTo(ErrorCode.OCR_SERVICE_ERROR); @@ -79,7 +80,7 @@ void requestOcrMapsErrorStatusToBusinessException() { void requestOcrMapsCapacityResponseToOverloaded() { server.expect(requestTo(BASE_URL + "/v1/ocr")).andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE)); - assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest("upload", "k", "u", "v1", "etag-1"))) + assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest(42L, "upload", "k", "u", "v1", "etag-1"))) .isInstanceOf(BusinessException.class) .extracting(error -> ((BusinessException) error).getErrorCode()) .isEqualTo(ErrorCode.AI_SERVICE_OVERLOADED); @@ -92,7 +93,7 @@ void requestOcrMapsConnectionFailureToUnavailable() { throw new IOException("connection refused"); }); - assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest("upload", "k", "u", "v1", "etag-1"))) + assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest(42L, "upload", "k", "u", "v1", "etag-1"))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getErrorCode()) .isEqualTo(ErrorCode.AI_SERVICE_UNAVAILABLE); @@ -104,7 +105,7 @@ void judgeParsesRuleEngineResponse() { String body = """ { - "scan_session": {"title": "m.jpg", "menu_count": 1, "risky_menu_count": 1, "scan_status": "completed"}, + "scan_session": {"store_id": 42, "title": "m.jpg", "menu_count": 1, "risky_menu_count": 1, "scan_status": "completed"}, "menu_image": {"source": "upload"}, "scan_quality": {"status": "usable", "score": 80}, "menu_analyses": [ @@ -117,7 +118,7 @@ void judgeParsesRuleEngineResponse() { .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); RuleEngineRequest request = - new RuleEngineRequest(new RuleProfile("halal", false, null, true, List.of(), false), null); + new RuleEngineRequest(42L, new RuleProfile("halal", false, null, true, List.of(), false), null); RuleEngineResponse response = aiClient.judge(request); assertThat(response.scanSession().riskyMenuCount()).isEqualTo(1); @@ -130,7 +131,7 @@ void judgeMapsErrorStatusToBusinessException() { server.expect(requestTo(BASE_URL + "/v1/ruleengine")).andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); RuleEngineRequest request = - new RuleEngineRequest(new RuleProfile(null, false, null, false, List.of(), false), null); + new RuleEngineRequest(42L, new RuleProfile(null, false, null, false, List.of(), false), null); assertThatThrownBy(() -> aiClient.judge(request)) .isInstanceOf(BusinessException.class) @@ -144,7 +145,7 @@ void resultParsesFinalResponse() { String body = """ { - "scan_session": {"title": "m.jpg", "menu_count": 1, "scan_status": "completed"}, + "scan_session": {"store_id": 42, "title": "m.jpg", "menu_count": 1, "scan_status": "completed"}, "menu_image": {"source": "upload"}, "scan_quality": {"status": "usable", "score": 80}, "menu_analyses": [ @@ -161,6 +162,7 @@ void resultParsesFinalResponse() { FinalResultResponse response = aiClient.result(new RuleEngineResponse(null, null, null, List.of())); + assertThat(response.scanSession().storeId()).isEqualTo(42L); assertThat(response.menuAnalyses()).hasSize(1); assertThat(response.menuAnalyses().get(0).riskLevel()).isEqualTo(RiskLevel.CAUTION); assertThat(response.menuAnalyses().get(0).message().ko()).isEqualTo("broth?"); 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 a5e7085..1e25416 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 @@ -143,6 +143,7 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception { """ { "scan_session": { + "store_id": 42, "title": "menu_001.jpg", "menu_count": 2, "risky_menu_count": null, @@ -211,6 +212,7 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception { OcrResponse result = objectMapper.readValue(json, OcrResponse.class); + assertThat(result.scanSession().storeId()).isEqualTo(42L); assertThat(result.scanSession().menuCount()).isEqualTo(2); assertThat(result.scanSession().riskyMenuCount()).isNull(); assertThat(result.menuImage().storageKey()).isEqualTo("scans/menu_001.jpg"); @@ -250,11 +252,12 @@ void serializesRuleProfileInSnakeCase() throws Exception { @Test void serializesOcrRequestInSnakeCase() throws Exception { - OcrRequest request = OcrRequest.forS3("camera", "scans/menu_003.jpg", null, "version-1", "\"etag-1\""); + OcrRequest request = OcrRequest.forS3(42L, "camera", "scans/menu_003.jpg", null, "version-1", "\"etag-1\""); String json = objectMapper.writeValueAsString(request); assertThat(json) + .contains("\"store_id\":42") .contains("\"storage_key\":\"scans/menu_003.jpg\"") .contains("\"image_url\":null") .contains("\"version_id\":\"version-1\"") 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 a38a399..0d65810 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 @@ -14,6 +14,7 @@ import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; import com.hanspoon.backend_api.domain.user.entity.User; import com.hanspoon.backend_api.domain.user.repository.UserRepository; import jakarta.persistence.EntityManager; @@ -25,6 +26,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.annotation.Transactional; /** @@ -48,9 +50,56 @@ class ScanPersistenceIntegrationTest { @Autowired private MenuAnalysisRepository menuAnalysisRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + @PersistenceContext private EntityManager entityManager; + @Test + void persistsAndReloadsAtomicStoreContext() { + User user = userRepository.save(User.create("scan-store@example.com", "scanstore", "ko")); + Long categoryId = jdbcTemplate.queryForObject( + """ + insert into store_categories(code, name, level) + values ('I2', '음식', 1) + returning id + """, + Long.class); + Long batchId = jdbcTemplate.queryForObject( + """ + insert into store_import_batches(source, source_version) + values ('sbiz', '202609') + returning id + """, + Long.class); + Long storeId = jdbcTemplate.queryForObject( + """ + insert into stores( + sbiz_store_no, name, category_id, lat, lng, origin, last_batch_id + ) values ('SCAN-STORE-1', '한스푼 강남점', ?, 37.4979, 127.0276, 'sbiz', ?) + returning id + """, + Long.class, + categoryId, + batchId); + + ScanSession session = scanSessionRepository.save(ScanSession.start( + user.getId(), + "scans/" + user.getId() + "/store-context.jpg", + storeId, + "한스푼 강남점", + StoreMatchMethod.NAME_SEARCH)); + + entityManager.flush(); + entityManager.clear(); + + ScanSession reloaded = scanSessionRepository.findById(session.getId()).orElseThrow(); + assertThat(reloaded.getStoreId()).isEqualTo(storeId); + assertThat(reloaded.getStoreNameSnapshot()).isEqualTo("한스푼 강남점"); + assertThat(reloaded.getStoreMatchMethod()).isEqualTo(StoreMatchMethod.NAME_SEARCH); + } + @Test void persistsAndReloadsScanGraphWithJsonbFields() { User user = userRepository.save(User.create("scan-test@example.com", "scanuser", "ko")); 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 20d0360..e844d77 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 @@ -3,6 +3,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -16,6 +17,7 @@ import com.hanspoon.backend_api.domain.scan.dto.ScanCreatedResponse; import com.hanspoon.backend_api.domain.scan.dto.ScanHistoryItem; import com.hanspoon.backend_api.domain.scan.dto.ScanResultResponse; +import com.hanspoon.backend_api.domain.scan.dto.ScanStoreSummary; import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.service.ScanService; import com.hanspoon.backend_api.global.common.PageResponse; @@ -73,14 +75,53 @@ void startScanReturns202() throws Exception { when(scanService.startScan(eq(USER_ID), any())) .thenReturn(new ScanCreatedResponse(scanId, ScanStatus.PROCESSING)); - mockMvc.perform(post("/api/v1/scans") - .contentType("application/json") - .content("{\"storageKey\":\"menu-x.jpg\",\"source\":\"upload\"}")) + mockMvc.perform( + post("/api/v1/scans") + .contentType("application/json") + .content( + """ + { + "storageKey": "menu-x.jpg", + "source": "upload", + "storeId": 42, + "storeMatchMethod": "gps_candidate" + } + """)) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.scanId").value(scanId.toString())) .andExpect(jsonPath("$.status").value("processing")); } + @Test + void startScanAllowsMissingStoreContext() throws Exception { + UUID scanId = UUID.randomUUID(); + when(scanService.startScan(eq(USER_ID), any())) + .thenReturn(new ScanCreatedResponse(scanId, ScanStatus.PROCESSING)); + + mockMvc.perform( + post("/api/v1/scans") + .contentType("application/json") + .content( + """ + {"storageKey":"scans/user/menu.jpg","source":"upload"} + """)) + .andExpect(status().isAccepted()); + } + + @Test + void startScanRejectsPartialStoreContext() throws Exception { + mockMvc.perform( + post("/api/v1/scans") + .contentType("application/json") + .content( + """ + {"storageKey":"scans/user/menu.jpg","source":"upload","storeId":42} + """)) + .andExpect(status().isBadRequest()); + + verify(scanService, never()).startScan(any(), any()); + } + @Test void startScanRejectsBlankStorageKey() throws Exception { mockMvc.perform(post("/api/v1/scans").contentType("application/json").content("{\"storageKey\":\"\"}")) @@ -89,21 +130,56 @@ void startScanRejectsBlankStorageKey() throws Exception { @Test void startScanRejectsUnknownSource() throws Exception { - mockMvc.perform(post("/api/v1/scans") - .contentType("application/json") - .content("{\"storageKey\":\"menu-x.jpg\",\"source\":\"external-url\"}")) + mockMvc.perform( + post("/api/v1/scans") + .contentType("application/json") + .content( + """ + { + "storageKey": "menu-x.jpg", + "source": "external-url", + "storeId": 42, + "storeMatchMethod": "gps_candidate" + } + """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("INVALID_REQUEST")); } + @Test + void startScanRejectsUnknownStoreMatchMethod() throws Exception { + mockMvc.perform( + post("/api/v1/scans") + .contentType("application/json") + .content( + """ + { + "storageKey": "menu-x.jpg", + "source": "upload", + "storeId": 42, + "storeMatchMethod": "user_created" + } + """)) + .andExpect(status().isBadRequest()); + } + @Test void startScanReturns503WhenScanCapacityIsExhausted() throws Exception { when(scanService.startScan(eq(USER_ID), any())) .thenThrow(new BusinessException(ErrorCode.SCAN_CAPACITY_EXCEEDED)); - mockMvc.perform(post("/api/v1/scans") - .contentType("application/json") - .content("{\"storageKey\":\"menu-x.jpg\",\"source\":\"upload\"}")) + mockMvc.perform( + post("/api/v1/scans") + .contentType("application/json") + .content( + """ + { + "storageKey": "menu-x.jpg", + "source": "upload", + "storeId": 42, + "storeMatchMethod": "gps_candidate" + } + """)) .andExpect(status().isServiceUnavailable()) .andExpect(header().string("Retry-After", "2")) .andExpect(jsonPath("$.code").value("SCAN_CAPACITY_EXCEEDED")); @@ -114,19 +190,32 @@ 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, null)); + scanId, + ScanStatus.COMPLETED, + null, + new ScanStoreSummary(42L, "한스푼"), + 2, + 1, + null, + List.of(), + null, + null, + null)); mockMvc.perform(get("/api/v1/scans/{scanId}", scanId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.scanId").value(scanId.toString())) .andExpect(jsonPath("$.status").value("completed")) + .andExpect(jsonPath("$.store.storeId").value(42)) + .andExpect(jsonPath("$.store.name").value("한스푼")) .andExpect(jsonPath("$.menuCount").value(2)); } @Test void getScansReturnsPage() throws Exception { UUID scanId = UUID.randomUUID(); - ScanHistoryItem item = new ScanHistoryItem(scanId, "강남 삼겹살집", ScanStatus.COMPLETED, 2, 1, null); + ScanHistoryItem item = new ScanHistoryItem( + scanId, "강남 삼겹살집", new ScanStoreSummary(42L, "한스푼"), ScanStatus.COMPLETED, 2, 1, null); when(scanService.getScans(eq(USER_ID), any())).thenReturn(new PageResponse<>(List.of(item), 0, 20, 1, 1)); mockMvc.perform(get("/api/v1/scans?page=0&size=20")) @@ -134,14 +223,15 @@ void getScansReturnsPage() throws Exception { .andExpect(jsonPath("$.totalElements").value(1)) .andExpect(jsonPath("$.page").value(0)) .andExpect(jsonPath("$.items[0].scanId").value(scanId.toString())) - .andExpect(jsonPath("$.items[0].title").value("강남 삼겹살집")); + .andExpect(jsonPath("$.items[0].title").value("강남 삼겹살집")) + .andExpect(jsonPath("$.items[0].store.name").value("한스푼")); } @Test void updateTitleReturns200() throws Exception { UUID scanId = UUID.randomUUID(); when(scanService.updateTitle(eq(USER_ID), eq(scanId), any())) - .thenReturn(new ScanHistoryItem(scanId, "새 제목", ScanStatus.COMPLETED, 2, 1, null)); + .thenReturn(new ScanHistoryItem(scanId, "새 제목", null, ScanStatus.COMPLETED, 2, 1, null)); mockMvc.perform(patch("/api/v1/scans/{scanId}", scanId) .contentType("application/json") diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/entity/ScanSessionTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/entity/ScanSessionTest.java index 9887cec..cb2236b 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/entity/ScanSessionTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/entity/ScanSessionTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -10,7 +11,7 @@ class ScanSessionTest { @Test void terminalFailureCannotBeOverwrittenByALateWorker() { - ScanSession session = ScanSession.start(UUID.randomUUID(), "scans/user/menu.jpg"); + ScanSession session = ScanSession.startLegacy(UUID.randomUUID(), "scans/user/menu.jpg"); session.markFailed("SCAN_PROCESSING_TIMEOUT"); assertThatThrownBy(() -> session.applyRuleEngineResult(1, ScanStatus.COMPLETED)) @@ -20,4 +21,18 @@ void terminalFailureCannotBeOverwrittenByALateWorker() { assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED); assertThat(session.getFailureCode()).isEqualTo("SCAN_PROCESSING_TIMEOUT"); } + + @Test + void startsWithAtomicStoreContext() { + ScanSession session = + ScanSession.start(UUID.randomUUID(), "scans/user/menu.jpg", 42L, "한스푼", StoreMatchMethod.NAME_SEARCH); + + assertThat(session.getStoreId()).isEqualTo(42L); + assertThat(session.getStoreNameSnapshot()).isEqualTo("한스푼"); + assertThat(session.getStoreMatchMethod()).isEqualTo(StoreMatchMethod.NAME_SEARCH); + assertThat(session.hasSameStoreContext(42L, StoreMatchMethod.NAME_SEARCH)) + .isTrue(); + assertThat(session.hasSameStoreContext(43L, StoreMatchMethod.NAME_SEARCH)) + .isFalse(); + } } 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 880a54c..51303ee 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 @@ -18,6 +18,7 @@ import com.hanspoon.backend_api.domain.ai.dto.result.OwnerCard; import com.hanspoon.backend_api.domain.ai.dto.result.OwnerQuestion; import com.hanspoon.backend_api.domain.ai.dto.ruleengine.RiskReason; +import com.hanspoon.backend_api.domain.ai.dto.ruleengine.RuleEngineRequest; import com.hanspoon.backend_api.domain.ai.dto.ruleengine.RuleEngineResponse; import com.hanspoon.backend_api.domain.ai.dto.ruleengine.RuleMenuAnalysis; import com.hanspoon.backend_api.domain.scan.entity.MenuAnalysis; @@ -26,6 +27,7 @@ import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload; import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.domain.user.entity.ReligionType; @@ -51,6 +53,7 @@ class ScanProcessorTest { private static final String STORAGE_KEY = "scans/11111111-1111-1111-1111-111111111111/abc.jpg"; private static final String VERSION_ID = "version-1"; private static final String ETAG = "\"etag-1\""; + private static final Long STORE_ID = 42L; private static final VerifiedUpload VERIFIED_UPLOAD = new VerifiedUpload(STORAGE_KEY, VERSION_ID, ETAG, 123L, "image/jpeg"); @@ -101,7 +104,7 @@ private static com.hanspoon.backend_api.domain.ai.dto.ocr.MenuAnalysis ocrMenu( private OcrResponse usableOcr() { return new OcrResponse( new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession( - "menu.jpg", 2, null, "completed", "2026-06-05T00:00:00Z"), + STORE_ID, "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/png", 999L), new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality( @@ -134,14 +137,17 @@ private OcrResponse usableOcr() { null); } + private ScanSession processingSession(UUID userId) { + return ScanSession.start(userId, STORAGE_KEY, STORE_ID, "한스푼", StoreMatchMethod.GPS_CANDIDATE); + } + @Test void completesScanAndMergesOcrWithRuleEngine() { UUID userId = UUID.randomUUID(); - ScanSession session = ScanSession.create(userId, "menu.jpg", null, null, ScanStatus.PROCESSING, null); + ScanSession session = processingSession(userId); UUID scanId = session.getId(); OcrResponse ocr = usableOcr(); - when(scanSessionRepository.existsById(scanId)).thenReturn(true); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY); when(aiClient.requestOcr(any())).thenReturn(ocr); @@ -149,7 +155,8 @@ void completesScanAndMergesOcrWithRuleEngine() { when(userProfileRepository.findByUserId(userId)).thenReturn(Optional.of(profile)); when(userAllergyRepository.findByUserProfileId(profile.getId())).thenReturn(List.of()); RuleEngineResponse judged = new RuleEngineResponse( - new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession("menu.jpg", 2, 1, "completed", null), + new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession( + STORE_ID, "menu.jpg", 2, 1, "completed", null), ocr.menuImage(), ocr.scanQuality(), List.of( @@ -176,20 +183,24 @@ void completesScanAndMergesOcrWithRuleEngine() { null, null))); when(aiClient.judge(any())).thenReturn(judged); - FinalResultResponse finalResult = new FinalResultResponse(List.of( - new FinalMenu( - "samgyeopsal", - RiskLevel.DANGER, - List.of("is_pork"), - new FinalMessage("pork included", null, null), - null), - new FinalMenu( - "doenjang", - RiskLevel.CAUTION, - List.of(), - new FinalMessage("broth unclear", null, null), - new OwnerCard( - "doenjang", "has_unclear_broth", new OwnerQuestion("use anchovy?", null, null))))); + FinalResultResponse finalResult = new FinalResultResponse( + judged.scanSession(), + List.of( + new FinalMenu( + "samgyeopsal", + RiskLevel.DANGER, + List.of("is_pork"), + new FinalMessage("pork included", null, null), + null), + new FinalMenu( + "doenjang", + RiskLevel.CAUTION, + List.of(), + new FinalMessage("broth unclear", null, null), + new OwnerCard( + "doenjang", + "has_unclear_broth", + new OwnerQuestion("use anchovy?", null, null))))); when(aiClient.result(any())).thenReturn(finalResult); scanProcessor.process(scanId, userId, VERIFIED_UPLOAD, "upload"); @@ -197,9 +208,15 @@ void completesScanAndMergesOcrWithRuleEngine() { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OcrRequest.class); verify(aiClient).requestOcr(requestCaptor.capture()); assertThat(requestCaptor.getValue().storageKey()).isEqualTo(STORAGE_KEY); + assertThat(requestCaptor.getValue().storeId()).isEqualTo(STORE_ID); assertThat(requestCaptor.getValue().imageUrl()).isNull(); assertThat(requestCaptor.getValue().versionId()).isEqualTo(VERSION_ID); assertThat(requestCaptor.getValue().expectedEtag()).isEqualTo(ETAG); + ArgumentCaptor ruleRequestCaptor = ArgumentCaptor.forClass(RuleEngineRequest.class); + verify(aiClient).judge(ruleRequestCaptor.capture()); + assertThat(ruleRequestCaptor.getValue().storeId()).isEqualTo(STORE_ID); + assertThat(ruleRequestCaptor.getValue().ocrResult().scanSession().storeId()) + .isEqualTo(STORE_ID); assertThat(session.getScanStatus()).isEqualTo(ScanStatus.COMPLETED); assertThat(session.getMenuCount()).isEqualTo(2); @@ -233,14 +250,29 @@ void completesScanAndMergesOcrWithRuleEngine() { assertThat(saved.get(1).getOwnerCard().question().ko()).isEqualTo("use anchovy?"); } + @Test + void sendsNullStoreContextToAiWhenUserSkippedStoreSelection() { + UUID userId = UUID.randomUUID(); + ScanSession session = ScanSession.startWithoutStore(userId, STORAGE_KEY); + when(scanSessionRepository.findById(session.getId())).thenReturn(Optional.of(session)); + when(aiClient.requestOcr(any())).thenReturn(null); + + scanProcessor.process(session.getId(), userId, VERIFIED_UPLOAD, "upload"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OcrRequest.class); + verify(aiClient).requestOcr(requestCaptor.capture()); + assertThat(requestCaptor.getValue().storeId()).isNull(); + assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED); + assertThat(session.getFailureCode()).isEqualTo(ErrorCode.OCR_SERVICE_ERROR.getCode()); + } + @Test void persistsOcrStateBeforeCallingRuleEngine() { UUID userId = UUID.randomUUID(); - ScanSession session = ScanSession.create(userId, "menu.jpg", null, null, ScanStatus.PROCESSING, null); + ScanSession session = processingSession(userId); UUID scanId = session.getId(); OcrResponse ocr = usableOcr(); - when(scanSessionRepository.existsById(scanId)).thenReturn(true); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY); when(aiClient.requestOcr(any())).thenReturn(ocr); @@ -264,10 +296,11 @@ void persistsOcrStateBeforeCallingRuleEngine() { @Test void marksNeedsRetakeAndSkipsRuleEngine() { UUID userId = UUID.randomUUID(); - ScanSession session = ScanSession.create(userId, "menu.jpg", null, null, ScanStatus.PROCESSING, null); + ScanSession session = processingSession(userId); UUID scanId = session.getId(); OcrResponse ocr = new OcrResponse( - new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession("menu.jpg", 0, null, "completed", null), + new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession( + STORE_ID, "menu.jpg", 0, null, "completed", null), new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage("upload", STORAGE_KEY, "u", "image/jpeg", 1L), new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality( "needs_retake", @@ -296,7 +329,6 @@ void marksNeedsRetakeAndSkipsRuleEngine() { List.of(), null); - when(scanSessionRepository.existsById(scanId)).thenReturn(true); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY); when(aiClient.requestOcr(any())).thenReturn(ocr); @@ -313,10 +345,9 @@ void marksNeedsRetakeAndSkipsRuleEngine() { @Test void marksFailedWhenOcrThrows() { UUID userId = UUID.randomUUID(); - ScanSession session = ScanSession.create(userId, "menu.jpg", null, null, ScanStatus.PROCESSING, null); + ScanSession session = processingSession(userId); UUID scanId = session.getId(); - when(scanSessionRepository.existsById(scanId)).thenReturn(true); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); when(aiClient.requestOcr(any())).thenThrow(new BusinessException(ErrorCode.OCR_SERVICE_ERROR, "boom")); @@ -326,23 +357,48 @@ void marksFailedWhenOcrThrows() { verify(menuAnalysisRepository, never()).saveAll(any()); } + @Test + void marksFailedBeforePersistenceWhenOcrChangesStoreContext() { + UUID userId = UUID.randomUUID(); + ScanSession session = processingSession(userId); + UUID scanId = session.getId(); + OcrResponse original = usableOcr(); + OcrResponse mismatched = new OcrResponse( + new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession(99L, "menu.jpg", 2, null, "completed", null), + original.menuImage(), + original.scanQuality(), + original.menuAnalyses(), + original.gptQualityJudgment()); + + when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); + when(aiClient.requestOcr(any())).thenReturn(mismatched); + + scanProcessor.process(scanId, userId, VERIFIED_UPLOAD, "upload"); + + assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED); + assertThat(session.getFailureCode()).isEqualTo(ErrorCode.AI_RESULT_MISMATCH.getCode()); + verify(menuImageRepository, never()).save(any()); + verify(aiClient, never()).judge(any()); + } + @Test void marksFailedWithoutSavingWhenFinalMenuCountDoesNotMatchOcr() { UUID userId = UUID.randomUUID(); - ScanSession session = ScanSession.create(userId, "menu.jpg", null, null, ScanStatus.PROCESSING, null); + ScanSession session = processingSession(userId); UUID scanId = session.getId(); - when(scanSessionRepository.existsById(scanId)).thenReturn(true); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY); when(aiClient.requestOcr(any())).thenReturn(usableOcr()); UserProfile profile = UserProfile.create(userId, "KR", false, false, null, ReligionType.HALAL, true, true); when(userProfileRepository.findByUserId(userId)).thenReturn(Optional.of(profile)); when(userAllergyRepository.findByUserProfileId(profile.getId())).thenReturn(List.of()); - when(aiClient.judge(any())).thenReturn(new RuleEngineResponse(null, null, null, List.of())); + var aiSession = new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession( + STORE_ID, "menu.jpg", 2, 1, "completed", null); + when(aiClient.judge(any())).thenReturn(new RuleEngineResponse(aiSession, null, null, List.of())); when(aiClient.result(any())) .thenReturn(new FinalResultResponse( - List.of(new FinalMenu("samgyeopsal", RiskLevel.DANGER, List.of(), null, null)))); + aiSession, List.of(new FinalMenu("samgyeopsal", RiskLevel.DANGER, List.of(), null, null)))); scanProcessor.process(scanId, userId, VERIFIED_UPLOAD, "upload"); 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 ef3176c..c3da70e 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 @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -18,6 +19,10 @@ import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; +import com.hanspoon.backend_api.domain.store.entity.Store; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; +import com.hanspoon.backend_api.domain.store.entity.StoreStatus; +import com.hanspoon.backend_api.domain.store.repository.StoreRepository; import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload; import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.global.common.PageResponse; @@ -50,6 +55,9 @@ class ScanServiceTest { @Mock private MenuAnalysisRepository menuAnalysisRepository; + @Mock + private StoreRepository storeRepository; + @Mock private ScanProcessor scanProcessor; @@ -64,15 +72,44 @@ void startScanSavesSessionTriggersProcessorAndReturnsProcessing() { UUID userId = UUID.randomUUID(); String key = "scans/" + userId + "/2f1c9d3e-0000-4000-8000-000000000001.jpg"; VerifiedUpload upload = new VerifiedUpload(key, "version-1", "\"etag-1\"", 123L, "image/jpeg"); + Store store = store(42L, "한스푼"); when(s3StorageService.resolveKey(userId, key)).thenReturn(key); + when(storeRepository.findByIdAndStatus(42L, StoreStatus.ACTIVE)).thenReturn(Optional.of(store)); when(s3StorageService.verifyUploadObject(key)).thenReturn(upload); when(scanSessionRepository.saveAndFlush(any(ScanSession.class))) .thenAnswer(invocation -> invocation.getArgument(0, ScanSession.class)); - ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload")); + ScanCreatedResponse response = + scanService.startScan(userId, new StartScanRequest(key, "upload", 42L, StoreMatchMethod.GPS_CANDIDATE)); assertThat(response.status()).isEqualTo(ScanStatus.PROCESSING); assertThat(response.scanId()).isNotNull(); + verify(scanSessionRepository) + .saveAndFlush(org.mockito.ArgumentMatchers.argThat( + session -> session.getStoreId().equals(42L) + && session.getStoreNameSnapshot().equals("한스푼") + && session.getStoreMatchMethod() == StoreMatchMethod.GPS_CANDIDATE)); + verify(scanProcessor).process(eq(response.scanId()), eq(userId), eq(upload), eq("upload")); + } + + @Test + void startScanAllowsUserToContinueWithoutStore() { + UUID userId = UUID.randomUUID(); + String key = "scans/" + userId + "/without-store.jpg"; + VerifiedUpload upload = new VerifiedUpload(key, "version-1", "\"etag-1\"", 123L, "image/jpeg"); + when(s3StorageService.resolveKey(userId, key)).thenReturn(key); + when(s3StorageService.verifyUploadObject(key)).thenReturn(upload); + when(scanSessionRepository.saveAndFlush(any(ScanSession.class))) + .thenAnswer(invocation -> invocation.getArgument(0, ScanSession.class)); + + ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload", null, null)); + + assertThat(response.status()).isEqualTo(ScanStatus.PROCESSING); + verify(storeRepository, never()).findByIdAndStatus(any(), any()); + verify(scanSessionRepository) + .saveAndFlush(org.mockito.ArgumentMatchers.argThat(session -> session.getStoreId() == null + && session.getStoreNameSnapshot() == null + && session.getStoreMatchMethod() == null)); verify(scanProcessor).process(eq(response.scanId()), eq(userId), eq(upload), eq("upload")); } @@ -80,11 +117,12 @@ void startScanSavesSessionTriggersProcessorAndReturnsProcessing() { void startScanReturnsExistingSessionForTheSameStorageKey() { UUID userId = UUID.randomUUID(); String key = "scans/" + userId + "/same.jpg"; - ScanSession existing = ScanSession.start(userId, key); + ScanSession existing = ScanSession.start(userId, key, 42L, "한스푼", StoreMatchMethod.GPS_CANDIDATE); when(s3StorageService.resolveKey(userId, key)).thenReturn(key); when(scanSessionRepository.findByUserIdAndStorageKey(userId, key)).thenReturn(Optional.of(existing)); - ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload")); + ScanCreatedResponse response = + scanService.startScan(userId, new StartScanRequest(key, "upload", 42L, StoreMatchMethod.GPS_CANDIDATE)); assertThat(response.scanId()).isEqualTo(existing.getId()); verify(s3StorageService, never()).verifyUploadObject(any()); @@ -96,32 +134,73 @@ void concurrentDuplicateStartReturnsTheSessionCreatedByTheWinner() { UUID userId = UUID.randomUUID(); String key = "scans/" + userId + "/race.jpg"; VerifiedUpload upload = new VerifiedUpload(key, "version-1", "\"etag-1\"", 123L, "image/jpeg"); - ScanSession winner = ScanSession.start(userId, key); + Store store = store(42L, "한스푼"); + ScanSession winner = ScanSession.start(userId, key, 42L, "한스푼", StoreMatchMethod.GPS_CANDIDATE); when(s3StorageService.resolveKey(userId, key)).thenReturn(key); when(scanSessionRepository.findByUserIdAndStorageKey(userId, key)) .thenReturn(Optional.empty(), Optional.of(winner)); + when(storeRepository.findByIdAndStatus(42L, StoreStatus.ACTIVE)).thenReturn(Optional.of(store)); when(s3StorageService.verifyUploadObject(key)).thenReturn(upload); when(scanSessionRepository.saveAndFlush(any())) .thenThrow(new DataIntegrityViolationException("duplicate storage key")); - ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload")); + ScanCreatedResponse response = + scanService.startScan(userId, new StartScanRequest(key, "upload", 42L, StoreMatchMethod.GPS_CANDIDATE)); assertThat(response.scanId()).isEqualTo(winner.getId()); verify(scanProcessor, never()).process(any(), any(), any(), any()); } + @Test + void startScanRejectsMissingOrInactiveStoreBeforeVerifyingUpload() { + UUID userId = UUID.randomUUID(); + String key = "scans/" + userId + "/missing-store.jpg"; + when(s3StorageService.resolveKey(userId, key)).thenReturn(key); + when(storeRepository.findByIdAndStatus(404L, StoreStatus.ACTIVE)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> scanService.startScan( + userId, new StartScanRequest(key, "upload", 404L, StoreMatchMethod.NAME_SEARCH))) + .isInstanceOf(BusinessException.class) + .extracting(error -> ((BusinessException) error).getErrorCode()) + .isEqualTo(ErrorCode.STORE_NOT_FOUND); + + verify(s3StorageService, never()).verifyUploadObject(any()); + verify(scanProcessor, never()).process(any(), any(), any(), any()); + } + + @Test + void duplicateStorageKeyWithDifferentStoreContextReturnsConflict() { + UUID userId = UUID.randomUUID(); + String key = "scans/" + userId + "/different-store.jpg"; + ScanSession existing = ScanSession.start(userId, key, 42L, "한스푼", StoreMatchMethod.GPS_CANDIDATE); + when(s3StorageService.resolveKey(userId, key)).thenReturn(key); + when(scanSessionRepository.findByUserIdAndStorageKey(userId, key)).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> scanService.startScan( + userId, new StartScanRequest(key, "upload", 43L, StoreMatchMethod.GPS_CANDIDATE))) + .isInstanceOf(BusinessException.class) + .extracting(error -> ((BusinessException) error).getErrorCode()) + .isEqualTo(ErrorCode.SCAN_REQUEST_CONFLICT); + + verify(s3StorageService, never()).verifyUploadObject(any()); + verify(scanProcessor, never()).process(any(), any(), any(), any()); + } + @Test void startScanDeletesSessionAndReturnsCapacityErrorWhenExecutorRejects() { UUID userId = UUID.randomUUID(); String key = "scans/" + userId + "/full.jpg"; VerifiedUpload upload = new VerifiedUpload(key, "version-1", "\"etag-1\"", 123L, "image/jpeg"); + Store store = store(42L, "한스푼"); when(s3StorageService.resolveKey(userId, key)).thenReturn(key); + when(storeRepository.findByIdAndStatus(42L, StoreStatus.ACTIVE)).thenReturn(Optional.of(store)); when(s3StorageService.verifyUploadObject(key)).thenReturn(upload); when(scanSessionRepository.saveAndFlush(any(ScanSession.class))) .thenAnswer(invocation -> invocation.getArgument(0, ScanSession.class)); doThrow(new TaskRejectedException("full")).when(scanProcessor).process(any(), any(), any(), any()); - assertThatThrownBy(() -> scanService.startScan(userId, new StartScanRequest(key, "upload"))) + assertThatThrownBy(() -> scanService.startScan( + userId, new StartScanRequest(key, "upload", 42L, StoreMatchMethod.GPS_CANDIDATE))) .isInstanceOf(BusinessException.class) .extracting(error -> ((BusinessException) error).getErrorCode()) .isEqualTo(ErrorCode.SCAN_CAPACITY_EXCEEDED); @@ -144,7 +223,11 @@ void getScanThrowsWhenNotOwnedOrMissing() { @Test void getScanReturnsResultForOwner() { UUID userId = UUID.randomUUID(); - ScanSession session = ScanSession.create(userId, "custom title", 2, 1, ScanStatus.COMPLETED, null); + ScanSession session = ScanSession.start( + userId, "scans/" + userId + "/completed.jpg", 42L, "한스푼", StoreMatchMethod.GPS_CANDIDATE); + session.changeTitle("custom title"); + session.applyOcrResult(2, Instant.now()); + session.applyRuleEngineResult(1, ScanStatus.COMPLETED); UUID scanId = session.getId(); when(scanSessionRepository.findByIdAndUserId(scanId, userId)).thenReturn(Optional.of(session)); when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(scanId)) @@ -155,6 +238,8 @@ void getScanReturnsResultForOwner() { assertThat(response.scanId()).isEqualTo(scanId); assertThat(response.status()).isEqualTo(ScanStatus.COMPLETED); assertThat(response.title()).isEqualTo("custom title"); + assertThat(response.store().storeId()).isEqualTo(42L); + assertThat(response.store().name()).isEqualTo("한스푼"); assertThat(response.menuCount()).isEqualTo(2); assertThat(response.riskyMenuCount()).isEqualTo(1); assertThat(response.menus()).isEmpty(); @@ -194,7 +279,7 @@ void getScanReturnsPersistedMenuDescriptions() { @Test void getScanReturnsFailureCodeWithoutInternalExceptionDetails() { UUID userId = UUID.randomUUID(); - ScanSession session = ScanSession.start(userId, "scans/" + userId + "/failed.jpg"); + ScanSession session = ScanSession.startLegacy(userId, "scans/" + userId + "/failed.jpg"); session.markFailed(ErrorCode.AI_SERVICE_OVERLOADED.getCode()); when(scanSessionRepository.findByIdAndUserId(session.getId(), userId)).thenReturn(Optional.of(session)); when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(session.getId())) @@ -209,7 +294,7 @@ void getScanReturnsFailureCodeWithoutInternalExceptionDetails() { @Test void getScanReturnsRetakeReasonsAndSuggestions() { UUID userId = UUID.randomUUID(); - ScanSession session = ScanSession.start(userId, "scans/" + userId + "/blurred.jpg"); + ScanSession session = ScanSession.startLegacy(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())) @@ -308,4 +393,11 @@ void deleteScanThrowsWhenNotOwnedOrMissing() { .isEqualTo(ErrorCode.SCAN_NOT_FOUND); verify(scanSessionRepository, never()).delete(any()); } + + private Store store(Long id, String name) { + Store store = mock(Store.class); + when(store.getId()).thenReturn(id); + when(store.getName()).thenReturn(name); + return store; + } } 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 2728b3e..44ada4f 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 @@ -48,7 +48,7 @@ class ScanStateWriterIntegrationTest { void commitsOcrResultAndFailureInIndependentTransactions() { User user = userRepository.save(User.create(uniqueEmail("tx"), "tx-user", "ko")); String storageKey = "scans/" + user.getId() + "/tx.jpg"; - ScanSession session = scanSessionRepository.saveAndFlush(ScanSession.start(user.getId(), storageKey)); + ScanSession session = scanSessionRepository.saveAndFlush(ScanSession.startLegacy(user.getId(), storageKey)); scanStateWriter.applyOcrResult( session.getId(), @@ -78,9 +78,9 @@ void commitsOcrResultAndFailureInIndependentTransactions() { void databaseConstraintRejectsDuplicateStorageKeyForTheSameUser() { User user = userRepository.save(User.create(uniqueEmail("idempotency"), "idempotent-user", "ko")); String storageKey = "scans/" + user.getId() + "/same.jpg"; - scanSessionRepository.saveAndFlush(ScanSession.start(user.getId(), storageKey)); + scanSessionRepository.saveAndFlush(ScanSession.startLegacy(user.getId(), storageKey)); - assertThatThrownBy(() -> scanSessionRepository.saveAndFlush(ScanSession.start(user.getId(), storageKey))) + assertThatThrownBy(() -> scanSessionRepository.saveAndFlush(ScanSession.startLegacy(user.getId(), storageKey))) .isInstanceOf(DataIntegrityViolationException.class); } @@ -88,7 +88,7 @@ void databaseConstraintRejectsDuplicateStorageKeyForTheSameUser() { void recoversProcessingSessionLeftBehindByAStoppedWorker() { User user = userRepository.save(User.create(uniqueEmail("recovery"), "recovery-user", "ko")); String storageKey = "scans/" + user.getId() + "/stale.jpg"; - ScanSession session = scanSessionRepository.saveAndFlush(ScanSession.start(user.getId(), storageKey)); + ScanSession session = scanSessionRepository.saveAndFlush(ScanSession.startLegacy(user.getId(), storageKey)); jdbcTemplate.update( "update scan_sessions set updated_at = ? where id = ?", Timestamp.from(Instant.now().minus(Duration.ofMinutes(3))), diff --git a/src/test/java/com/hanspoon/backend_api/domain/store/StoreDomainMigrationIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/store/StoreDomainMigrationIntegrationTest.java new file mode 100644 index 0000000..aa02711 --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/domain/store/StoreDomainMigrationIntegrationTest.java @@ -0,0 +1,209 @@ +package com.hanspoon.backend_api.domain.store; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hanspoon.backend_api.TestcontainersConfiguration; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +/** Flyway V1~V5 전체 적용과 가게 도메인의 핵심 DB 계약을 실제 PostgreSQL에서 검증한다. */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +@Transactional +class StoreDomainMigrationIntegrationTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void installsSearchExtensionsAndPreservesUnicodeNormalizationContract() { + List extensions = jdbcTemplate.queryForList( + "select extname from pg_extension where extname in ('cube', 'earthdistance', 'pg_trgm')", String.class); + + assertThat(extensions).containsExactlyInAnyOrder("cube", "earthdistance", "pg_trgm"); + assertThat(jdbcTemplate.queryForObject("select normalize_store_name(?)", String.class, "CU 마트 B1")) + .isEqualTo("cu마트b1"); + assertThat(jdbcTemplate.queryForObject("select normalize_store_name(?)", String.class, "스시 さくら")) + .isEqualTo("스시さくら"); + } + + @Test + void usesBigintForStoreIdentifiers() { + String storeIdType = jdbcTemplate.queryForObject( + """ + select data_type + from information_schema.columns + where table_schema = 'public' + and table_name = 'stores' + and column_name = 'id' + """, + String.class); + String scanStoreIdType = jdbcTemplate.queryForObject( + """ + select data_type + from information_schema.columns + where table_schema = 'public' + and table_name = 'scan_sessions' + and column_name = 'store_id' + """, + String.class); + + assertThat(storeIdType).isEqualTo("bigint"); + assertThat(scanStoreIdType).isEqualTo("bigint"); + } + + @Test + void rejectsStoreNameThatNormalizesToEmpty() { + long categoryId = insertRootCategory(); + long batchId = insertCompletedBatch(); + + assertThatThrownBy(() -> insertPublicStore(categoryId, batchId, "store-empty-name", "!!!")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void rejectsEmptyExternalStoreIdentifier() { + long storeId = insertPublicStore(insertRootCategory(), insertCompletedBatch(), "store-external", "한그릇"); + + assertThatThrownBy(() -> jdbcTemplate.update( + """ + insert into store_external_refs (store_id, provider, external_id, external_url) + values (?, 'kakao', ' ', '') + """, + storeId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void rejectsStoreWithoutCategory() { + long batchId = insertCompletedBatch(); + + assertThatThrownBy(() -> jdbcTemplate.update( + """ + insert into stores + (sbiz_store_no, name, lat, lng, origin, last_batch_id) + values ('missing-category', '분류없는식당', 37.5, 127.0, 'sbiz', ?) + """, + batchId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void rejectsRemovedUserSubmittedOrigin() { + long categoryId = insertRootCategory(); + long batchId = insertCompletedBatch(); + + assertThatThrownBy(() -> jdbcTemplate.update( + """ + insert into stores + (name, category_id, lat, lng, origin, last_batch_id) + values ('사용자제보식당', ?, 37.5, 127.0, 'user_submitted', ?) + """, + categoryId, + batchId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void preservesLegacyScanWithoutStoreContext() { + UUID userId = insertUser("legacy-store-context@example.com"); + UUID scanId = UUID.randomUUID(); + + int inserted = jdbcTemplate.update( + "insert into scan_sessions (id, user_id, scan_status) values (?, ?, 'processing')", scanId, userId); + + assertThat(inserted).isOne(); + } + + @Test + void rejectsPartialStoreContextOnScan() { + long storeId = insertPublicStore(insertRootCategory(), insertCompletedBatch(), "store-scan", "스캔식당"); + UUID userId = insertUser("partial-store-context@example.com"); + + assertThatThrownBy(() -> jdbcTemplate.update( + """ + insert into scan_sessions (id, user_id, scan_status, store_id) + values (?, ?, 'processing', ?) + """, + UUID.randomUUID(), + userId, + storeId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void acceptsCompleteStoreContextOnScan() { + long storeId = insertPublicStore(insertRootCategory(), insertCompletedBatch(), "store-complete", "완전한식당"); + UUID userId = insertUser("complete-store-context@example.com"); + + int inserted = jdbcTemplate.update( + """ + insert into scan_sessions + (id, user_id, scan_status, store_id, store_name_snapshot, store_match_method) + values (?, ?, 'processing', ?, '완전한식당', 'gps_candidate') + """, + UUID.randomUUID(), + userId, + storeId); + + assertThat(inserted).isOne(); + } + + @Test + void enforcesImportBatchStatusAndFinishedAtPair() { + assertThatThrownBy( + () -> jdbcTemplate.update( + """ + insert into store_import_batches + (source, source_version, status, finished_at) + values ('sbiz', '209901', 'running', now()) + """)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private long insertRootCategory() { + return jdbcTemplate.queryForObject( + "insert into store_categories (code, name, level) values ('I2', '음식', 1) returning id", Long.class); + } + + private long insertCompletedBatch() { + return jdbcTemplate.queryForObject( + """ + insert into store_import_batches + (source, source_version, row_count, status, finished_at) + values ('sbiz', '202606', 1, 'completed', now()) + returning id + """, + Long.class); + } + + private long insertPublicStore(long categoryId, long batchId, String storeNo, String name) { + return jdbcTemplate.queryForObject( + """ + insert into stores + (sbiz_store_no, name, category_id, lat, lng, origin, last_batch_id) + values (?, ?, ?, 37.5000, 127.0000, 'sbiz', ?) + returning id + """, + Long.class, + storeNo, + name, + categoryId, + batchId); + } + + private UUID insertUser(String email) { + UUID userId = UUID.randomUUID(); + jdbcTemplate.update( + "insert into users (id, email, nickname, language_code) values (?, ?, 'tester', 'ko')", userId, email); + return userId; + } +} diff --git a/src/test/java/com/hanspoon/backend_api/domain/store/StoreRepositoryIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/store/StoreRepositoryIntegrationTest.java new file mode 100644 index 0000000..c0495d5 --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/domain/store/StoreRepositoryIntegrationTest.java @@ -0,0 +1,125 @@ +package com.hanspoon.backend_api.domain.store; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hanspoon.backend_api.TestcontainersConfiguration; +import com.hanspoon.backend_api.domain.store.entity.Store; +import com.hanspoon.backend_api.domain.store.entity.StoreOrigin; +import com.hanspoon.backend_api.domain.store.entity.StoreStatus; +import com.hanspoon.backend_api.domain.store.repository.StoreCandidateProjection; +import com.hanspoon.backend_api.domain.store.repository.StoreRepository; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +/** 가게 엔티티 매핑과 PostgreSQL 위치·상호명 검색 쿼리를 실제 DB에서 검증한다. */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +@Transactional +class StoreRepositoryIntegrationTest { + + private static final double CENTER_LATITUDE = 37.5000; + private static final double CENTER_LONGITUDE = 127.0000; + + @Autowired + private StoreRepository storeRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private long categoryId; + private long batchId; + + @BeforeEach + void setUpReferences() { + categoryId = jdbcTemplate.queryForObject( + "insert into store_categories (code, name, level) values ('I20101', '한식 일반 음식점업', 1) returning id", + Long.class); + batchId = jdbcTemplate.queryForObject( + """ + insert into store_import_batches + (source, source_version, row_count, status, finished_at) + values ('sbiz', '209901', 4, 'completed', now()) + returning id + """, + Long.class); + } + + @Test + void mapsGeneratedNameAndLowercaseEnumCodesToStoreEntity() { + long storeId = insertStore("entity-store", "CU 마트 B1", 37.5001, 127.0000, StoreStatus.ACTIVE, true); + + Store store = + storeRepository.findByIdAndStatus(storeId, StoreStatus.ACTIVE).orElseThrow(); + + assertThat(store.getNameNormalized()).isEqualTo("cu마트b1"); + assertThat(store.getStatus()).isEqualTo(StoreStatus.ACTIVE); + assertThat(store.getOrigin()).isEqualTo(StoreOrigin.SBIZ); + assertThat(store.isActive()).isTrue(); + assertThat(store.isVerified()).isTrue(); + } + + @Test + void returnsOnlyActiveStoresInsideRadiusInStablePriorityOrder() { + long nearbyId = insertStore("nearby-store", "한스푼 역삼점", 37.5001, 127.0000, StoreStatus.ACTIVE, false); + long verifiedId = insertStore("verified-store", "한스푼 강남점", 37.5010, 127.0000, StoreStatus.ACTIVE, true); + insertStore("inactive-store", "한스푼 휴업점", 37.5000, 127.0000, StoreStatus.INACTIVE, true); + insertStore("outside-store", "한스푼 외곽점", 37.5200, 127.0000, StoreStatus.ACTIVE, true); + + List candidates = + storeRepository.findNearbyCandidates(CENTER_LATITUDE, CENTER_LONGITUDE, 500, 10); + + assertThat(candidates).extracting(StoreCandidateProjection::getStoreId).containsExactly(verifiedId, nearbyId); + assertThat(candidates.getFirst().getVerified()).isTrue(); + assertThat(candidates.getFirst().getCategoryCode()).isEqualTo("I20101"); + assertThat(candidates.getFirst().getCategoryName()).isEqualTo("한식 일반 음식점업"); + assertThat(candidates.getFirst().getDistanceMeters()).isBetween(100, 120); + assertThat(candidates.getFirst().getNameSimilarity()).isNull(); + } + + @Test + void normalizesSearchTermAndFiltersCandidatesWithTrigramIndexOperator() { + long expectedId = insertStore("search-store", "CU 마트 B1", 37.5001, 127.0000, StoreStatus.ACTIVE, false); + insertStore("other-store", "한스푼 식당", 37.5002, 127.0000, StoreStatus.ACTIVE, false); + + List candidates = + storeRepository.findNearbyCandidatesByName("cu 마트 b1", CENTER_LATITUDE, CENTER_LONGITUDE, 500, 10); + + assertThat(candidates).hasSize(1); + assertThat(candidates.getFirst().getStoreId()).isEqualTo(expectedId); + assertThat(candidates.getFirst().getName()).isEqualTo("CU 마트 B1"); + assertThat(candidates.getFirst().getNameSimilarity()).isEqualTo(1.0); + } + + private long insertStore( + String storeNo, String name, double latitude, double longitude, StoreStatus status, boolean verified) { + Timestamp inactiveAt = status == StoreStatus.INACTIVE ? Timestamp.from(Instant.now()) : null; + Timestamp verifiedAt = verified ? Timestamp.from(Instant.now()) : null; + + return jdbcTemplate.queryForObject( + """ + insert into stores + (sbiz_store_no, name, category_id, road_address, lat, lng, status, origin, + inactive_at, verified_at, last_batch_id) + values (?, ?, ?, '서울특별시 강남구 테헤란로', ?, ?, ?, 'sbiz', ?, ?, ?) + returning id + """, + Long.class, + storeNo, + name, + categoryId, + latitude, + longitude, + status.getCode(), + inactiveAt, + verifiedAt, + batchId); + } +} diff --git a/src/test/java/com/hanspoon/backend_api/domain/store/controller/StoreControllerTest.java b/src/test/java/com/hanspoon/backend_api/domain/store/controller/StoreControllerTest.java new file mode 100644 index 0000000..64abec6 --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/domain/store/controller/StoreControllerTest.java @@ -0,0 +1,110 @@ +package com.hanspoon.backend_api.domain.store.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateListResponse; +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateResponse; +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateSearchRequest; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; +import com.hanspoon.backend_api.domain.store.service.StoreSearchService; +import com.hanspoon.backend_api.global.exception.GlobalExceptionHandler; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class StoreControllerTest { + + private final StoreSearchService storeSearchService = mock(StoreSearchService.class); + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new StoreController(storeSearchService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void returnsStoreCandidates() throws Exception { + when(storeSearchService.findCandidates(any())) + .thenReturn(new StoreCandidateListResponse(List.of(new StoreCandidateResponse( + 42L, + "한스푼", + "강남점", + "서울특별시 강남구 테헤란로 1", + 37.4978, + 127.0275, + 42, + "I20101", + "한식 일반 음식점업", + true, + StoreMatchMethod.NAME_SEARCH)))); + + mockMvc.perform( + post("/api/v1/stores/candidates") + .contentType("application/json") + .content( + """ + { + "latitude": 37.4979, + "longitude": 127.0276, + "query": "한스푼" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].storeId").value(42)) + .andExpect(jsonPath("$.items[0].name").value("한스푼")) + .andExpect(jsonPath("$.items[0].distanceMeters").value(42)) + .andExpect(jsonPath("$.items[0].verified").value(true)) + .andExpect(jsonPath("$.items[0].matchMethod").value("name_search")); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(StoreCandidateSearchRequest.class); + verify(storeSearchService).findCandidates(requestCaptor.capture()); + StoreCandidateSearchRequest request = requestCaptor.getValue(); + assertThat(request.resolvedRadiusMeters()).isEqualTo(100); + assertThat(request.resolvedLimit()).isEqualTo(20); + } + + @Test + void rejectsMissingLocation() throws Exception { + mockMvc.perform(post("/api/v1/stores/candidates") + .contentType("application/json") + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")); + + verify(storeSearchService, never()).findCandidates(any()); + } + + @Test + void rejectsOutOfKoreaCoordinatesAndOversizedQuery() throws Exception { + mockMvc.perform(post("/api/v1/stores/candidates") + .contentType("application/json") + .content( + """ + { + "latitude": 40.0, + "longitude": 127.0276, + "query": "%s", + "radiusMeters": 1001, + "limit": 21 + } + """ + .formatted("가".repeat(101)))) + .andExpect(status().isBadRequest()); + + verify(storeSearchService, never()).findCandidates(any()); + } +} diff --git a/src/test/java/com/hanspoon/backend_api/domain/store/service/StoreSearchServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/store/service/StoreSearchServiceTest.java new file mode 100644 index 0000000..e9b9a9e --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/domain/store/service/StoreSearchServiceTest.java @@ -0,0 +1,74 @@ +package com.hanspoon.backend_api.domain.store.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateListResponse; +import com.hanspoon.backend_api.domain.store.dto.StoreCandidateSearchRequest; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; +import com.hanspoon.backend_api.domain.store.repository.StoreCandidateProjection; +import com.hanspoon.backend_api.domain.store.repository.StoreRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StoreSearchServiceTest { + + @Mock + private StoreRepository storeRepository; + + @InjectMocks + private StoreSearchService storeSearchService; + + @Test + void searchesNearbyWithDefaultsWhenQueryIsBlank() { + StoreCandidateSearchRequest request = new StoreCandidateSearchRequest(37.4979, 127.0276, " ", null, null); + StoreCandidateProjection candidate = candidate(); + when(storeRepository.findNearbyCandidates(37.4979, 127.0276, 100, 20)).thenReturn(List.of(candidate)); + + StoreCandidateListResponse response = storeSearchService.findCandidates(request); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().getFirst().storeId()).isEqualTo(42L); + assertThat(response.items().getFirst().name()).isEqualTo("한스푼"); + assertThat(response.items().getFirst().verified()).isTrue(); + assertThat(response.items().getFirst().matchMethod()).isEqualTo(StoreMatchMethod.GPS_CANDIDATE); + verify(storeRepository).findNearbyCandidates(37.4979, 127.0276, 100, 20); + verifyNoMoreInteractions(storeRepository); + } + + @Test + void trimsNameAndUsesNameSearchWithRequestedBounds() { + StoreCandidateSearchRequest request = new StoreCandidateSearchRequest(37.4979, 127.0276, " 한스푼 ", 300, 5); + when(storeRepository.findNearbyCandidatesByName("한스푼", 37.4979, 127.0276, 300, 5)) + .thenReturn(List.of()); + + StoreCandidateListResponse response = storeSearchService.findCandidates(request); + + assertThat(response.items()).isEmpty(); + verify(storeRepository).findNearbyCandidatesByName("한스푼", 37.4979, 127.0276, 300, 5); + verifyNoMoreInteractions(storeRepository); + } + + private StoreCandidateProjection candidate() { + StoreCandidateProjection candidate = mock(StoreCandidateProjection.class); + when(candidate.getStoreId()).thenReturn(42L); + when(candidate.getName()).thenReturn("한스푼"); + when(candidate.getBranchName()).thenReturn("강남점"); + when(candidate.getRoadAddress()).thenReturn("서울특별시 강남구 테헤란로 1"); + when(candidate.getLatitude()).thenReturn(37.4978); + when(candidate.getLongitude()).thenReturn(127.0275); + when(candidate.getDistanceMeters()).thenReturn(42); + when(candidate.getCategoryCode()).thenReturn("I20101"); + when(candidate.getCategoryName()).thenReturn("한식 일반 음식점업"); + when(candidate.getVerified()).thenReturn(true); + return candidate; + } +}