Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@

<div align="center">

<img src="infra/images/architecture.png" alt="한스푼 시스템 아키텍처" width="900" />
<img src="infra/images/aws_architecture.png" alt="한스푼 시스템 아키텍처" width="900" />

</div>

Expand Down
255 changes: 0 additions & 255 deletions infra/images/architecture.svg

This file was deleted.

Binary file added infra/images/aws_architecture.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
10 changes: 5 additions & 5 deletions scripts/load_stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 뒤 실행하면 통계 갱신 실패를
Expand Down
1 change: 1 addition & 0 deletions scripts/test_load_stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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/판정은 백그라운드 처리")
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, ScanFeedbackAnswer> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 스캔 시각
Expand All @@ -27,6 +28,7 @@ public record ScanResultResponse(
ScanStatus status,
String title,
ScanStoreSummary store,
ScanRecordResponse record,
Integer menuCount,
Integer riskyMenuCount,
Instant scannedAt,
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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이다.
*
* <p>스캔 시작 시 선택한 가게는 {@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<String, ScanFeedbackAnswer> feedback;

@Column(name = "saved_at", nullable = false)
private Instant savedAt;

private ScanRecord(
UUID scanSessionId,
Long attachedStoreId,
String attachedStoreNameSnapshot,
StoreMatchMethod attachedStoreMatchMethod,
Map<String, ScanFeedbackAnswer> 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<String, ScanFeedbackAnswer> feedback,
Instant savedAt) {
return new ScanRecord(
scanSessionId, attachedStoreId, attachedStoreNameSnapshot, attachedStoreMatchMethod, feedback, savedAt);
}

public void update(
Long attachedStoreId,
String attachedStoreNameSnapshot,
StoreMatchMethod attachedStoreMatchMethod,
Map<String, ScanFeedbackAnswer> feedback,
Instant savedAt) {
this.attachedStoreId = attachedStoreId;
this.attachedStoreNameSnapshot = attachedStoreNameSnapshot;
this.attachedStoreMatchMethod = attachedStoreMatchMethod;
this.feedback = Map.copyOf(feedback);
this.savedAt = savedAt;
}
}
Original file line number Diff line number Diff line change
@@ -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<ScanRecord, UUID> {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +21,10 @@ public interface ScanSessionRepository extends JpaRepository<ScanSession, UUID>

Optional<ScanSession> findByIdAndUserId(UUID id, UUID userId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select scan from ScanSession scan where scan.id = :id and scan.userId = :userId")
Optional<ScanSession> findByIdAndUserIdForUpdate(@Param("id") UUID id, @Param("userId") UUID userId);

Optional<ScanSession> findByUserIdAndStorageKey(UUID userId, String storageKey);

@Modifying(clearAutomatically = true, flushAutomatically = true)
Expand Down
Loading
Loading