diff --git a/README.md b/README.md index aafb35f..508d9c7 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@
-한스푼 시스템 아키텍처 +한스푼 시스템 아키텍처
diff --git a/infra/images/architecture.svg b/infra/images/architecture.svg deleted file mode 100644 index 59e2d24..0000000 --- a/infra/images/architecture.svg +++ /dev/null @@ -1,255 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 한스푼 · AWS 아키텍처 - ap-northeast-2 · 단일 EC2 · NAT/ALB 없음 - - - - 사용자 - - 모바일 · 웹 - - - Vercel - han-spoon.site · SPA + /api/* rewrite - - - GitHub Actions - backend · ai 저장소 - - - - CloudFront - - api.han-spoon.site - 오리진 2개 · PriceClass_200 - - - ACM 인증서 - us-east-1 (CloudFront 전용) - - - - AWS 리전 · ap-northeast-2 - - - - VPC - 10.0.0.0/16 - - - - 퍼블릭 서브넷 · AZ-a - 10.0.0.0/24 - - - EIP (고정) - - - - EC2 t4g.small · ASG 1대 · ECS - - - ECS Task · network_mode: host - - - backend - - :8080 · 640MB - - ai - - - backend → ai · POST /v1/ocr · /v1/ruleengine · /v1/result - http://localhost:8000 — host 네트워크 공유라 프로세스 간 호출 - :8000 · 768MB - - - - 퍼블릭 서브넷 · AZ-b - 10.0.1.0/24 - 비어 있음 - ASG 다중 AZ 확장용 - - - - S3 Gateway Endpoint - - 라우팅 테이블 2개 연결 · 무료 - - - 인터넷 게이트웨이 - - 아웃바운드 · NAT 없음 - - - - 프라이빗 서브넷 · AZ-a - 10.0.10.0/24 - - RDS PostgreSQL 16 - - db.t4g.micro · Single-AZ · gp3 20GB - 퍼블릭 접근 불가 · 백업 7일 - - - 프라이빗 서브넷 · AZ-b - 10.0.11.0/24 - DB 서브넷 그룹 소속 - Multi-AZ 승격용 - - - 보안 그룹 2개 · instance ← CloudFront / rds ← instance - - - - 리전 서비스 (VPC 외부) - - - Amazon S3 - - hanspoon-prod-images-* · 퍼블릭 차단 · 버저닝 - scans/ 90일 만료 · menus/ 영구 보관 - presigned PUT/GET · CORS 허용 오리진 - - - Amazon ECR - - backend · ai · IMMUTABLE 태그 - 최근 10개 유지 - - - SSM Parameter Store - - SecureString 6개 · 태스크 시작 시 주입 - db_password · jwt_secret · google_client_id … - - - CloudWatch Logs - - /ecs/hanspoon-prod/backend - /ecs/hanspoon-prod/ai - - - Budgets - 월 예산 알림 · 80% / 100% - - - - CLOVA OCR - General V2 · 네이버 클라우드 - - - OpenAI - gpt-4o-mini - - - - - - - /api/* rewrite - → api.han-spoon.site - - - TLS - - - - api 오리진 · EIP:8080 · http-only - 커스텀 헤더 X-Origin-Secret · SG 는 CloudFront 프리픽스 목록만 허용 - - - - images 오리진 · /menus/* · OAC - - - - OIDC AssumeRole - 이미지 푸시 + 태스크 갱신 - - - - 5432 - SG: instance 에서만 - - - - - S3 트래픽은 인터넷을 타지 않음 - - - - - - - - - - - 아웃바운드 (IGW 경유) - - - - - 뷰어 요청 - - VPC 내부 - - 배포 · 아웃바운드 - 점선 박스 = 현재 비어 있으나 확장 시 사용 - diff --git a/infra/images/aws_architecture.png b/infra/images/aws_architecture.png new file mode 100644 index 0000000..512aca7 Binary files /dev/null and b/infra/images/aws_architecture.png differ diff --git a/infra/images/architecture.png b/infra/images/azure_architecture.png similarity index 100% rename from infra/images/architecture.png rename to infra/images/azure_architecture.png diff --git a/scripts/load_stores.py b/scripts/load_stores.py index 1607ec3..6af7e25 100755 --- a/scripts/load_stores.py +++ b/scripts/load_stores.py @@ -101,11 +101,11 @@ def record_failed_batch(cmd: list[str], version: str) -> bool: sql = f""" INSERT INTO store_import_batches (source, source_version, row_count, status, started_at, finished_at) -VALUES ('{SOURCE}', '{version}', 0, 'failed', now(), now()) +VALUES ('{SOURCE}', '{version}', 0, 'failed', clock_timestamp(), clock_timestamp()) ON CONFLICT (source, source_version) DO UPDATE SET row_count = 0, status = 'failed', - finished_at = now() + finished_at = clock_timestamp() WHERE store_import_batches.status <> 'completed'; """ try: @@ -368,9 +368,9 @@ def emit(w: io.TextIOBase, files: list[Path], category: str, sweep: bool, versio 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()) +VALUES ('{SOURCE}', '{version}', 'running', clock_timestamp()) ON CONFLICT (source, source_version) -DO UPDATE SET status = 'running', started_at = now(), finished_at = NULL +DO UPDATE SET status = 'running', started_at = clock_timestamp(), finished_at = NULL RETURNING id AS batch_id \\gset @@ -510,7 +510,7 @@ def store_rows(): w.write("""\ UPDATE store_import_batches - SET status = 'completed', finished_at = now(), row_count = (SELECT count(*) FROM stg_store) + SET status = 'completed', finished_at = clock_timestamp(), row_count = (SELECT count(*) FROM stg_store) WHERE id = :batch_id; -- ANALYZE까지 성공해야 배치를 완료한다. COMMIT 뒤 실행하면 통계 갱신 실패를 diff --git a/scripts/test_load_stores.py b/scripts/test_load_stores.py index 3645e2a..36e78a7 100644 --- a/scripts/test_load_stores.py +++ b/scripts/test_load_stores.py @@ -77,6 +77,7 @@ def test_import_sql_serializes_loaders_and_analyzes_before_commit(self): sql = output.getvalue() self.assertIn("pg_advisory_xact_lock", sql) self.assertIn("store import count mismatch", sql) + self.assertIn("finished_at = clock_timestamp()", sql) self.assertLess(sql.index("ANALYZE stores;"), sql.index("COMMIT;")) def test_legacy_sweep_option_maps_to_inactive_sweep(self): diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/controller/ScanController.java b/src/main/java/com/hanspoon/backend_api/domain/scan/controller/ScanController.java index c14d46f..65e8089 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/controller/ScanController.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/controller/ScanController.java @@ -1,10 +1,13 @@ package com.hanspoon.backend_api.domain.scan.controller; +import com.hanspoon.backend_api.domain.scan.dto.SaveScanRecordRequest; 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.ScanRecordResponse; import com.hanspoon.backend_api.domain.scan.dto.ScanResultResponse; 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.service.ScanRecordService; import com.hanspoon.backend_api.domain.scan.service.ScanService; import com.hanspoon.backend_api.global.common.PageResponse; import com.hanspoon.backend_api.global.security.CurrentUser; @@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; @@ -33,9 +37,11 @@ public class ScanController { private final ScanService scanService; + private final ScanRecordService scanRecordService; - public ScanController(ScanService scanService) { + public ScanController(ScanService scanService, ScanRecordService scanRecordService) { this.scanService = scanService; + this.scanRecordService = scanRecordService; } @Operation(summary = "스캔 시작(비동기). 202 로 scanId 반환 후 OCR/판정은 백그라운드 처리") @@ -61,6 +67,19 @@ public ScanResultResponse getScan(@CurrentUser String userId, @PathVariable UUID return scanService.getScan(UUID.fromString(userId), scanId); } + @Operation(summary = "완료된 스캔을 기록으로 저장/갱신. 스캔 시점 가게는 고정, 미지정 스캔만 사후 연결 가능") + @PutMapping("/{scanId}/record") + public ScanRecordResponse saveRecord( + @CurrentUser String userId, @PathVariable UUID scanId, @Valid @RequestBody SaveScanRecordRequest request) { + return scanRecordService.save(UUID.fromString(userId), scanId, request); + } + + @Operation(summary = "저장된 본인의 스캔 기록 조회.") + @GetMapping("/{scanId}/record") + public ScanRecordResponse getRecord(@CurrentUser String userId, @PathVariable UUID scanId) { + return scanRecordService.get(UUID.fromString(userId), scanId); + } + @Operation(summary = "스캔 이력 제목 수정. 본인 스캔만. 없거나 타인 소유면 404") @PatchMapping("/{scanId}") public ScanHistoryItem updateTitle( diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/SaveScanRecordRequest.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/SaveScanRecordRequest.java new file mode 100644 index 0000000..1d8d962 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/SaveScanRecordRequest.java @@ -0,0 +1,33 @@ +package com.hanspoon.backend_api.domain.scan.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer; +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.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.util.Map; + +/** + * 스캔 기록 저장 요청. + * + * @param storeId 가게 없이 시작한 스캔에 사후 연결할 가게 ID + * @param storeMatchMethod 사후 연결 가게를 찾은 경로. storeId와 함께 입력하거나 함께 생략 + * @param feedback 프로필 항목 ID별 식단 의사소통 경험. 응답하지 않은 항목은 생략 + */ +@Schema(description = "스캔 기록 저장 요청") +public record SaveScanRecordRequest( + @Schema(description = "사후 연결할 가게 ID(선택)", example = "10342") @Positive Long storeId, + @Schema(description = "가게 후보 검색 경로(선택)", example = "name_search") StoreMatchMethod storeMatchMethod, + @Schema(description = "프로필 항목별 의사소통 피드백") @Size(max = 32) Map<@NotBlank @Size(max = 100) @Pattern(regexp = "[a-z0-9:_-]+") String, @NotNull ScanFeedbackAnswer> + feedback) { + + @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/dto/ScanRecordResponse.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanRecordResponse.java new file mode 100644 index 0000000..fe2b0f7 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanRecordResponse.java @@ -0,0 +1,39 @@ +package com.hanspoon.backend_api.domain.scan.dto; + +import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer; +import com.hanspoon.backend_api.domain.scan.entity.ScanRecord; +import com.hanspoon.backend_api.domain.scan.entity.ScanSession; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +/** 명시적으로 보관한 스캔 기록. storeLocked=true면 스캔 시작 시 확정된 가게라 변경할 수 없다. */ +@Schema(description = "저장된 스캔 기록") +public record ScanRecordResponse( + UUID scanId, + ScanStoreSummary store, + boolean storeLocked, + StoreMatchMethod storeMatchMethod, + Map feedback, + Instant savedAt) { + + public static ScanRecordResponse of(ScanSession session, ScanRecord record) { + boolean storeLocked = session.getStoreId() != null; + ScanStoreSummary store = storeLocked + ? new ScanStoreSummary(session.getStoreId(), session.getStoreNameSnapshot()) + : record.getAttachedStoreId() == null + ? null + : new ScanStoreSummary(record.getAttachedStoreId(), record.getAttachedStoreNameSnapshot()); + StoreMatchMethod matchMethod = + storeLocked ? session.getStoreMatchMethod() : record.getAttachedStoreMatchMethod(); + return new ScanRecordResponse( + session.getId(), + store, + storeLocked, + matchMethod, + Map.copyOf(record.getFeedback()), + record.getSavedAt()); + } +} 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 adb059e..dd0cf96 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 @@ -13,6 +13,7 @@ * @param status 스캔 상태 (processing | completed | failed | needs_retake) * @param title 유저가 수정한 제목(미수정이면 null). 기본 제목은 FE 가 scannedAt 을 로케일로 포맷해 표시 * @param store 스캔 시점 가게 정보. 가게 도입 전 레거시 스캔은 null + * @param record 사용자가 명시적으로 보관한 기록. 아직 저장하지 않았으면 null * @param menuCount 추출된 메뉴 수 * @param riskyMenuCount 위험/주의 메뉴 수 * @param scannedAt 스캔 시각 @@ -27,6 +28,7 @@ public record ScanResultResponse( ScanStatus status, String title, ScanStoreSummary store, + ScanRecordResponse record, Integer menuCount, Integer riskyMenuCount, Instant scannedAt, diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanFeedbackAnswer.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanFeedbackAnswer.java new file mode 100644 index 0000000..30f9a6b --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanFeedbackAnswer.java @@ -0,0 +1,31 @@ +package com.hanspoon.backend_api.domain.scan.entity; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; + +/** 가게에서 식단 요구사항을 전달한 경험. 응답하지 않은 항목은 JSON 객체에서 생략. */ +public enum ScanFeedbackAnswer { + YES("yes"), + NO("no"), + UNKNOWN("unknown"); + + private final String code; + + ScanFeedbackAnswer(String code) { + this.code = code; + } + + @JsonValue + public String getCode() { + return code; + } + + @JsonCreator + public static ScanFeedbackAnswer fromCode(String code) { + return Arrays.stream(values()) + .filter(value -> value.code.equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown scan feedback answer: " + code)); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanRecord.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanRecord.java new file mode 100644 index 0000000..f2098fb --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanRecord.java @@ -0,0 +1,84 @@ +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; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * 사용자가 명시적으로 보관한 스캔 기록. scan_sessions 와 1:1이다. + * + *

스캔 시작 시 선택한 가게는 {@link ScanSession}에 고정한다. attachedStore*는 가게 없이 시작한 스캔에 나중에 + * 연결한 가게만 표현해 분석 출처와 기록 분류를 섞지 않는다. + */ +@Entity +@Table(name = "scan_records") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ScanRecord extends BaseEntity { + + @Id + @Column(name = "scan_session_id", columnDefinition = "uuid", updatable = false, nullable = false) + private UUID scanSessionId; + + @Column(name = "attached_store_id") + private Long attachedStoreId; + + @Column(name = "attached_store_name_snapshot", length = 200) + private String attachedStoreNameSnapshot; + + @Column(name = "attached_store_match_method", length = 20) + private StoreMatchMethod attachedStoreMatchMethod; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "feedback", columnDefinition = "jsonb", nullable = false) + private Map feedback; + + @Column(name = "saved_at", nullable = false) + private Instant savedAt; + + private ScanRecord( + UUID scanSessionId, + Long attachedStoreId, + String attachedStoreNameSnapshot, + StoreMatchMethod attachedStoreMatchMethod, + Map feedback, + Instant savedAt) { + this.scanSessionId = scanSessionId; + update(attachedStoreId, attachedStoreNameSnapshot, attachedStoreMatchMethod, feedback, savedAt); + } + + public static ScanRecord create( + UUID scanSessionId, + Long attachedStoreId, + String attachedStoreNameSnapshot, + StoreMatchMethod attachedStoreMatchMethod, + Map feedback, + Instant savedAt) { + return new ScanRecord( + scanSessionId, attachedStoreId, attachedStoreNameSnapshot, attachedStoreMatchMethod, feedback, savedAt); + } + + public void update( + Long attachedStoreId, + String attachedStoreNameSnapshot, + StoreMatchMethod attachedStoreMatchMethod, + Map feedback, + Instant savedAt) { + this.attachedStoreId = attachedStoreId; + this.attachedStoreNameSnapshot = attachedStoreNameSnapshot; + this.attachedStoreMatchMethod = attachedStoreMatchMethod; + this.feedback = Map.copyOf(feedback); + this.savedAt = savedAt; + } +} diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanRecordRepository.java b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanRecordRepository.java new file mode 100644 index 0000000..5380b9a --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanRecordRepository.java @@ -0,0 +1,7 @@ +package com.hanspoon.backend_api.domain.scan.repository; + +import com.hanspoon.backend_api.domain.scan.entity.ScanRecord; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScanRecordRepository extends JpaRepository {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java index ab838f4..f17e92e 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java @@ -2,12 +2,14 @@ import com.hanspoon.backend_api.domain.scan.entity.ScanSession; import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; +import jakarta.persistence.LockModeType; import java.time.Instant; import java.util.Optional; import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -19,6 +21,10 @@ public interface ScanSessionRepository extends JpaRepository Optional findByIdAndUserId(UUID id, UUID userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select scan from ScanSession scan where scan.id = :id and scan.userId = :userId") + Optional findByIdAndUserIdForUpdate(@Param("id") UUID id, @Param("userId") UUID userId); + Optional findByUserIdAndStorageKey(UUID userId, String storageKey); @Modifying(clearAutomatically = true, flushAutomatically = true) diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordService.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordService.java new file mode 100644 index 0000000..ed576aa --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordService.java @@ -0,0 +1,148 @@ +package com.hanspoon.backend_api.domain.scan.service; + +import com.hanspoon.backend_api.domain.scan.dto.SaveScanRecordRequest; +import com.hanspoon.backend_api.domain.scan.dto.ScanRecordResponse; +import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer; +import com.hanspoon.backend_api.domain.scan.entity.ScanRecord; +import com.hanspoon.backend_api.domain.scan.entity.ScanSession; +import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; +import com.hanspoon.backend_api.domain.scan.repository.ScanRecordRepository; +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.user.entity.AllergyCode; +import com.hanspoon.backend_api.domain.user.entity.ReligionType; +import com.hanspoon.backend_api.domain.user.entity.VegetarianType; +import com.hanspoon.backend_api.global.exception.BusinessException; +import com.hanspoon.backend_api.global.exception.ErrorCode; +import java.time.Instant; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ScanRecordService { + + private static final Set SUPPORTED_FEEDBACK_ITEM_IDS = supportedFeedbackItemIds(); + + private final ScanSessionRepository scanSessionRepository; + private final ScanRecordRepository scanRecordRepository; + private final StoreRepository storeRepository; + + public ScanRecordService( + ScanSessionRepository scanSessionRepository, + ScanRecordRepository scanRecordRepository, + StoreRepository storeRepository) { + this.scanSessionRepository = scanSessionRepository; + this.scanRecordRepository = scanRecordRepository; + this.storeRepository = storeRepository; + } + + /** 같은 scanId에 대한 PUT은 한 행을 갱신한다. 스캔 행 잠금으로 최초 생성 경쟁도 직렬화한다. */ + @Transactional + public ScanRecordResponse save(UUID userId, UUID scanId, SaveScanRecordRequest request) { + ScanSession session = scanSessionRepository + .findByIdAndUserIdForUpdate(scanId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.SCAN_NOT_FOUND)); + if (session.getScanStatus() != ScanStatus.COMPLETED) { + throw new BusinessException(ErrorCode.SCAN_NOT_COMPLETED); + } + if (session.getStoreId() != null && request.storeId() != null) { + throw new BusinessException(ErrorCode.SCAN_STORE_CONTEXT_LOCKED); + } + + ScanRecord record = scanRecordRepository.findById(scanId).orElse(null); + AttachedStore attachedStore = resolveAttachedStore(session, request, record); + Map feedback = request.feedback() == null ? Map.of() : request.feedback(); + if (!SUPPORTED_FEEDBACK_ITEM_IDS.containsAll(feedback.keySet())) { + throw new BusinessException(ErrorCode.INVALID_SCAN_FEEDBACK); + } + if (session.getStoreId() == null && attachedStore == null && !feedback.isEmpty()) { + throw new BusinessException(ErrorCode.SCAN_FEEDBACK_REQUIRES_STORE); + } + + Instant savedAt = Instant.now(); + if (record == null) { + record = ScanRecord.create( + scanId, + attachedStore == null ? null : attachedStore.id(), + attachedStore == null ? null : attachedStore.name(), + attachedStore == null ? null : attachedStore.matchMethod(), + feedback, + savedAt); + } + record.update( + attachedStore == null ? null : attachedStore.id(), + attachedStore == null ? null : attachedStore.name(), + attachedStore == null ? null : attachedStore.matchMethod(), + feedback, + savedAt); + scanRecordRepository.save(record); + return ScanRecordResponse.of(session, record); + } + + @Transactional(readOnly = true) + public ScanRecordResponse get(UUID userId, UUID scanId) { + ScanSession session = scanSessionRepository + .findByIdAndUserId(scanId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.SCAN_NOT_FOUND)); + ScanRecord record = scanRecordRepository + .findById(scanId) + .orElseThrow(() -> new BusinessException(ErrorCode.SCAN_RECORD_NOT_FOUND)); + return ScanRecordResponse.of(session, record); + } + + @Transactional(readOnly = true) + public ScanRecordResponse findResponse(ScanSession session) { + return scanRecordRepository + .findById(session.getId()) + .map(record -> ScanRecordResponse.of(session, record)) + .orElse(null); + } + + private AttachedStore resolveAttachedStore( + ScanSession session, SaveScanRecordRequest request, ScanRecord existingRecord) { + if (session.getStoreId() != null || request.storeId() == null) { + return null; + } + if (existingRecord != null + && Objects.equals(existingRecord.getAttachedStoreId(), request.storeId()) + && existingRecord.getAttachedStoreMatchMethod() == request.storeMatchMethod()) { + // 이미 저장한 가게는 이후 비활성화돼도 과거 기록 갱신이 가능해야 한다. + return new AttachedStore( + existingRecord.getAttachedStoreId(), + existingRecord.getAttachedStoreNameSnapshot(), + existingRecord.getAttachedStoreMatchMethod()); + } + Store store = storeRepository + .findByIdAndStatus(request.storeId(), StoreStatus.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + return new AttachedStore(store.getId(), store.getName(), request.storeMatchMethod()); + } + + private static Set supportedFeedbackItemIds() { + Set ids = new HashSet<>(); + for (VegetarianType type : VegetarianType.values()) { + ids.add("vegan:" + type.getCode()); + } + for (ReligionType type : ReligionType.values()) { + if (type != ReligionType.NONE) { + ids.add("religion:" + type.getCode()); + } + } + for (AllergyCode code : AllergyCode.values()) { + ids.add("allergy:" + code.getCode()); + } + ids.add("preference:no-spicy"); + ids.add("preference:no-alcohol"); + return Set.copyOf(ids); + } + + private record AttachedStore(Long id, String name, StoreMatchMethod matchMethod) {} +} 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 1209e08..4a79283 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 @@ -39,6 +39,7 @@ public class ScanService { private final ScanSessionRepository scanSessionRepository; private final MenuAnalysisRepository menuAnalysisRepository; private final StoreRepository storeRepository; + private final ScanRecordService scanRecordService; private final ScanProcessor scanProcessor; private final ScanStateWriter scanStateWriter; @@ -47,12 +48,14 @@ public ScanService( ScanSessionRepository scanSessionRepository, MenuAnalysisRepository menuAnalysisRepository, StoreRepository storeRepository, + ScanRecordService scanRecordService, ScanProcessor scanProcessor, ScanStateWriter scanStateWriter) { this.s3StorageService = s3StorageService; this.scanSessionRepository = scanSessionRepository; this.menuAnalysisRepository = menuAnalysisRepository; this.storeRepository = storeRepository; + this.scanRecordService = scanRecordService; this.scanProcessor = scanProcessor; this.scanStateWriter = scanStateWriter; } @@ -118,6 +121,7 @@ public ScanResultResponse getScan(UUID userId, UUID scanId) { session.getScanStatus(), session.getTitle(), toStoreSummary(session), + scanRecordService.findResponse(session), session.getMenuCount(), session.getRiskyMenuCount(), session.getScannedAt(), 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 b695c9b..fa3835a 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 @@ -44,6 +44,16 @@ public enum ErrorCode { 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."), + SCAN_NOT_COMPLETED(HttpStatus.CONFLICT, "SCAN_NOT_COMPLETED", "Only a completed scan can be saved."), + SCAN_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "SCAN_RECORD_NOT_FOUND", "Saved scan record not found."), + SCAN_STORE_CONTEXT_LOCKED( + HttpStatus.CONFLICT, + "SCAN_STORE_CONTEXT_LOCKED", + "The store selected when the scan started cannot be changed."), + SCAN_FEEDBACK_REQUIRES_STORE( + HttpStatus.BAD_REQUEST, "SCAN_FEEDBACK_REQUIRES_STORE", "Restaurant feedback requires a linked store."), + INVALID_SCAN_FEEDBACK( + HttpStatus.BAD_REQUEST, "INVALID_SCAN_FEEDBACK", "Scan feedback contains an unsupported profile item."), // ── 가게 ──────────────────────────────────────────────────────────── STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_NOT_FOUND", "Active store not found."), diff --git a/src/main/resources/db/migration/V6__scan_records.sql b/src/main/resources/db/migration/V6__scan_records.sql new file mode 100644 index 0000000..291cb97 --- /dev/null +++ b/src/main/resources/db/migration/V6__scan_records.sql @@ -0,0 +1,46 @@ +-- 사용자가 명시적으로 보관한 스캔 기록. +-- scan_sessions.store_id 는 분석 시작 시점의 불변 컨텍스트이므로 사후 가게 연결로 수정하지 않는다. +-- 가게 없이 시작한 스캔에 사용자가 나중에 연결한 가게만 attached_store_* 에 저장한다. +CREATE TABLE scan_records ( + scan_session_id UUID NOT NULL, + attached_store_id BIGINT NULL, + attached_store_name_snapshot VARCHAR(200) NULL, + attached_store_match_method VARCHAR(20) NULL, + feedback JSONB NOT NULL DEFAULT '{}'::jsonb, + saved_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT pk_scan_records PRIMARY KEY (scan_session_id), + CONSTRAINT fk_scan_records_session FOREIGN KEY (scan_session_id) + REFERENCES scan_sessions (id) ON DELETE CASCADE, + CONSTRAINT fk_scan_records_attached_store FOREIGN KEY (attached_store_id) + REFERENCES stores (id) ON DELETE RESTRICT, + CONSTRAINT ck_scan_records_match_method CHECK ( + attached_store_match_method IS NULL + OR attached_store_match_method IN ('gps_candidate', 'name_search', 'kakao_fallback') + ), + CONSTRAINT ck_scan_records_store_context CHECK ( + (attached_store_id IS NULL + AND attached_store_name_snapshot IS NULL + AND attached_store_match_method IS NULL) + OR + (attached_store_id IS NOT NULL + AND attached_store_name_snapshot IS NOT NULL + AND btrim(attached_store_name_snapshot) <> '' + AND attached_store_match_method IS NOT NULL) + ), + CONSTRAINT ck_scan_records_feedback_object CHECK (jsonb_typeof(feedback) = 'object') +); + +CREATE INDEX idx_scan_records_attached_store + ON scan_records (attached_store_id) WHERE attached_store_id IS NOT NULL; + +COMMENT ON TABLE scan_records IS + '사용자가 Keep this scan으로 보관한 1:1 기록. 분석 컨텍스트와 사후 가게 연결을 분리한다.'; +COMMENT ON COLUMN scan_records.attached_store_id IS + '가게 없이 시작한 스캔의 사후 연결만 저장. scan_sessions.store_id가 있으면 반드시 NULL.'; +COMMENT ON COLUMN scan_records.attached_store_name_snapshot IS + '사후 연결 시 서버가 stores.name에서 복사한 표시용 스냅샷.'; +COMMENT ON COLUMN scan_records.feedback IS + '프로필 항목 ID별 식단 의사소통 경험(yes/no/unknown). 가게가 있는 기록에서만 허용.'; diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/ScanRecordPersistenceIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/ScanRecordPersistenceIntegrationTest.java new file mode 100644 index 0000000..eb554a8 --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/ScanRecordPersistenceIntegrationTest.java @@ -0,0 +1,135 @@ +package com.hanspoon.backend_api.domain.scan; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hanspoon.backend_api.TestcontainersConfiguration; +import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer; +import com.hanspoon.backend_api.domain.scan.entity.ScanRecord; +import com.hanspoon.backend_api.domain.scan.entity.ScanSession; +import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; +import com.hanspoon.backend_api.domain.scan.repository.ScanRecordRepository; +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; +import jakarta.persistence.PersistenceContext; +import java.time.Instant; +import java.util.Map; +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; + +@SpringBootTest +@Import(TestcontainersConfiguration.class) +@Transactional +class ScanRecordPersistenceIntegrationTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private ScanSessionRepository scanSessionRepository; + + @Autowired + private ScanRecordRepository scanRecordRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @PersistenceContext + private EntityManager entityManager; + + @Test + void persistsAttachedStoreSnapshotAndFeedbackAsOneToOneRecord() { + User user = userRepository.save(User.create(uniqueEmail("record"), "record-user", "ko")); + ScanSession scan = scanSessionRepository.save( + ScanSession.create(user.getId(), null, 1, 0, ScanStatus.COMPLETED, Instant.now())); + long storeId = insertStore("RECORD-STORE-1", "기록 식당"); + + scanRecordRepository.save(ScanRecord.create( + scan.getId(), + storeId, + "기록 식당", + StoreMatchMethod.NAME_SEARCH, + Map.of("allergy:shrimp", ScanFeedbackAnswer.YES), + Instant.parse("2026-09-18T12:00:00Z"))); + entityManager.flush(); + entityManager.clear(); + + ScanRecord reloaded = scanRecordRepository.findById(scan.getId()).orElseThrow(); + assertThat(reloaded.getAttachedStoreId()).isEqualTo(storeId); + assertThat(reloaded.getAttachedStoreNameSnapshot()).isEqualTo("기록 식당"); + assertThat(reloaded.getAttachedStoreMatchMethod()).isEqualTo(StoreMatchMethod.NAME_SEARCH); + assertThat(reloaded.getFeedback()).containsEntry("allergy:shrimp", ScanFeedbackAnswer.YES); + } + + @Test + void scanDeletionCascadesToRecord() { + User user = userRepository.save(User.create(uniqueEmail("cascade"), "record-user", "ko")); + ScanSession scan = scanSessionRepository.save( + ScanSession.create(user.getId(), null, 1, 0, ScanStatus.COMPLETED, Instant.now())); + scanRecordRepository.save(ScanRecord.create(scan.getId(), null, null, null, Map.of(), Instant.now())); + entityManager.flush(); + entityManager.clear(); + + scanSessionRepository.delete( + scanSessionRepository.findById(scan.getId()).orElseThrow()); + entityManager.flush(); + entityManager.clear(); + + assertThat(scanRecordRepository.findById(scan.getId())).isEmpty(); + } + + @Test + void databaseRejectsPartialAttachedStoreContext() { + User user = userRepository.save(User.create(uniqueEmail("partial"), "record-user", "ko")); + ScanSession scan = scanSessionRepository.save( + ScanSession.create(user.getId(), null, 1, 0, ScanStatus.COMPLETED, Instant.now())); + long storeId = insertStore("RECORD-STORE-2", "불완전 기록 식당"); + entityManager.flush(); + + assertThatThrownBy(() -> jdbcTemplate.update( + """ + insert into scan_records (scan_session_id, attached_store_id, feedback, saved_at) + values (?, ?, '{}'::jsonb, now()) + """, + scan.getId(), + storeId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private long insertStore(String storeNo, String name) { + Long categoryId = jdbcTemplate.queryForObject( + "insert into store_categories(code, name, level) values (?, '음식', 1) returning id", + Long.class, + "I" + UUID.randomUUID().toString().substring(0, 5)); + Long batchId = jdbcTemplate.queryForObject( + """ + insert into store_import_batches(source, source_version) + values ('sbiz', ?) returning id + """, + Long.class, + String.valueOf(100000 + Math.abs(UUID.randomUUID().hashCode() % 899999))); + return jdbcTemplate.queryForObject( + """ + insert into stores(sbiz_store_no, name, category_id, lat, lng, origin, last_batch_id) + values (?, ?, ?, 37.5, 127.0, 'sbiz', ?) returning id + """, + Long.class, + storeNo, + name, + categoryId, + batchId); + } + + private String uniqueEmail(String prefix) { + return prefix + "+" + UUID.randomUUID() + "@example.com"; + } +} 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 e844d77..d2e7c9b 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 @@ -10,22 +10,29 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; 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.scan.dto.ScanCreatedResponse; import com.hanspoon.backend_api.domain.scan.dto.ScanHistoryItem; +import com.hanspoon.backend_api.domain.scan.dto.ScanRecordResponse; 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.ScanFeedbackAnswer; import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; +import com.hanspoon.backend_api.domain.scan.service.ScanRecordService; import com.hanspoon.backend_api.domain.scan.service.ScanService; +import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod; import com.hanspoon.backend_api.global.common.PageResponse; import com.hanspoon.backend_api.global.exception.BusinessException; import com.hanspoon.backend_api.global.exception.ErrorCode; import com.hanspoon.backend_api.global.exception.GlobalExceptionHandler; import com.hanspoon.backend_api.global.security.CurrentUser; +import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -44,6 +51,7 @@ class ScanControllerTest { private static final UUID USER_ID = UUID.randomUUID(); private final ScanService scanService = mock(ScanService.class); + private final ScanRecordService scanRecordService = mock(ScanRecordService.class); private MockMvc mockMvc; @BeforeEach @@ -63,7 +71,7 @@ public Object resolveArgument( return USER_ID.toString(); } }; - mockMvc = MockMvcBuilders.standaloneSetup(new ScanController(scanService)) + mockMvc = MockMvcBuilders.standaloneSetup(new ScanController(scanService, scanRecordService)) .setControllerAdvice(new GlobalExceptionHandler()) .setCustomArgumentResolvers(currentUserResolver, new PageableHandlerMethodArgumentResolver()) .build(); @@ -194,6 +202,7 @@ void getScanReturnsResult() throws Exception { ScanStatus.COMPLETED, null, new ScanStoreSummary(42L, "한스푼"), + null, 2, 1, null, @@ -211,6 +220,61 @@ void getScanReturnsResult() throws Exception { .andExpect(jsonPath("$.menuCount").value(2)); } + @Test + void saveRecordReturnsEffectiveLockedStore() throws Exception { + UUID scanId = UUID.randomUUID(); + Instant savedAt = Instant.parse("2026-09-18T12:00:00Z"); + when(scanRecordService.save(eq(USER_ID), eq(scanId), any())) + .thenReturn(new ScanRecordResponse( + scanId, + new ScanStoreSummary(42L, "한스푼"), + true, + StoreMatchMethod.GPS_CANDIDATE, + Map.of("allergy:shrimp", ScanFeedbackAnswer.YES), + savedAt)); + + mockMvc.perform( + put("/api/v1/scans/{scanId}/record", scanId) + .contentType("application/json") + .content( + """ + {"feedback":{"allergy:shrimp":"yes"}} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scanId").value(scanId.toString())) + .andExpect(jsonPath("$.store.storeId").value(42)) + .andExpect(jsonPath("$.storeLocked").value(true)) + .andExpect(jsonPath("$.storeMatchMethod").value("gps_candidate")) + .andExpect(jsonPath("$.feedback['allergy:shrimp']").value("yes")) + .andExpect(jsonPath("$.savedAt").value(savedAt.toString())); + } + + @Test + void saveRecordRejectsPartialStoreContext() throws Exception { + mockMvc.perform(put("/api/v1/scans/{scanId}/record", UUID.randomUUID()) + .contentType("application/json") + .content(""" + {"storeId":42,"feedback":{}} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")); + + verify(scanRecordService, never()).save(any(), any(), any()); + } + + @Test + void getRecordReturnsSavedRecord() throws Exception { + UUID scanId = UUID.randomUUID(); + when(scanRecordService.get(USER_ID, scanId)) + .thenReturn(new ScanRecordResponse(scanId, null, false, null, Map.of(), Instant.now())); + + mockMvc.perform(get("/api/v1/scans/{scanId}/record", scanId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scanId").value(scanId.toString())) + .andExpect(jsonPath("$.store").doesNotExist()) + .andExpect(jsonPath("$.storeLocked").value(false)); + } + @Test void getScansReturnsPage() throws Exception { UUID scanId = UUID.randomUUID(); diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordServiceTest.java new file mode 100644 index 0000000..f86a9c6 --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordServiceTest.java @@ -0,0 +1,223 @@ +package com.hanspoon.backend_api.domain.scan.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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 com.hanspoon.backend_api.domain.scan.dto.SaveScanRecordRequest; +import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer; +import com.hanspoon.backend_api.domain.scan.entity.ScanRecord; +import com.hanspoon.backend_api.domain.scan.entity.ScanSession; +import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; +import com.hanspoon.backend_api.domain.scan.repository.ScanRecordRepository; +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.global.exception.BusinessException; +import com.hanspoon.backend_api.global.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ScanRecordServiceTest { + + @Mock + private ScanSessionRepository scanSessionRepository; + + @Mock + private ScanRecordRepository scanRecordRepository; + + @Mock + private StoreRepository storeRepository; + + @InjectMocks + private ScanRecordService scanRecordService; + + @Test + void savesLockedScanStoreWithoutDuplicatingItAsAttachment() { + UUID userId = UUID.randomUUID(); + ScanSession session = completedSessionWithStore(userId, 42L, "한스푼"); + when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId)) + .thenReturn(Optional.of(session)); + when(scanRecordRepository.findById(session.getId())).thenReturn(Optional.empty()); + when(scanRecordRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var response = scanRecordService.save( + userId, + session.getId(), + new SaveScanRecordRequest(null, null, Map.of("allergy:shrimp", ScanFeedbackAnswer.YES))); + + assertThat(response.storeLocked()).isTrue(); + assertThat(response.store().storeId()).isEqualTo(42L); + assertThat(response.feedback()).containsEntry("allergy:shrimp", ScanFeedbackAnswer.YES); + ArgumentCaptor captor = ArgumentCaptor.forClass(ScanRecord.class); + verify(scanRecordRepository).save(captor.capture()); + assertThat(captor.getValue().getAttachedStoreId()).isNull(); + } + + @Test + void attachesActiveStoreOnlyWhenScanStartedWithoutStore() { + UUID userId = UUID.randomUUID(); + ScanSession session = completedSessionWithoutStore(userId); + Store store = store(77L, "나중에 고른 식당"); + when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId)) + .thenReturn(Optional.of(session)); + when(storeRepository.findByIdAndStatus(77L, StoreStatus.ACTIVE)).thenReturn(Optional.of(store)); + when(scanRecordRepository.findById(session.getId())).thenReturn(Optional.empty()); + when(scanRecordRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var response = scanRecordService.save( + userId, session.getId(), new SaveScanRecordRequest(77L, StoreMatchMethod.NAME_SEARCH, Map.of())); + + assertThat(response.storeLocked()).isFalse(); + assertThat(response.store().storeId()).isEqualTo(77L); + assertThat(response.store().name()).isEqualTo("나중에 고른 식당"); + assertThat(response.storeMatchMethod()).isEqualTo(StoreMatchMethod.NAME_SEARCH); + verify(storeRepository).findByIdAndStatus(77L, StoreStatus.ACTIVE); + } + + @Test + void rejectsStoreOverrideWhenScanContextIsLocked() { + UUID userId = UUID.randomUUID(); + ScanSession session = completedSessionWithStore(userId, 42L, "원래 식당"); + when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId)) + .thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> scanRecordService.save( + userId, + session.getId(), + new SaveScanRecordRequest(77L, StoreMatchMethod.NAME_SEARCH, Map.of()))) + .isInstanceOf(BusinessException.class) + .extracting(error -> ((BusinessException) error).getErrorCode()) + .isEqualTo(ErrorCode.SCAN_STORE_CONTEXT_LOCKED); + + verify(storeRepository, never()).findByIdAndStatus(any(), any()); + verify(scanRecordRepository, never()).save(any()); + } + + @Test + void rejectsFeedbackWhenNoStoreIsLinked() { + UUID userId = UUID.randomUUID(); + ScanSession session = completedSessionWithoutStore(userId); + when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId)) + .thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> scanRecordService.save( + userId, + session.getId(), + new SaveScanRecordRequest(null, null, Map.of("preference:no-spicy", ScanFeedbackAnswer.NO)))) + .isInstanceOf(BusinessException.class) + .extracting(error -> ((BusinessException) error).getErrorCode()) + .isEqualTo(ErrorCode.SCAN_FEEDBACK_REQUIRES_STORE); + } + + @Test + void rejectsUnsupportedFeedbackItem() { + UUID userId = UUID.randomUUID(); + ScanSession session = completedSessionWithStore(userId, 42L, "원래 식당"); + when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId)) + .thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> scanRecordService.save( + userId, + session.getId(), + new SaveScanRecordRequest(null, null, Map.of("allergy:not-supported", ScanFeedbackAnswer.YES)))) + .isInstanceOf(BusinessException.class) + .extracting(error -> ((BusinessException) error).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_SCAN_FEEDBACK); + + verify(scanRecordRepository, never()).save(any()); + } + + @Test + void rejectsNonCompletedScan() { + UUID userId = UUID.randomUUID(); + ScanSession session = ScanSession.startWithoutStore(userId, "scans/processing.jpg"); + when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId)) + .thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> scanRecordService.save( + userId, session.getId(), new SaveScanRecordRequest(null, null, Map.of()))) + .isInstanceOf(BusinessException.class) + .extracting(error -> ((BusinessException) error).getErrorCode()) + .isEqualTo(ErrorCode.SCAN_NOT_COMPLETED); + } + + @Test + void updatesExistingRecordIdempotently() { + UUID userId = UUID.randomUUID(); + ScanSession session = completedSessionWithoutStore(userId); + ScanRecord existing = + ScanRecord.create(session.getId(), null, null, null, Map.of(), Instant.parse("2026-09-18T00:00:00Z")); + when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId)) + .thenReturn(Optional.of(session)); + when(scanRecordRepository.findById(session.getId())).thenReturn(Optional.of(existing)); + + var response = scanRecordService.save(userId, session.getId(), new SaveScanRecordRequest(null, null, Map.of())); + + assertThat(response.scanId()).isEqualTo(session.getId()); + verify(scanRecordRepository).save(existing); + } + + @Test + void preservesExistingAttachedStoreAfterItBecomesInactive() { + UUID userId = UUID.randomUUID(); + ScanSession session = completedSessionWithoutStore(userId); + ScanRecord existing = ScanRecord.create( + session.getId(), + 77L, + "지금은 비활성 식당", + StoreMatchMethod.NAME_SEARCH, + Map.of(), + Instant.parse("2026-09-18T00:00:00Z")); + when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId)) + .thenReturn(Optional.of(session)); + when(scanRecordRepository.findById(session.getId())).thenReturn(Optional.of(existing)); + + var response = scanRecordService.save( + userId, + session.getId(), + new SaveScanRecordRequest( + 77L, StoreMatchMethod.NAME_SEARCH, Map.of("allergy:shrimp", ScanFeedbackAnswer.UNKNOWN))); + + assertThat(response.store().name()).isEqualTo("지금은 비활성 식당"); + assertThat(response.feedback()).containsEntry("allergy:shrimp", ScanFeedbackAnswer.UNKNOWN); + verify(storeRepository, never()).findByIdAndStatus(any(), any()); + } + + private ScanSession completedSessionWithStore(UUID userId, Long storeId, String storeName) { + ScanSession session = + ScanSession.start(userId, "scans/locked.jpg", storeId, storeName, StoreMatchMethod.GPS_CANDIDATE); + session.applyOcrResult(1, Instant.now()); + session.applyRuleEngineResult(0, ScanStatus.COMPLETED); + return session; + } + + private ScanSession completedSessionWithoutStore(UUID userId) { + ScanSession session = ScanSession.startWithoutStore(userId, "scans/unlocked.jpg"); + session.applyOcrResult(1, Instant.now()); + session.applyRuleEngineResult(0, ScanStatus.COMPLETED); + return session; + } + + 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/ScanServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java index c3da70e..739a5ad 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 @@ -12,9 +12,12 @@ 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.ScanRecordResponse; +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; +import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer; import com.hanspoon.backend_api.domain.scan.entity.ScanSession; import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; @@ -30,6 +33,7 @@ import com.hanspoon.backend_api.global.exception.ErrorCode; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -58,6 +62,9 @@ class ScanServiceTest { @Mock private StoreRepository storeRepository; + @Mock + private ScanRecordService scanRecordService; + @Mock private ScanProcessor scanProcessor; @@ -232,6 +239,14 @@ void getScanReturnsResultForOwner() { when(scanSessionRepository.findByIdAndUserId(scanId, userId)).thenReturn(Optional.of(session)); when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(scanId)) .thenReturn(List.of()); + ScanRecordResponse savedRecord = new ScanRecordResponse( + scanId, + new ScanStoreSummary(42L, "한스푼"), + true, + StoreMatchMethod.GPS_CANDIDATE, + Map.of("allergy:shrimp", ScanFeedbackAnswer.YES), + Instant.now()); + when(scanRecordService.findResponse(session)).thenReturn(savedRecord); var response = scanService.getScan(userId, scanId); @@ -240,6 +255,7 @@ void getScanReturnsResultForOwner() { assertThat(response.title()).isEqualTo("custom title"); assertThat(response.store().storeId()).isEqualTo(42L); assertThat(response.store().name()).isEqualTo("한스푼"); + assertThat(response.record()).isEqualTo(savedRecord); assertThat(response.menuCount()).isEqualTo(2); assertThat(response.riskyMenuCount()).isEqualTo(1); assertThat(response.menus()).isEmpty();