From df0d2b53a722d3a8f847a8d32e8b6d4e633c85b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=AC=EA=B2=BD=EB=B3=B4=20kyungbo=20Shim?= Date: Sat, 2 May 2026 03:27:32 +0900 Subject: [PATCH 1/2] Chore/zone crawl followups (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 폐점 후보 자동 판별 기능 추가 * refactor: RestaurantSyncService 를 4개의 하위클래스 및 파사드패턴으로 분리 --- .../com/kustaurant/map/MapConstantsV2.java | 16 +- .../controller/RestaurantCrawlController.java | 2 +- .../single/RestaurantSingleCrawler.java | 10 +- .../service/RestaurantRawSaveService.java | 21 +- .../controller/RestaurantSyncController.java | 34 ++ ...dCandidateAutoProcessJobStartResponse.java | 6 + ...CandidateAutoProcessJobStatusResponse.java | 13 + .../ClosedCandidateAutoProcessResponse.java | 9 + .../dto/NewCandidateAutoApproveResponse.java | 8 + .../ClosedCandidateAutoProcessService.java | 186 ++++++++ .../service/CuisineMappingService.java | 70 +++ .../service/RestaurantSyncApplyService.java | 196 ++++++++ .../RestaurantSyncCandidateService.java | 222 +++++++++ .../service/RestaurantSyncService.java | 451 +----------------- .../controller/RestaurantApiController.java | 1 - .../resources/static/js/admin/admin-crawl.js | 15 + .../resources/static/js/admin/admin-sync.js | 147 +++++- .../js/restaurant/restaurantMenuScript.js | 18 +- .../main/resources/templates/admin/admin.html | 10 + .../templates/restaurant/restaurant.html | 11 +- 20 files changed, 987 insertions(+), 459 deletions(-) create mode 100644 server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessJobStartResponse.java create mode 100644 server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessJobStatusResponse.java create mode 100644 server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessResponse.java create mode 100644 server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/NewCandidateAutoApproveResponse.java create mode 100644 server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/ClosedCandidateAutoProcessService.java create mode 100644 server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/CuisineMappingService.java create mode 100644 server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncApplyService.java create mode 100644 server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncCandidateService.java diff --git a/common/jpa/src/main/java/com/kustaurant/map/MapConstantsV2.java b/common/jpa/src/main/java/com/kustaurant/map/MapConstantsV2.java index 23555cae..91f84826 100644 --- a/common/jpa/src/main/java/com/kustaurant/map/MapConstantsV2.java +++ b/common/jpa/src/main/java/com/kustaurant/map/MapConstantsV2.java @@ -12,14 +12,14 @@ private MapConstantsV2() {} List.of( new CoordinateV2(37.5401732,127.062852), new CoordinateV2(37.5378977,127.0696049), - new CoordinateV2(37.5422127,127.071636), // - new CoordinateV2(37.5428253,127.0710213), // - new CoordinateV2(37.5422656,127.0707644), // - new CoordinateV2(37.5441701,127.0651452) // + new CoordinateV2(37.5422127,127.071636), + new CoordinateV2(37.5428253,127.0710213), + new CoordinateV2(37.5422656,127.0707644), + new CoordinateV2(37.5441701,127.0651452) ) ), new ZonePolygon( // 중문~어대 - com.kustaurant.map.ZoneType.MIDDLE_TO_PARK, + ZoneType.MIDDLE_TO_PARK, List.of( new CoordinateV2(37.5422127,127.071636), new CoordinateV2(37.5428253,127.0710213), @@ -32,7 +32,7 @@ private MapConstantsV2() {} ) ), new ZonePolygon( // 후문 - com.kustaurant.map.ZoneType.BACK_GATE, + ZoneType.BACK_GATE, List.of( new CoordinateV2(37.5445367,127.0728555), new CoordinateV2(37.5444815,127.0731477), @@ -48,7 +48,7 @@ private MapConstantsV2() {} ) ), new ZonePolygon( // 정문 - com.kustaurant.map.ZoneType.FRONT_GATE, + ZoneType.FRONT_GATE, List.of( new CoordinateV2(37.5397225,127.0708216), new CoordinateV2(37.5385701,127.0750329), @@ -66,7 +66,7 @@ private MapConstantsV2() {} ) ), new ZonePolygon( // 구의역 - com.kustaurant.map.ZoneType.GUI_STATION, + ZoneType.GUI_STATION, List.of( new CoordinateV2(37.536197,127.0837349), new CoordinateV2(37.5370672,127.0876883), diff --git a/server/crawler/src/main/java/com/kustaurant/crawler/RestaurantSync/controller/RestaurantCrawlController.java b/server/crawler/src/main/java/com/kustaurant/crawler/RestaurantSync/controller/RestaurantCrawlController.java index 7aa409cd..4ab2eb2d 100644 --- a/server/crawler/src/main/java/com/kustaurant/crawler/RestaurantSync/controller/RestaurantCrawlController.java +++ b/server/crawler/src/main/java/com/kustaurant/crawler/RestaurantSync/controller/RestaurantCrawlController.java @@ -31,7 +31,7 @@ public class RestaurantCrawlController { private final ZoneTestCrawler testCrawler; private final ZoneCrawlJobService zoneCrawlJobService; - // 1. 단건 크롤 & 저장 + // 1. 단건 크롤 & 저장 . @PostMapping({"/crawl-one"}) public RestaurantRaw crawlOne(@RequestBody @Valid RestaurantCrawlRequest request) { return crawler.crawl(request.normalizedPlaceUrl()); diff --git a/server/crawler/src/main/java/com/kustaurant/crawler/RestaurantSync/service/single/RestaurantSingleCrawler.java b/server/crawler/src/main/java/com/kustaurant/crawler/RestaurantSync/service/single/RestaurantSingleCrawler.java index 66d852db..629f0003 100644 --- a/server/crawler/src/main/java/com/kustaurant/crawler/RestaurantSync/service/single/RestaurantSingleCrawler.java +++ b/server/crawler/src/main/java/com/kustaurant/crawler/RestaurantSync/service/single/RestaurantSingleCrawler.java @@ -23,6 +23,7 @@ public class RestaurantSingleCrawler { private static final int MAX_RETRIES = 2; + private static final String NAVER_PLACE_NOT_FOUND_TEXT = "요청하신 페이지를 찾을 수 없습니다."; private final PlaywrightManager playwrightManager; private final RestaurantPageDriver pageDriver; @@ -156,9 +157,10 @@ private RestaurantRaw crawlInternal(String placeUrl, boolean analyzeMode) { log.warn("home html 캡처 실패. sourcePlaceId={}, placeId={}", placeId, placeUrl); } log.info( - " === 네이버플레이스 분석 완료. sourcePlaceId={}, placeName={}, category={}, restaurantAddress={}, phone={}, lat={}, lng={}, menuCount={}", + " === 네이버플레이스 분석 완료. sourcePlaceId={}, placeName={}, category={}, restaurantAddress={}, phone={}, lat={}, lng={}, zoneType={}, zoneDescription={}, menuCount={}", result.sourcePlaceId(), result.placeName(), result.category(), result.restaurantAddress(), result.phoneNumber(), result.latitude(), result.longitude(), + zoneType, zoneType == null ? null : zoneType.getDescription(), result.menus() == null ? 0 : result.menus().size() ); } else { @@ -177,6 +179,7 @@ private RestaurantRaw crawlInternal(String placeUrl, boolean analyzeMode) { private boolean shouldRetryWithBackoff(RestaurantRaw result, int attempt) { if (attempt > MAX_RETRIES) return false; if (result == null) return true; + if (isNaverPlaceNotFoundResult(result)) return false; boolean noBasic = isBlank(result.placeName()) && isBlank(result.category()) && isBlank(result.restaurantAddress()) && isBlank(result.phoneNumber()); @@ -185,6 +188,11 @@ private boolean shouldRetryWithBackoff(RestaurantRaw result, int attempt) { return (noBasic && menuCount == 0) || missingCoordinates; } + private boolean isNaverPlaceNotFoundResult(RestaurantRaw result) { + String address = result.restaurantAddress(); + return address != null && address.contains(NAVER_PLACE_NOT_FOUND_TEXT); + } + private long retryBackoffMillis(int attempt) { if (attempt == 1) return 30_000; return 60_000; diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantCrawl/service/RestaurantRawSaveService.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantCrawl/service/RestaurantRawSaveService.java index 0f2b10e9..d082915b 100644 --- a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantCrawl/service/RestaurantRawSaveService.java +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantCrawl/service/RestaurantRawSaveService.java @@ -17,15 +17,21 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @RequiredArgsConstructor public class RestaurantRawSaveService { + private static final int IMAGE_URL_MAX_LENGTH = 512; + private final RestaurantCrawlerClient crawlerClient; private final RestaurantCrawlRawRepository rawRepository; private final RestaurantMenuRawRepository menuRawRepository; + private final PlatformTransactionManager transactionManager; @Transactional public RestaurantCrawlResponse crawlAndSave(String placeId) { @@ -39,6 +45,7 @@ public RestaurantCrawlResponse saveResult(RestaurantRaw result, ZoneType crawlSc deleteExistingRawByPlaceIdOrUrl(result); ZoneType zoneType = crawlScope != null ? crawlScope : (result.crawlScope() == null ? ZoneType.OUT_OF_ZONE : result.crawlScope()); + String normalizedImageUrl = normalizeImageUrl(result.imageUrl()); RestaurantCrawlRawEntity rawEntity = rawRepository.save( RestaurantCrawlRawEntity.success( result.sourcePlaceId(), @@ -49,7 +56,7 @@ public RestaurantCrawlResponse saveResult(RestaurantRaw result, ZoneType crawlSc result.phoneNumber(), result.latitude(), result.longitude(), - result.imageUrl(), + normalizedImageUrl, zoneType ) ); @@ -83,7 +90,6 @@ public RestaurantCrawlResponse saveResult(RestaurantRaw result, ZoneType crawlSc ); } - @Transactional public BatchSaveResult saveResultsBatch(List results, ZoneType crawlScope) { if (results == null || results.isEmpty()) { return new BatchSaveResult(0, 0, List.of()); @@ -92,6 +98,8 @@ public BatchSaveResult saveResultsBatch(List results, ZoneType cr int savedCount = 0; int failedCount = 0; Set failedPlaceIds = new LinkedHashSet<>(); + TransactionTemplate requiresNewTx = new TransactionTemplate(transactionManager); + requiresNewTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); for (RestaurantRaw result : results) { String placeId = result == null ? null : result.sourcePlaceId(); @@ -99,7 +107,7 @@ public BatchSaveResult saveResultsBatch(List results, ZoneType cr if (result == null) { throw new IllegalArgumentException("naver place crawl result is null"); } - saveResult(result, crawlScope); + requiresNewTx.executeWithoutResult(status -> saveResult(result, crawlScope)); savedCount++; } catch (Exception e) { failedCount++; @@ -162,6 +170,13 @@ private String defaultIfBlank(String value, String fallback) { return (value == null || value.isBlank()) ? fallback : value; } + private String normalizeImageUrl(String imageUrl) { + if (imageUrl == null) { + return null; + } + return imageUrl.length() > IMAGE_URL_MAX_LENGTH ? null : imageUrl; + } + public record BatchSaveResult( int savedCount, int failedCount, diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/RestaurantSyncController.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/RestaurantSyncController.java index 5bf6a76e..5849fef8 100644 --- a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/RestaurantSyncController.java +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/RestaurantSyncController.java @@ -3,6 +3,10 @@ import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncCandidateActionResponse; import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncCandidateApproveRequest; import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncCandidateResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessJobStartResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessJobStatusResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.NewCandidateAutoApproveResponse; import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncRunRequest; import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncRunResponse; import com.kustaurant.kustaurant.admin.RestaurantSync.domain.SyncCandidateStatus; @@ -64,4 +68,34 @@ public RestaurantSyncCandidateActionResponse reject( ) { return restaurantSyncService.reject(candidateId, principal == null ? "UNKNOWN_ADMIN" : principal.getName()); } + + @PreAuthorize("isAuthenticated() and hasRole('ROLE_ADMIN')") + @PostMapping("/candidates/closed/auto-process") + public ClosedCandidateAutoProcessResponse autoProcessClosedCandidates(Principal principal) { + return restaurantSyncService.autoProcessClosedCandidates( + principal == null ? "UNKNOWN_ADMIN" : principal.getName() + ); + } + + @PreAuthorize("isAuthenticated() and hasRole('ROLE_ADMIN')") + @PostMapping("/candidates/closed/auto-process/jobs") + public ClosedCandidateAutoProcessJobStartResponse startClosedAutoProcessJob(Principal principal) { + return restaurantSyncService.startClosedAutoProcessJob( + principal == null ? "UNKNOWN_ADMIN" : principal.getName() + ); + } + + @PreAuthorize("isAuthenticated() and hasRole('ROLE_ADMIN')") + @GetMapping("/candidates/closed/auto-process/jobs/{jobId}") + public ClosedCandidateAutoProcessJobStatusResponse getClosedAutoProcessJobStatus(@PathVariable String jobId) { + return restaurantSyncService.getClosedAutoProcessJobStatus(jobId); + } + + @PreAuthorize("isAuthenticated() and hasRole('ROLE_ADMIN')") + @PostMapping("/candidates/new/auto-approve") + public NewCandidateAutoApproveResponse autoApproveNewCandidates(Principal principal) { + return restaurantSyncService.autoApproveNewCandidates( + principal == null ? "UNKNOWN_ADMIN" : principal.getName() + ); + } } diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessJobStartResponse.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessJobStartResponse.java new file mode 100644 index 00000000..b0aaa826 --- /dev/null +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessJobStartResponse.java @@ -0,0 +1,6 @@ +package com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto; + +public record ClosedCandidateAutoProcessJobStartResponse( + String jobId +) { +} diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessJobStatusResponse.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessJobStatusResponse.java new file mode 100644 index 00000000..eec52a8d --- /dev/null +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessJobStatusResponse.java @@ -0,0 +1,13 @@ +package com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto; + +public record ClosedCandidateAutoProcessJobStatusResponse( + String jobId, + String status, + int total, + int processed, + int autoClosedCount, + int recrawledCount, + int failedCount, + boolean done +) { +} diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessResponse.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessResponse.java new file mode 100644 index 00000000..2967eab9 --- /dev/null +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/ClosedCandidateAutoProcessResponse.java @@ -0,0 +1,9 @@ +package com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto; + +public record ClosedCandidateAutoProcessResponse( + int totalPendingClosed, + int autoClosedCount, + int recrawledCount, + int failedCount +) { +} diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/NewCandidateAutoApproveResponse.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/NewCandidateAutoApproveResponse.java new file mode 100644 index 00000000..93cb615e --- /dev/null +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/controller/dto/NewCandidateAutoApproveResponse.java @@ -0,0 +1,8 @@ +package com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto; + +public record NewCandidateAutoApproveResponse( + int totalPendingNew, + int approvedCount, + int failedCount +) { +} diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/ClosedCandidateAutoProcessService.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/ClosedCandidateAutoProcessService.java new file mode 100644 index 00000000..fb9cd0c5 --- /dev/null +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/ClosedCandidateAutoProcessService.java @@ -0,0 +1,186 @@ +package com.kustaurant.kustaurant.admin.RestaurantSync.service; + +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantCrawlerClient; +import com.kustaurant.kustaurant.admin.RestaurantCrawl.service.RestaurantRawSaveService; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessJobStartResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessJobStatusResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.domain.SyncCandidateStatus; +import com.kustaurant.kustaurant.admin.RestaurantSync.domain.SyncCandidateType; +import com.kustaurant.kustaurant.admin.RestaurantSync.infrastructure.RestaurantSyncCandidateEntity; +import com.kustaurant.kustaurant.admin.RestaurantSync.infrastructure.RestaurantSyncCandidateRepository; +import com.kustaurant.map.ZoneType; +import com.kustaurant.restaurantSync.RestaurantRaw; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Slf4j +@Service +@RequiredArgsConstructor +// 폐점 후보 자동 판별/자동 처리 및 진행률 job 상태 관리를 담당하는 서비스. +public class ClosedCandidateAutoProcessService { + + private static final String NAVER_PLACE_NOT_FOUND_TEXT = "요청하신 페이지를 찾을 수 없습니다."; + + private final RestaurantSyncCandidateRepository candidateRepository; + private final RestaurantCrawlerClient restaurantCrawlerClient; + private final RestaurantRawSaveService restaurantRawSaveService; + private final RestaurantSyncApplyService applyService; + + private final Map jobs = new ConcurrentHashMap<>(); + + @Transactional + public ClosedCandidateAutoProcessResponse autoProcessClosedCandidates(String reviewedBy) { + List closedCandidates = loadPendingClosedCandidates(); + int autoClosedCount = 0; + int recrawledCount = 0; + int failedCount = 0; + + for (RestaurantSyncCandidateEntity candidate : closedCandidates) { + String originalPlaceId = candidate.getPlaceId(); + String lookupPlaceId = toLookupPlaceId(originalPlaceId); + try { + RestaurantRaw analyzed = restaurantCrawlerClient.analyzeOne(lookupPlaceId); + if (isClosedByNotFoundMessage(analyzed)) { + applyService.applyClosedRestaurant(originalPlaceId); + candidateRepository.delete(candidate); + autoClosedCount++; + log.info("폐점 후보 자동처리: 폐점 처리 완료. candidateId={}, placeId={}, lookupPlaceId={}", + candidate.getId(), originalPlaceId, lookupPlaceId); + continue; + } + + ZoneType zoneType = analyzed.crawlScope() == null ? ZoneType.OUT_OF_ZONE : analyzed.crawlScope(); + restaurantRawSaveService.saveResult(analyzed, zoneType); + candidateRepository.delete(candidate); + recrawledCount++; + log.info("폐점 후보 자동처리: 재크롤 raw 저장 완료. candidateId={}, placeId={}, lookupPlaceId={}, zoneType={}", + candidate.getId(), originalPlaceId, lookupPlaceId, zoneType); + } catch (Exception e) { + failedCount++; + log.info("폐점 후보 자동처리 실패. candidateId={}, placeId={}, lookupPlaceId={}, reason={}", + candidate.getId(), originalPlaceId, lookupPlaceId, e.getMessage()); + } + } + + log.info("폐점 후보 자동처리 집계. totalPendingClosed={}, autoClosedCount={}, recrawledCount={}, failedCount={}", + closedCandidates.size(), autoClosedCount, recrawledCount, failedCount); + return new ClosedCandidateAutoProcessResponse(closedCandidates.size(), autoClosedCount, recrawledCount, failedCount); + } + + public ClosedCandidateAutoProcessJobStartResponse startClosedAutoProcessJob(String reviewedBy) { + String jobId = UUID.randomUUID().toString(); + ClosedAutoProcessJobState state = new ClosedAutoProcessJobState(jobId); + jobs.put(jobId, state); + CompletableFuture.runAsync(() -> runJob(state, reviewedBy)); + return new ClosedCandidateAutoProcessJobStartResponse(jobId); + } + + public ClosedCandidateAutoProcessJobStatusResponse getClosedAutoProcessJobStatus(String jobId) { + ClosedAutoProcessJobState state = jobs.get(jobId); + if (state == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "closed auto process job not found: " + jobId); + } + return state.toResponse(); + } + + private void runJob(ClosedAutoProcessJobState state, String reviewedBy) { + state.markRunning(); + try { + List candidates = loadPendingClosedCandidates(); + state.setTotal(candidates.size()); + for (RestaurantSyncCandidateEntity candidate : candidates) { + String originalPlaceId = candidate.getPlaceId(); + String lookupPlaceId = toLookupPlaceId(originalPlaceId); + try { + RestaurantRaw analyzed = restaurantCrawlerClient.analyzeOne(lookupPlaceId); + if (isClosedByNotFoundMessage(analyzed)) { + applyService.applyClosedRestaurant(originalPlaceId); + candidateRepository.delete(candidate); + state.incAutoClosed(); + log.info("폐점 후보 자동처리: 폐점 처리 완료. candidateId={}, placeId={}, lookupPlaceId={}", + candidate.getId(), originalPlaceId, lookupPlaceId); + } else { + ZoneType zoneType = analyzed.crawlScope() == null ? ZoneType.OUT_OF_ZONE : analyzed.crawlScope(); + restaurantRawSaveService.saveResult(analyzed, zoneType); + candidateRepository.delete(candidate); + state.incRecrawled(); + log.info("폐점 후보 자동처리: 재크롤 raw 저장 완료. candidateId={}, placeId={}, lookupPlaceId={}, zoneType={}", + candidate.getId(), originalPlaceId, lookupPlaceId, zoneType); + } + } catch (Exception e) { + state.incFailed(); + log.info("폐점 후보 자동처리 실패. candidateId={}, placeId={}, lookupPlaceId={}, reason={}", + candidate.getId(), originalPlaceId, lookupPlaceId, e.getMessage()); + } finally { + state.incProcessed(); + } + } + log.info("폐점 후보 자동처리 집계. totalPendingClosed={}, autoClosedCount={}, recrawledCount={}, failedCount={}", + state.total, state.autoClosedCount, state.recrawledCount, state.failedCount); + state.markCompleted(); + } catch (Exception e) { + state.markFailed(); + log.info("폐점 후보 자동처리 job 실패. jobId={}, reason={}", state.jobId, e.getMessage()); + } + } + + private List loadPendingClosedCandidates() { + return candidateRepository.findAllByCandidateStatusOrderByCreatedAtDesc(SyncCandidateStatus.PENDING) + .stream() + .filter(candidate -> candidate.getCandidateType() == SyncCandidateType.CLOSED) + .toList(); + } + + private boolean isClosedByNotFoundMessage(RestaurantRaw analyzed) { + if (analyzed == null) return false; + String address = analyzed.restaurantAddress(); + return address != null && address.contains(NAVER_PLACE_NOT_FOUND_TEXT); + } + + private String toLookupPlaceId(String placeId) { + if (placeId == null) return ""; + int idx = placeId.indexOf('_'); + if (idx <= 0) return placeId; + return placeId.substring(0, idx); + } + + private static final class ClosedAutoProcessJobState { + private final String jobId; + private String status = "PENDING"; + private int total; + private int processed; + private int autoClosedCount; + private int recrawledCount; + private int failedCount; + private boolean done; + + private ClosedAutoProcessJobState(String jobId) { + this.jobId = jobId; + } + + private synchronized void markRunning() { this.status = "RUNNING"; } + private synchronized void markCompleted() { this.status = "COMPLETED"; this.done = true; } + private synchronized void markFailed() { this.status = "FAILED"; this.done = true; } + private synchronized void setTotal(int total) { this.total = total; } + private synchronized void incProcessed() { this.processed++; } + private synchronized void incAutoClosed() { this.autoClosedCount++; } + private synchronized void incRecrawled() { this.recrawledCount++; } + private synchronized void incFailed() { this.failedCount++; } + + private synchronized ClosedCandidateAutoProcessJobStatusResponse toResponse() { + return new ClosedCandidateAutoProcessJobStatusResponse( + jobId, status, total, processed, autoClosedCount, recrawledCount, failedCount, done + ); + } + } +} diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/CuisineMappingService.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/CuisineMappingService.java new file mode 100644 index 00000000..3ac013b2 --- /dev/null +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/CuisineMappingService.java @@ -0,0 +1,70 @@ +package com.kustaurant.kustaurant.admin.RestaurantSync.service; + +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +// raw 카테고리를 고정 음식종류로 매핑하고 수동 지정값을 검증하는 서비스. +public class CuisineMappingService { + + private static final List FIXED_CUISINES = List.of( + "한식", "일식", "중식", "양식", "아시안", "고기", "치킨", + "해산물", "패스트푸드/분식", "분식", "주점", "카페/디저트", "베이커리", "샐러드" + ); + + public String resolveCuisineForNew(String rawCategory, String manualCuisine) { + String normalizedManual = normalizeToNull(manualCuisine); + if (normalizedManual != null && !FIXED_CUISINES.contains(normalizedManual)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid manualCuisine: " + manualCuisine); + } + + String autoMapped = mapRawCategoryToFixedCuisine(rawCategory); + if (autoMapped != null) { + return autoMapped; + } + if (normalizedManual != null) { + return normalizedManual; + } + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "자동 매핑 실패: manualCuisine(고정 음식종류) 값을 지정해 주세요." + ); + } + + public String mapRawCategoryToFixedCuisine(String rawCategory) { + String normalized = normalizeToNull(rawCategory); + if (normalized == null) return null; + if (FIXED_CUISINES.contains(normalized)) return normalized; + + if (containsAny(normalized, "찜닭", "백반", "한정식", "국밥", "냉면", "순대국", "된장국")) return "한식"; + if (containsAny(normalized, "스시", "초밥", "우동", "라멘", "돈카츠", "이자카야", "덮밥", "돈까스")) return "일식"; + if (containsAny(normalized, "중식", "중국집", "짜장", "짬뽕", "탕수육", "마라", "딤섬")) return "중식"; + if (containsAny(normalized, "파스타", "스테이크", "리조또", "브런치", "이탈리안", "프렌치")) return "양식"; + if (containsAny(normalized, "아시안", "쌀국수", "태국", "베트남", "인도", "동남아")) return "아시안"; + if (containsAny(normalized, "고깃집", "삼겹", "갈비", "곱창", "대창", "막창", "육회", "족발", "보쌈", "양꼬치", "조개구이", "소고기")) return "고기"; + if (containsAny(normalized, "치킨", "통닭", "닭발", "프라이드", "양념치킨", "후라이드")) return "치킨"; + if (containsAny(normalized, "해산물", "횟집", "생선", "대게", "조개", "낙지", "파스타", "주꾸미")) return "해산물"; + if (containsAny(normalized, "패스트푸드", "버거", "피자", "핫도그")) return "패스트푸드/분식"; + if (containsAny(normalized, "분식", "떡볶이", "김밥", "쫄면", "순대", "튀김")) return "분식"; + if (containsAny(normalized, "주점", "술집", "사케", "호프", "바", "와인바", "이자카야", "포차")) return "주점"; + if (containsAny(normalized, "카페", "커피", "디저트", "빙수", "도넛", "젤라또", "마카롱")) return "카페/디저트"; + if (containsAny(normalized, "베이커리", "빵집", "제과", "제빵", "브런치")) return "베이커리"; + if (containsAny(normalized, "샐러드", "샤브", "그레인", "비건")) return "샐러드"; + return null; + } + + private boolean containsAny(String value, String... keywords) { + for (String keyword : keywords) { + if (value.contains(keyword)) return true; + } + return false; + } + + private String normalizeToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncApplyService.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncApplyService.java new file mode 100644 index 00000000..a9a59195 --- /dev/null +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncApplyService.java @@ -0,0 +1,196 @@ +package com.kustaurant.kustaurant.admin.RestaurantSync.service; + +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantCrawlRawEntity; +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantCrawlRawRepository; +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantMenuCrawlRawEntity; +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantMenuRawRepository; +import com.kustaurant.kustaurant.admin.RestaurantSync.infrastructure.RestaurantMenuJpaRepository; +import com.kustaurant.kustaurant.restaurant.restaurant.infrastructure.repository.RestaurantJpaRepository; +import com.kustaurant.restaurant.entity.RestaurantEntity; +import com.kustaurant.restaurant.entity.RestaurantMenuEntity; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +@RequiredArgsConstructor +// raw 데이터를 운영 식당 데이터에 반영(신규/폐점/업데이트)하는 적용 전용 서비스. +public class RestaurantSyncApplyService { + + private static final String SUCCESS = "SUCCESS"; + private static final String ACTIVE = "ACTIVE"; + private static final String UNKNOWN_CATEGORY = "기타"; + + private final RestaurantCrawlRawRepository rawRepository; + private final RestaurantMenuRawRepository menuRawRepository; + private final RestaurantJpaRepository restaurantRepository; + private final RestaurantMenuJpaRepository restaurantMenuRepository; + private final CuisineMappingService cuisineMappingService; + + @Transactional + public void applyNewRestaurant(String placeId, String manualCuisine) { + RestaurantCrawlRawEntity raw = rawRepository.findBySourcePlaceId(placeId) + .filter(entity -> SUCCESS.equals(entity.getCrawlStatus())) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "raw not found for placeId=" + placeId)); + + List rawMenus = menuRawRepository.findAllByRestaurantRawIdIn(List.of(raw.getId())); + String contentHash = computeContentHash(raw); + String menuHash = computeMenuHash(rawMenus); + String rawType = normalize(raw.getCategory(), UNKNOWN_CATEGORY); + String mappedCuisine = cuisineMappingService.resolveCuisineForNew(raw.getCategory(), manualCuisine); + + RestaurantEntity restaurant = restaurantRepository.findByPlaceId(placeId) + .orElseGet(() -> restaurantRepository.save(new RestaurantEntity( + null, + normalize(raw.getPlaceName(), "UNKNOWN_PLACE"), + rawType, + raw.getCrawlScope() == null ? null : raw.getCrawlScope().getDescription(), + raw.getRestaurantAddress(), + raw.getPhoneNumber(), + raw.getSourcePlaceId(), + raw.getImageUrl(), + 0, + mappedCuisine, + raw.getLatitude(), + raw.getLongitude(), + null, + ACTIVE, + contentHash, + menuHash + ))); + + restaurant.applyRaw( + normalize(raw.getPlaceName(), "UNKNOWN_PLACE"), + rawType, + raw.getCrawlScope() == null ? null : raw.getCrawlScope().getDescription(), + raw.getRestaurantAddress(), + raw.getPhoneNumber(), + raw.getImageUrl(), + mappedCuisine, + raw.getLatitude(), + raw.getLongitude() + ); + restaurant.updateHashes(contentHash, menuHash); + restaurant.markActive(); + replaceRestaurantMenus(restaurant.getRestaurantId(), rawMenus); + } + + @Transactional + public void applyClosedRestaurant(String placeId) { + RestaurantEntity restaurant = restaurantRepository.findByPlaceId(placeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "restaurant not found for placeId=" + placeId)); + restaurant.markInactive(); + restaurantRepository.save(restaurant); + } + + @Transactional + public List updateIntersectedRestaurants( + Map rawByPlaceId, + Map restaurantByPlaceId, + Map> rawMenusByRawId + ) { + List updatedPlaceIds = new ArrayList<>(); + for (Map.Entry entry : rawByPlaceId.entrySet()) { + String placeId = entry.getKey(); + RestaurantEntity restaurant = restaurantByPlaceId.get(placeId); + if (restaurant == null) continue; + + RestaurantCrawlRawEntity raw = entry.getValue(); + List rawMenus = rawMenusByRawId.getOrDefault(raw.getId(), List.of()); + String contentHash = computeContentHash(raw); + String menuHash = computeMenuHash(rawMenus); + boolean changed = !Objects.equals(restaurant.getContentHash(), contentHash) + || !Objects.equals(restaurant.getMenuHash(), menuHash) + || !ACTIVE.equals(restaurant.getStatus()); + if (!changed) continue; + + restaurant.applyRaw( + normalize(raw.getPlaceName(), "UNKNOWN_PLACE"), + normalize(raw.getCategory(), UNKNOWN_CATEGORY), + raw.getCrawlScope() == null ? null : raw.getCrawlScope().getDescription(), + raw.getRestaurantAddress(), + raw.getPhoneNumber(), + raw.getImageUrl(), + normalize(raw.getCategory(), UNKNOWN_CATEGORY), + raw.getLatitude(), + raw.getLongitude() + ); + restaurant.updateHashes(contentHash, menuHash); + restaurant.markActive(); + replaceRestaurantMenus(restaurant.getRestaurantId(), rawMenus); + updatedPlaceIds.add(placeId); + } + return updatedPlaceIds; + } + + private void replaceRestaurantMenus(Long restaurantId, Collection rawMenus) { + restaurantMenuRepository.deleteByRestaurantId(restaurantId); + if (rawMenus == null || rawMenus.isEmpty()) return; + + List menus = rawMenus.stream() + .map(menu -> RestaurantMenuEntity.of( + restaurantId, + normalize(menu.getMenuName(), "UNKNOWN_MENU"), + menu.getMenuPrice(), + menu.getMenuImageUrl() + )) + .toList(); + restaurantMenuRepository.saveAll(menus); + } + + private String computeContentHash(RestaurantCrawlRawEntity raw) { + String serialized = String.join("|", + normalize(raw.getSourcePlaceId(), ""), + normalize(raw.getPlaceName(), ""), + normalize(raw.getCategory(), ""), + normalize(raw.getRestaurantAddress(), ""), + normalize(raw.getPhoneNumber(), ""), + normalize(raw.getImageUrl(), ""), + String.valueOf(raw.getLatitude()), + String.valueOf(raw.getLongitude()), + raw.getCrawlScope() == null ? "" : raw.getCrawlScope().name() + ); + return sha256(serialized); + } + + private String computeMenuHash(List menus) { + if (menus == null || menus.isEmpty()) return sha256(""); + List lines = menus.stream() + .sorted(Comparator.comparing(menu -> normalize(menu.getMenuName(), ""))) + .map(menu -> String.join("|", + normalize(menu.getMenuName(), ""), + normalize(menu.getMenuPrice(), ""), + normalize(menu.getMenuImageUrl(), "") + )) + .toList(); + return sha256(String.join("\n", lines)); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private String normalize(String value, String fallback) { + if (value == null) return fallback; + String trimmed = value.trim(); + return trimmed.isEmpty() ? fallback : trimmed; + } +} diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncCandidateService.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncCandidateService.java new file mode 100644 index 00000000..a83a738a --- /dev/null +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncCandidateService.java @@ -0,0 +1,222 @@ +package com.kustaurant.kustaurant.admin.RestaurantSync.service; + +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantCrawlRawEntity; +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantCrawlRawRepository; +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantMenuCrawlRawEntity; +import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantMenuRawRepository; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.NewCandidateAutoApproveResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncCandidateActionResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncCandidateResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncRunResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncUpdatedItemResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.domain.SyncCandidateStatus; +import com.kustaurant.kustaurant.admin.RestaurantSync.domain.SyncCandidateType; +import com.kustaurant.kustaurant.admin.RestaurantSync.infrastructure.RestaurantSyncCandidateEntity; +import com.kustaurant.kustaurant.admin.RestaurantSync.infrastructure.RestaurantSyncCandidateRepository; +import com.kustaurant.kustaurant.restaurant.restaurant.infrastructure.repository.RestaurantJpaRepository; +import com.kustaurant.map.ZoneType; +import com.kustaurant.restaurant.entity.RestaurantEntity; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Slf4j +@Service +@RequiredArgsConstructor +// 후보 생성/조회/승인/반려 및 신규 후보 일괄 승인 흐름을 담당하는 서비스. +public class RestaurantSyncCandidateService { + + private static final String SUCCESS = "SUCCESS"; + private static final String ACTIVE = "ACTIVE"; + + private final RestaurantCrawlRawRepository rawRepository; + private final RestaurantMenuRawRepository menuRawRepository; + private final RestaurantJpaRepository restaurantRepository; + private final RestaurantSyncCandidateRepository candidateRepository; + private final RestaurantSyncApplyService applyService; + private final CuisineMappingService cuisineMappingService; + + @Transactional + public RestaurantSyncRunResponse generateCandidatesAndSync(ZoneType crawlScope) { + List raws = loadRaws(crawlScope); + Map> rawMenusByRawId = loadRawMenus(raws); + Map rawByPlaceId = raws.stream() + .collect(Collectors.toMap(RestaurantCrawlRawEntity::getSourcePlaceId, Function.identity(), (a, b) -> a, LinkedHashMap::new)); + + List restaurants = loadRestaurants(crawlScope); + Map restaurantByPlaceId = restaurants.stream() + .collect(Collectors.toMap(RestaurantEntity::getPlaceId, Function.identity(), (a, b) -> a, LinkedHashMap::new)); + + List newCandidatePlaceIds = createNewCandidates(rawByPlaceId.keySet(), restaurantByPlaceId.keySet()); + List closedCandidatePlaceIds = createClosedCandidates(rawByPlaceId.keySet(), restaurantByPlaceId.keySet()); + List updatedPlaceIds = applyService.updateIntersectedRestaurants(rawByPlaceId, restaurantByPlaceId, rawMenusByRawId); + List updatedRestaurants = updatedPlaceIds.stream() + .map(placeId -> { + RestaurantEntity restaurant = restaurantByPlaceId.get(placeId); + if (restaurant == null) { + return new RestaurantSyncUpdatedItemResponse(placeId, placeId, "https://map.naver.com/p/entry/place/" + placeId); + } + return new RestaurantSyncUpdatedItemResponse(placeId, restaurant.getRestaurantName(), "/restaurants/" + restaurant.getRestaurantId()); + }).toList(); + + return new RestaurantSyncRunResponse( + rawByPlaceId.size(), + restaurantByPlaceId.size(), + newCandidatePlaceIds.size(), + closedCandidatePlaceIds.size(), + updatedPlaceIds.size(), + List.copyOf(newCandidatePlaceIds), + List.copyOf(closedCandidatePlaceIds), + List.copyOf(updatedPlaceIds), + updatedRestaurants + ); + } + + @Transactional(readOnly = true) + public List getCandidates(SyncCandidateStatus status) { + List candidates = candidateRepository.findAllByCandidateStatusOrderByCreatedAtDesc(status); + Set placeIds = candidates.stream().map(RestaurantSyncCandidateEntity::getPlaceId).collect(Collectors.toSet()); + if (placeIds.isEmpty()) return List.of(); + + Map restaurantsByPlaceId = restaurantRepository.findAllByPlaceIdIn(placeIds) + .stream().collect(Collectors.toMap(RestaurantEntity::getPlaceId, Function.identity(), (a, b) -> a)); + Map rawsByPlaceId = rawRepository.findAllBySourcePlaceIdIn(placeIds) + .stream().collect(Collectors.toMap(RestaurantCrawlRawEntity::getSourcePlaceId, Function.identity(), (a, b) -> a)); + + return candidates.stream() + .map(candidate -> toResponse(candidate, restaurantsByPlaceId.get(candidate.getPlaceId()), rawsByPlaceId.get(candidate.getPlaceId()))) + .toList(); + } + + @Transactional + public RestaurantSyncCandidateActionResponse approve(Long candidateId, String reviewedBy, String manualCuisine) { + RestaurantSyncCandidateEntity candidate = candidateRepository + .findByIdAndCandidateStatus(candidateId, SyncCandidateStatus.PENDING) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "pending candidate not found: " + candidateId)); + + if (candidate.getCandidateType() == SyncCandidateType.NEW) { + applyService.applyNewRestaurant(candidate.getPlaceId(), manualCuisine); + } else { + applyService.applyClosedRestaurant(candidate.getPlaceId()); + } + candidateRepository.delete(candidate); + return new RestaurantSyncCandidateActionResponse(candidateId, SyncCandidateStatus.APPROVED.name()); + } + + @Transactional + public RestaurantSyncCandidateActionResponse reject(Long candidateId, String reviewedBy) { + RestaurantSyncCandidateEntity candidate = candidateRepository + .findByIdAndCandidateStatus(candidateId, SyncCandidateStatus.PENDING) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "pending candidate not found: " + candidateId)); + candidateRepository.delete(candidate); + return new RestaurantSyncCandidateActionResponse(candidateId, SyncCandidateStatus.REJECTED.name()); + } + + @Transactional + public NewCandidateAutoApproveResponse autoApproveNewCandidates(String reviewedBy) { + List newCandidates = candidateRepository.findAllByCandidateStatusOrderByCreatedAtDesc(SyncCandidateStatus.PENDING) + .stream().filter(candidate -> candidate.getCandidateType() == SyncCandidateType.NEW).toList(); + int approvedCount = 0; + int failedCount = 0; + for (RestaurantSyncCandidateEntity candidate : newCandidates) { + try { + applyService.applyNewRestaurant(candidate.getPlaceId(), null); + candidateRepository.delete(candidate); + approvedCount++; + } catch (Exception e) { + failedCount++; + log.info("신규 후보 자동 승인 실패. candidateId={}, placeId={}, reason={}", + candidate.getId(), candidate.getPlaceId(), e.getMessage()); + } + } + return new NewCandidateAutoApproveResponse(newCandidates.size(), approvedCount, failedCount); + } + + private List loadRaws(ZoneType crawlScope) { + if (crawlScope == null) return rawRepository.findAllByCrawlStatus(SUCCESS); + return rawRepository.findAllByCrawlStatusAndCrawlScope(SUCCESS, crawlScope); + } + + private Map> loadRawMenus(List raws) { + if (raws.isEmpty()) return Map.of(); + List rawIds = raws.stream().map(RestaurantCrawlRawEntity::getId).toList(); + return menuRawRepository.findAllByRestaurantRawIdIn(rawIds).stream() + .collect(Collectors.groupingBy(RestaurantMenuCrawlRawEntity::getRestaurantRawId)); + } + + private List loadRestaurants(ZoneType crawlScope) { + if (crawlScope == null) return restaurantRepository.findAllByStatus(ACTIVE); + return restaurantRepository.findAllByStatusAndRestaurantPosition(ACTIVE, crawlScope.getDescription()); + } + + private List createNewCandidates(Set rawPlaceIds, Set restaurantPlaceIds) { + List created = new ArrayList<>(); + for (String placeId : rawPlaceIds) { + if (restaurantPlaceIds.contains(placeId)) continue; + if (createPendingCandidateIfAbsent(placeId, SyncCandidateType.NEW, null)) created.add(placeId); + } + return created; + } + + private List createClosedCandidates(Set rawPlaceIds, Set restaurantPlaceIds) { + List created = new ArrayList<>(); + for (String placeId : restaurantPlaceIds) { + if (rawPlaceIds.contains(placeId)) continue; + if (createPendingCandidateIfAbsent(placeId, SyncCandidateType.CLOSED, null)) created.add(placeId); + } + return created; + } + + private boolean createPendingCandidateIfAbsent(String placeId, SyncCandidateType type, String reason) { + boolean exists = candidateRepository.existsByPlaceIdAndCandidateTypeAndCandidateStatus(placeId, type, SyncCandidateStatus.PENDING); + if (exists) return false; + candidateRepository.save(RestaurantSyncCandidateEntity.pending(placeId, type, reason)); + return true; + } + + private RestaurantSyncCandidateResponse toResponse( + RestaurantSyncCandidateEntity entity, + RestaurantEntity restaurant, + RestaurantCrawlRawEntity raw + ) { + String restaurantName = restaurant != null ? restaurant.getRestaurantName() : (raw != null ? raw.getPlaceName() : entity.getPlaceId()); + String restaurantType = restaurant != null ? restaurant.getRestaurantType() : (raw != null ? raw.getCategory() : null); + String mappedCuisine = cuisineMappingService.mapRawCategoryToFixedCuisine(restaurantType); + if (mappedCuisine == null && raw != null) { + mappedCuisine = cuisineMappingService.mapRawCategoryToFixedCuisine(raw.getCategory()); + } + String restaurantLink = "https://map.naver.com/p/entry/place/" + entity.getPlaceId(); + + return new RestaurantSyncCandidateResponse( + entity.getId(), + entity.getPlaceId(), + restaurantName, + normalize(restaurantType, "-"), + normalize(mappedCuisine, "-"), + restaurantLink, + entity.getCandidateType(), + entity.getCandidateStatus(), + entity.getReason(), + entity.getReviewedBy(), + entity.getReviewedAt(), + entity.getAppliedAt(), + entity.getCreatedAt() + ); + } + + private String normalize(String value, String fallback) { + if (value == null) return fallback; + String trimmed = value.trim(); + return trimmed.isEmpty() ? fallback : trimmed; + } +} diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncService.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncService.java index ee044e8c..72209b5b 100644 --- a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncService.java +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/admin/RestaurantSync/service/RestaurantSyncService.java @@ -1,468 +1,55 @@ package com.kustaurant.kustaurant.admin.RestaurantSync.service; -import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantCrawlRawEntity; -import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantCrawlRawRepository; -import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantMenuCrawlRawEntity; -import com.kustaurant.kustaurant.admin.RestaurantCrawl.infrastructure.RestaurantMenuRawRepository; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessJobStartResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessJobStatusResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.ClosedCandidateAutoProcessResponse; +import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.NewCandidateAutoApproveResponse; import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncCandidateActionResponse; import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncCandidateResponse; import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncRunResponse; -import com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto.RestaurantSyncUpdatedItemResponse; import com.kustaurant.kustaurant.admin.RestaurantSync.domain.SyncCandidateStatus; -import com.kustaurant.kustaurant.admin.RestaurantSync.domain.SyncCandidateType; -import com.kustaurant.kustaurant.admin.RestaurantSync.infrastructure.RestaurantMenuJpaRepository; -import com.kustaurant.kustaurant.admin.RestaurantSync.infrastructure.RestaurantSyncCandidateEntity; -import com.kustaurant.kustaurant.admin.RestaurantSync.infrastructure.RestaurantSyncCandidateRepository; -import com.kustaurant.kustaurant.restaurant.restaurant.infrastructure.repository.RestaurantJpaRepository; import com.kustaurant.map.ZoneType; -import com.kustaurant.restaurant.entity.RestaurantEntity; -import com.kustaurant.restaurant.entity.RestaurantMenuEntity; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Comparator; -import java.util.HexFormat; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; @Service @RequiredArgsConstructor +// RestaurantSync 도메인 유스케이스를 하위 서비스에 위임하는 파사드 서비스. public class RestaurantSyncService { - private static final String SUCCESS = "SUCCESS"; - private static final String ACTIVE = "ACTIVE"; - private static final String UNKNOWN_CATEGORY = "기타"; - private static final List FIXED_CUISINES = List.of( - "한식", "일식", "중식", "양식", "아시안", "고기", "치킨", - "해산물", "햄버거/피자", "분식", "술집", "카페/디저트", "베이커리", "샐러드" - ); + private final RestaurantSyncCandidateService candidateService; + private final ClosedCandidateAutoProcessService closedCandidateAutoProcessService; - private final RestaurantCrawlRawRepository rawRepository; - private final RestaurantMenuRawRepository menuRawRepository; - private final RestaurantJpaRepository restaurantRepository; - private final RestaurantMenuJpaRepository restaurantMenuRepository; - private final RestaurantSyncCandidateRepository candidateRepository; - - @Transactional public RestaurantSyncRunResponse generateCandidatesAndSync(ZoneType crawlScope) { - List raws = loadRaws(crawlScope); - Map> rawMenusByRawId = loadRawMenus(raws); - Map rawByPlaceId = raws.stream() - .collect(Collectors.toMap(RestaurantCrawlRawEntity::getSourcePlaceId, Function.identity(), (a, b) -> a, LinkedHashMap::new)); - - List restaurants = loadRestaurants(crawlScope); - Map restaurantByPlaceId = restaurants.stream() - .collect(Collectors.toMap(RestaurantEntity::getPlaceId, Function.identity(), (a, b) -> a, LinkedHashMap::new)); - - List newCandidatePlaceIds = createNewCandidates(rawByPlaceId.keySet(), restaurantByPlaceId.keySet()); - List closedCandidatePlaceIds = createClosedCandidates(rawByPlaceId.keySet(), restaurantByPlaceId.keySet()); - List updatedPlaceIds = updateIntersectedRestaurants(rawByPlaceId, restaurantByPlaceId, rawMenusByRawId); - List updatedRestaurants = updatedPlaceIds.stream() - .map(placeId -> { - RestaurantEntity restaurant = restaurantByPlaceId.get(placeId); - if (restaurant == null) { - return new RestaurantSyncUpdatedItemResponse( - placeId, - placeId, - "https://map.naver.com/p/entry/place/" + placeId - ); - } - return new RestaurantSyncUpdatedItemResponse( - placeId, - restaurant.getRestaurantName(), - "/restaurants/" + restaurant.getRestaurantId() - ); - }) - .toList(); - - return new RestaurantSyncRunResponse( - rawByPlaceId.size(), - restaurantByPlaceId.size(), - newCandidatePlaceIds.size(), - closedCandidatePlaceIds.size(), - updatedPlaceIds.size(), - List.copyOf(newCandidatePlaceIds), - List.copyOf(closedCandidatePlaceIds), - List.copyOf(updatedPlaceIds), - updatedRestaurants - ); + return candidateService.generateCandidatesAndSync(crawlScope); } - @Transactional(readOnly = true) public List getCandidates(SyncCandidateStatus status) { - List candidates = candidateRepository.findAllByCandidateStatusOrderByCreatedAtDesc(status); - Set placeIds = candidates.stream() - .map(RestaurantSyncCandidateEntity::getPlaceId) - .collect(Collectors.toSet()); - if (placeIds.isEmpty()) { - return List.of(); - } - - Map restaurantsByPlaceId = restaurantRepository.findAllByPlaceIdIn(placeIds) - .stream() - .collect(Collectors.toMap(RestaurantEntity::getPlaceId, Function.identity(), (a, b) -> a)); - Map rawsByPlaceId = rawRepository.findAllBySourcePlaceIdIn(placeIds) - .stream() - .collect(Collectors.toMap(RestaurantCrawlRawEntity::getSourcePlaceId, Function.identity(), (a, b) -> a)); - - return candidates.stream() - .map(candidate -> toResponse(candidate, restaurantsByPlaceId.get(candidate.getPlaceId()), rawsByPlaceId.get(candidate.getPlaceId()))) - .toList(); + return candidateService.getCandidates(status); } - @Transactional public RestaurantSyncCandidateActionResponse approve(Long candidateId, String reviewedBy, String manualCuisine) { - RestaurantSyncCandidateEntity candidate = candidateRepository - .findByIdAndCandidateStatus(candidateId, SyncCandidateStatus.PENDING) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "pending candidate not found: " + candidateId)); - - if (candidate.getCandidateType() == SyncCandidateType.NEW) { - applyNewRestaurant(candidate.getPlaceId(), manualCuisine); - } else { - applyClosedRestaurant(candidate.getPlaceId()); - } - - candidateRepository.delete(candidate); - return new RestaurantSyncCandidateActionResponse(candidateId, SyncCandidateStatus.APPROVED.name()); + return candidateService.approve(candidateId, reviewedBy, manualCuisine); } - @Transactional public RestaurantSyncCandidateActionResponse reject(Long candidateId, String reviewedBy) { - RestaurantSyncCandidateEntity candidate = candidateRepository - .findByIdAndCandidateStatus(candidateId, SyncCandidateStatus.PENDING) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "pending candidate not found: " + candidateId)); - - candidateRepository.delete(candidate); - return new RestaurantSyncCandidateActionResponse(candidateId, SyncCandidateStatus.REJECTED.name()); - } - - private List loadRaws(ZoneType crawlScope) { - if (crawlScope == null) { - return rawRepository.findAllByCrawlStatus(SUCCESS); - } - return rawRepository.findAllByCrawlStatusAndCrawlScope(SUCCESS, crawlScope); - } - - private Map> loadRawMenus(List raws) { - if (raws.isEmpty()) { - return Map.of(); - } - List rawIds = raws.stream().map(RestaurantCrawlRawEntity::getId).toList(); - return menuRawRepository.findAllByRestaurantRawIdIn(rawIds).stream() - .collect(Collectors.groupingBy(RestaurantMenuCrawlRawEntity::getRestaurantRawId)); - } - - private List loadRestaurants(ZoneType crawlScope) { - if (crawlScope == null) { - return restaurantRepository.findAllByStatus(ACTIVE); - } - return restaurantRepository.findAllByStatusAndRestaurantPosition(ACTIVE, crawlScope.getDescription()); - } - - private List createNewCandidates(Set rawPlaceIds, Set restaurantPlaceIds) { - List created = new ArrayList<>(); - for (String placeId : rawPlaceIds) { - if (restaurantPlaceIds.contains(placeId)) { - continue; - } - if (createPendingCandidateIfAbsent(placeId, SyncCandidateType.NEW, null)) { - created.add(placeId); - } - } - return created; - } - - private List createClosedCandidates(Set rawPlaceIds, Set restaurantPlaceIds) { - List created = new ArrayList<>(); - for (String placeId : restaurantPlaceIds) { - if (rawPlaceIds.contains(placeId)) { - continue; - } - if (createPendingCandidateIfAbsent(placeId, SyncCandidateType.CLOSED, null)) { - created.add(placeId); - } - } - return created; - } - - private boolean createPendingCandidateIfAbsent(String placeId, SyncCandidateType type, String reason) { - boolean exists = candidateRepository.existsByPlaceIdAndCandidateTypeAndCandidateStatus( - placeId, - type, - SyncCandidateStatus.PENDING - ); - if (exists) { - return false; - } - candidateRepository.save(RestaurantSyncCandidateEntity.pending(placeId, type, reason)); - return true; - } - - private List updateIntersectedRestaurants( - Map rawByPlaceId, - Map restaurantByPlaceId, - Map> rawMenusByRawId - ) { - List updatedPlaceIds = new ArrayList<>(); - for (Map.Entry entry : rawByPlaceId.entrySet()) { - String placeId = entry.getKey(); - RestaurantEntity restaurant = restaurantByPlaceId.get(placeId); - if (restaurant == null) { - continue; - } - - RestaurantCrawlRawEntity raw = entry.getValue(); - List rawMenus = rawMenusByRawId.getOrDefault(raw.getId(), List.of()); - String contentHash = computeContentHash(raw); - String menuHash = computeMenuHash(rawMenus); - boolean changed = !Objects.equals(restaurant.getContentHash(), contentHash) - || !Objects.equals(restaurant.getMenuHash(), menuHash) - || !ACTIVE.equals(restaurant.getStatus()); - - if (!changed) { - continue; - } - - restaurant.applyRaw( - normalize(raw.getPlaceName(), "UNKNOWN_PLACE"), - normalize(raw.getCategory(), UNKNOWN_CATEGORY), - raw.getCrawlScope() == null ? null : raw.getCrawlScope().getDescription(), - raw.getRestaurantAddress(), - raw.getPhoneNumber(), - raw.getImageUrl(), - normalize(raw.getCategory(), UNKNOWN_CATEGORY), - raw.getLatitude(), - raw.getLongitude() - ); - restaurant.updateHashes(contentHash, menuHash); - restaurant.markActive(); - replaceRestaurantMenus(restaurant.getRestaurantId(), rawMenus); - updatedPlaceIds.add(placeId); - } - return updatedPlaceIds; - } - - private void applyNewRestaurant(String placeId, String manualCuisine) { - RestaurantCrawlRawEntity raw = rawRepository.findBySourcePlaceId(placeId) - .filter(entity -> SUCCESS.equals(entity.getCrawlStatus())) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "raw not found for placeId=" + placeId)); - - List rawMenus = menuRawRepository.findAllByRestaurantRawIdIn(List.of(raw.getId())); - String contentHash = computeContentHash(raw); - String menuHash = computeMenuHash(rawMenus); - String rawType = normalize(raw.getCategory(), UNKNOWN_CATEGORY); - String mappedCuisine = resolveCuisineForNew(raw.getCategory(), manualCuisine); - - RestaurantEntity restaurant = restaurantRepository.findByPlaceId(placeId) - .orElseGet(() -> restaurantRepository.save(new RestaurantEntity( - null, - normalize(raw.getPlaceName(), "UNKNOWN_PLACE"), - rawType, - raw.getCrawlScope() == null ? null : raw.getCrawlScope().getDescription(), - raw.getRestaurantAddress(), - raw.getPhoneNumber(), - raw.getSourcePlaceId(), - raw.getImageUrl(), - 0, - mappedCuisine, - raw.getLatitude(), - raw.getLongitude(), - null, - ACTIVE, - contentHash, - menuHash - ))); - - restaurant.applyRaw( - normalize(raw.getPlaceName(), "UNKNOWN_PLACE"), - rawType, - raw.getCrawlScope() == null ? null : raw.getCrawlScope().getDescription(), - raw.getRestaurantAddress(), - raw.getPhoneNumber(), - raw.getImageUrl(), - mappedCuisine, - raw.getLatitude(), - raw.getLongitude() - ); - restaurant.updateHashes(contentHash, menuHash); - restaurant.markActive(); - replaceRestaurantMenus(restaurant.getRestaurantId(), rawMenus); - } - - private void applyClosedRestaurant(String placeId) { - RestaurantEntity restaurant = restaurantRepository.findByPlaceId(placeId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "restaurant not found for placeId=" + placeId)); - restaurant.markInactive(); - } - - private void replaceRestaurantMenus(Long restaurantId, Collection rawMenus) { - restaurantMenuRepository.deleteByRestaurantId(restaurantId); - if (rawMenus == null || rawMenus.isEmpty()) { - return; - } - List menus = rawMenus.stream() - .map(menu -> RestaurantMenuEntity.of( - restaurantId, - normalize(menu.getMenuName(), "UNKNOWN_MENU"), - menu.getMenuPrice(), - menu.getMenuImageUrl() - )) - .toList(); - restaurantMenuRepository.saveAll(menus); - } - - private RestaurantSyncCandidateResponse toResponse( - RestaurantSyncCandidateEntity entity, - RestaurantEntity restaurant, - RestaurantCrawlRawEntity raw - ) { - String restaurantName = restaurant != null - ? restaurant.getRestaurantName() - : (raw != null ? raw.getPlaceName() : entity.getPlaceId()); - String restaurantType = restaurant != null - ? restaurant.getRestaurantType() - : (raw != null ? raw.getCategory() : null); - String mappedCuisine = mapRawCategoryToFixedCuisine(restaurantType); - if (mappedCuisine == null && raw != null) { - mappedCuisine = mapRawCategoryToFixedCuisine(raw.getCategory()); - } - String restaurantLink = "https://map.naver.com/p/entry/place/" + entity.getPlaceId(); - - return new RestaurantSyncCandidateResponse( - entity.getId(), - entity.getPlaceId(), - restaurantName, - normalize(restaurantType, "-"), - normalize(mappedCuisine, "-"), - restaurantLink, - entity.getCandidateType(), - entity.getCandidateStatus(), - entity.getReason(), - entity.getReviewedBy(), - entity.getReviewedAt(), - entity.getAppliedAt(), - entity.getCreatedAt() - ); - } - - private String resolveCuisineForNew(String rawCategory, String manualCuisine) { - String normalizedManual = normalizeToNull(manualCuisine); - if (normalizedManual != null && !FIXED_CUISINES.contains(normalizedManual)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid manualCuisine: " + manualCuisine); - } - - String autoMapped = mapRawCategoryToFixedCuisine(rawCategory); - if (autoMapped != null) { - return autoMapped; - } - if (normalizedManual != null) { - return normalizedManual; - } - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "자동 매핑 실패: manualCuisine(고정 식당종류) 값을 지정해주세요." - ); - } - - private String mapRawCategoryToFixedCuisine(String rawCategory) { - String normalized = normalizeToNull(rawCategory); - if (normalized == null) { - return null; - } - if (FIXED_CUISINES.contains(normalized)) { - return normalized; - } - if (containsAny(normalized, "찜닭", "백반", "한정식", "국밥", "냉면")) return "한식"; - if (containsAny(normalized, "스시", "초밥", "라멘", "우동", "소바", "돈카츠", "돈까스")) return "일식"; - if (containsAny(normalized, "중식", "중국집", "짜장", "짬뽕", "탕수육", "마라", "훠궈")) return "중식"; - if (containsAny(normalized, "파스타", "스테이크", "리조또", "브런치", "이탈리안", "프렌치")) return "양식"; - if (containsAny(normalized, "아시안", "태국", "타이", "베트남", "쌀국수", "인도", "동남아")) return "아시안"; - if (containsAny(normalized, "고깃집", "삼겹살", "갈비", "곱창", "대창", "막창", "육회")) return "고기"; - if (containsAny(normalized, "치킨", "닭강정", "통닭", "후라이드", "양념치킨", "닭꼬치")) return "치킨"; - if (containsAny(normalized, "해산물", "횟집", "숙성회", "해물", "조개", "대게", "킹크랩")) return "해산물"; - if (containsAny(normalized, "햄버거", "버거", "피자", "핫도그")) return "햄버거/피자"; - if (containsAny(normalized, "분식", "떡볶이", "김밥", "쫄면", "순대", "어묵")) return "분식"; - if (containsAny(normalized, "술집", "주점", "포차", "호프", "펍", "와인바", "이자카야")) return "술집"; - if (containsAny(normalized, "카페", "커피", "디저트", "빙수", "도넛", "젤라또", "마카롱")) return "카페/디저트"; - if (containsAny(normalized, "베이커리", "빵집", "제과", "제빵", "크루아상", "바게트")) return "베이커리"; - if (containsAny(normalized, "샐러드", "포케", "그레인볼", "비건볼")) return "샐러드"; - return null; - } - - private boolean containsAny(String value, String... keywords) { - for (String keyword : keywords) { - if (value.contains(keyword)) { - return true; - } - } - return false; - } - - private String normalizeToNull(String value) { - if (value == null) { - return null; - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; + return candidateService.reject(candidateId, reviewedBy); } - private String computeContentHash(RestaurantCrawlRawEntity raw) { - String serialized = String.join("|", - normalize(raw.getSourcePlaceId(), ""), - normalize(raw.getPlaceName(), ""), - normalize(raw.getCategory(), ""), - normalize(raw.getRestaurantAddress(), ""), - normalize(raw.getPhoneNumber(), ""), - normalize(raw.getImageUrl(), ""), - String.valueOf(raw.getLatitude()), - String.valueOf(raw.getLongitude()), - raw.getCrawlScope() == null ? "" : raw.getCrawlScope().name() - ); - return sha256(serialized); + public ClosedCandidateAutoProcessResponse autoProcessClosedCandidates(String reviewedBy) { + return closedCandidateAutoProcessService.autoProcessClosedCandidates(reviewedBy); } - private String computeMenuHash(List menus) { - if (menus == null || menus.isEmpty()) { - return sha256(""); - } - List lines = menus.stream() - .sorted(Comparator.comparing(menu -> normalize(menu.getMenuName(), ""))) - .map(menu -> String.join("|", - normalize(menu.getMenuName(), ""), - normalize(menu.getMenuPrice(), ""), - normalize(menu.getMenuImageUrl(), "") - )) - .toList(); - return sha256(String.join("\n", lines)); + public ClosedCandidateAutoProcessJobStartResponse startClosedAutoProcessJob(String reviewedBy) { + return closedCandidateAutoProcessService.startClosedAutoProcessJob(reviewedBy); } - private String sha256(String value) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashed = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(hashed); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 unavailable", e); - } + public ClosedCandidateAutoProcessJobStatusResponse getClosedAutoProcessJobStatus(String jobId) { + return closedCandidateAutoProcessService.getClosedAutoProcessJobStatus(jobId); } - private String normalize(String value, String fallback) { - if (value == null) { - return fallback; - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? fallback : trimmed; + public NewCandidateAutoApproveResponse autoApproveNewCandidates(String reviewedBy) { + return candidateService.autoApproveNewCandidates(reviewedBy); } } diff --git a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/restaurant/restaurant/controller/RestaurantApiController.java b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/restaurant/restaurant/controller/RestaurantApiController.java index f049c929..7d2caba4 100644 --- a/server/kustaurant/src/main/java/com/kustaurant/kustaurant/restaurant/restaurant/controller/RestaurantApiController.java +++ b/server/kustaurant/src/main/java/com/kustaurant/kustaurant/restaurant/restaurant/controller/RestaurantApiController.java @@ -23,7 +23,6 @@ @RequestMapping("/api") public class RestaurantApiController implements RestaurantApiDoc { private final RestaurantService restaurantService; - private final ViewerKeyProvider viewerKeyProvider; private final ViewCountService viewCountService; diff --git a/server/kustaurant/src/main/resources/static/js/admin/admin-crawl.js b/server/kustaurant/src/main/resources/static/js/admin/admin-crawl.js index ac575756..5a2ab1e6 100644 --- a/server/kustaurant/src/main/resources/static/js/admin/admin-crawl.js +++ b/server/kustaurant/src/main/resources/static/js/admin/admin-crawl.js @@ -1,4 +1,5 @@ let naverPlaceZoneCrawlPollTimer = null; +let activeNaverPlaceZoneCrawlJobId = null; function stopNaverPlaceZoneCrawlPolling() { if (!naverPlaceZoneCrawlPollTimer) return; @@ -47,6 +48,7 @@ function closeNaverPlaceModal() { function openNaverPlaceSyncModal() { document.getElementById("naver-place-sync-modal")?.classList.remove("hidden"); + resumeNaverPlaceZoneCrawlPollingIfNeeded(); } function closeNaverPlaceSyncModal() { @@ -218,6 +220,7 @@ function renderNaverPlaceZoneCrawlResult(data) { } function pollNaverPlaceZoneCrawlStatus(jobId, submitBtn) { + activeNaverPlaceZoneCrawlJobId = jobId; fetch(`/admin/api/crawl/naver-place/crawl-zone/jobs/${jobId}`, { method: "GET", headers: { @@ -230,6 +233,7 @@ function pollNaverPlaceZoneCrawlStatus(jobId, submitBtn) { .then(data => { renderNaverPlaceZoneCrawlResult(data); if (isZoneCrawlJobFinished(data.status)) { + activeNaverPlaceZoneCrawlJobId = null; submitBtn.disabled = false; submitBtn.textContent = "구역 크롤 시작"; stopNaverPlaceZoneCrawlPolling(); @@ -246,6 +250,17 @@ function pollNaverPlaceZoneCrawlStatus(jobId, submitBtn) { }); } +function resumeNaverPlaceZoneCrawlPollingIfNeeded() { + if (!activeNaverPlaceZoneCrawlJobId) return; + const submitBtn = document.getElementById("naver-place-sync-submit-btn"); + if (!submitBtn) return; + + submitBtn.disabled = true; + submitBtn.textContent = "吏꾪뻾 以?.."; + stopNaverPlaceZoneCrawlPolling(); + pollNaverPlaceZoneCrawlStatus(activeNaverPlaceZoneCrawlJobId, submitBtn); +} + function runNaverPlaceZoneCrawl() { const crawlScope = getSelectedSyncScope(); if (!crawlScope) { diff --git a/server/kustaurant/src/main/resources/static/js/admin/admin-sync.js b/server/kustaurant/src/main/resources/static/js/admin/admin-sync.js index ebd6f077..ab52366c 100644 --- a/server/kustaurant/src/main/resources/static/js/admin/admin-sync.js +++ b/server/kustaurant/src/main/resources/static/js/admin/admin-sync.js @@ -169,7 +169,8 @@ async function handleSyncCandidateAction(action, candidateId) { request.body = JSON.stringify({ manualCuisine: cuisine }); } else { const actionLabel = action === "approve" ? "승인" : "반려"; - if (!window.confirm(`${actionLabel} 처리할까요?`)) return; + const name = candidate?.restaurantName || candidate?.placeId || `후보ID ${candidateId}`; + if (!window.confirm(`${name} ${actionLabel} 처리할까요?`)) return; } const response = await fetch(`/admin/api/sync/candidates/${candidateId}/${action}`, request); @@ -196,6 +197,138 @@ function bindSyncCandidateTableActions() { document.getElementById("sync-closed-tbody")?.addEventListener("click", handler); } +async function autoProcessClosedCandidates() { + const button = document.getElementById("auto-process-closed-candidates-btn"); + if (!button) return; + if (!window.confirm("폐점 후보를 자동 판별할까요?")) return; + + button.disabled = true; + const previousText = button.textContent; + button.textContent = "처리 중..."; + + try { + const response = await fetch("/admin/api/sync/candidates/closed/auto-process", { + method: "POST", + headers: { + "Accept": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-XSRF-TOKEN": getCookie("XSRF-TOKEN") + } + }); + const result = await parseJsonResponse(response); + await loadPendingSyncCandidates(); + loadRestaurants(0); + alert( + `자동 처리 완료\n` + + `대상: ${result.totalPendingClosed}건\n` + + `자동 폐점: ${result.autoClosedCount}건\n` + + `재크롤 저장: ${result.recrawledCount}건\n` + + `실패: ${result.failedCount}건` + ); + } catch (error) { + console.error("auto process closed candidates failed:", error); + alert(`폐점 후보 자동 판별 실패: ${error.message}`); + } finally { + button.disabled = false; + button.textContent = previousText; + } +} + +async function autoProcessClosedCandidatesWithProgress() { + const button = document.getElementById("auto-process-closed-candidates-btn"); + if (!button) return; + if (!window.confirm("폐점 후보를 자동 판별할까요?")) return; + + button.disabled = true; + const previousText = button.textContent; + button.textContent = "(0/0) 처리 중..."; + + try { + const startResponse = await fetch("/admin/api/sync/candidates/closed/auto-process/jobs", { + method: "POST", + headers: { + "Accept": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-XSRF-TOKEN": getCookie("XSRF-TOKEN") + } + }); + const start = await parseJsonResponse(startResponse); + const jobId = start.jobId; + if (!jobId) throw new Error("jobId missing"); + + let result = null; + while (true) { + const statusResponse = await fetch(`/admin/api/sync/candidates/closed/auto-process/jobs/${jobId}`, { + method: "GET", + headers: { + "Accept": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-XSRF-TOKEN": getCookie("XSRF-TOKEN") + } + }); + const status = await parseJsonResponse(statusResponse); + button.textContent = `(${status.processed}/${status.total}) 처리 중...`; + if (status.done) { + result = status; + break; + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + await loadPendingSyncCandidates(); + loadRestaurants(0); + alert( + `자동 처리 완료\n` + + `대상: ${result.total}건\n` + + `자동 폐점: ${result.autoClosedCount}건\n` + + `재크롤 저장: ${result.recrawledCount}건\n` + + `실패: ${result.failedCount}건` + ); + } catch (error) { + console.error("auto process closed candidates failed:", error); + alert(`폐점 후보 자동 판별 실패: ${error.message}`); + } finally { + button.disabled = false; + button.textContent = previousText; + } +} + +async function autoApproveNewCandidates() { + const button = document.getElementById("auto-approve-new-candidates-btn"); + if (!button) return; + if (!window.confirm("신규 후보를 전체 자동승인할까요?")) return; + + button.disabled = true; + const previousText = button.textContent; + button.textContent = "처리 중..."; + + try { + const response = await fetch("/admin/api/sync/candidates/new/auto-approve", { + method: "POST", + headers: { + "Accept": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-XSRF-TOKEN": getCookie("XSRF-TOKEN") + } + }); + const result = await parseJsonResponse(response); + await loadPendingSyncCandidates(); + loadRestaurants(0); + alert( + `자동 승인 완료\n` + + `대상: ${result.totalPendingNew}건\n` + + `승인: ${result.approvedCount}건\n` + + `실패: ${result.failedCount}건` + ); + } catch (error) { + console.error("auto approve new candidates failed:", error); + alert(`신규 후보 전체 자동승인 실패: ${error.message}`); + } finally { + button.disabled = false; + button.textContent = previousText; + } +} + function initializeRestaurantSyncSection() { document.getElementById("open-restaurant-sync-modal-btn")?.addEventListener("click", openRestaurantSyncRunModal); document.getElementById("refresh-restaurant-sync-btn")?.addEventListener("click", () => { @@ -209,6 +342,18 @@ function initializeRestaurantSyncSection() { document.getElementById("restaurant-sync-run-modal-close")?.addEventListener("click", closeRestaurantSyncRunModal); document.querySelector("#restaurant-sync-run-modal .admin-modal-overlay") ?.addEventListener("click", closeRestaurantSyncRunModal); + document.getElementById("auto-approve-new-candidates-btn")?.addEventListener("click", () => { + autoApproveNewCandidates().catch(error => { + console.error("auto approve trigger failed:", error); + alert(`신규 후보 전체 자동승인 실패: ${error.message}`); + }); + }); + document.getElementById("auto-process-closed-candidates-btn")?.addEventListener("click", () => { + autoProcessClosedCandidatesWithProgress().catch(error => { + console.error("auto process trigger failed:", error); + alert(`폐점 후보 자동 판별 실패: ${error.message}`); + }); + }); bindSyncCandidateTableActions(); renderUpdatedPlaces([]); diff --git a/server/kustaurant/src/main/resources/static/js/restaurant/restaurantMenuScript.js b/server/kustaurant/src/main/resources/static/js/restaurant/restaurantMenuScript.js index d202c588..a75ccf0f 100644 --- a/server/kustaurant/src/main/resources/static/js/restaurant/restaurantMenuScript.js +++ b/server/kustaurant/src/main/resources/static/js/restaurant/restaurantMenuScript.js @@ -30,15 +30,17 @@ function fillMenuInfo(data, num) { textDiv.appendChild(menuPriceContainer); const menuImgUrl = (item.menuImgUrl || "").trim(); - if (menuImgUrl) { - const imgDiv = document.createElement("div"); - imgDiv.classList.add("menu-img-container"); - const img = document.createElement("img"); - img.alt = "menu img"; - img.src = menuImgUrl === "icon" ? "/img/favicon.png" : menuImgUrl; - imgDiv.appendChild(img); - menuLi.appendChild(imgDiv); + const imgDiv = document.createElement("div"); + imgDiv.classList.add("menu-img-container"); + const img = document.createElement("img"); + img.alt = "menu img"; + if (menuImgUrl === "icon" || menuImgUrl === "no_img" || !menuImgUrl) { + img.src = "/img/tier/no_img.png"; + } else { + img.src = menuImgUrl; } + imgDiv.appendChild(img); + menuLi.appendChild(imgDiv); menuLi.appendChild(textDiv); menuUl.appendChild(menuLi); diff --git a/server/kustaurant/src/main/resources/templates/admin/admin.html b/server/kustaurant/src/main/resources/templates/admin/admin.html index e1488143..30c07574 100644 --- a/server/kustaurant/src/main/resources/templates/admin/admin.html +++ b/server/kustaurant/src/main/resources/templates/admin/admin.html @@ -190,6 +190,11 @@

업데이트된 식당

신규 후보

+
+
+ +
+
@@ -200,6 +205,11 @@

신규 후보

폐점 후보

+
+
+ +
+
후보IDplaceId식당명식당종류생성시각작업
diff --git a/server/kustaurant/src/main/resources/templates/restaurant/restaurant.html b/server/kustaurant/src/main/resources/templates/restaurant/restaurant.html index e7109a77..61685cfd 100644 --- a/server/kustaurant/src/main/resources/templates/restaurant/restaurant.html +++ b/server/kustaurant/src/main/resources/templates/restaurant/restaurant.html @@ -150,10 +150,13 @@
  • - menu img - menu img + menu img
  • 후보IDplaceId식당명식당종류생성시각작업