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;
+ }
+}