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
16 changes: 8 additions & 8 deletions common/jpa/src/main/java/com/kustaurant/map/MapConstantsV2.java
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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());
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion server/kustaurant/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ idea {
}
}

version = '2.6.1 - SNAPSHOT'
version = '2.6.2'
description = 'kustaurant'

jar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto;

public record ClosedCandidateAutoProcessJobStartResponse(
String jobId
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto;

public record ClosedCandidateAutoProcessResponse(
int totalPendingClosed,
int autoClosedCount,
int recrawledCount,
int failedCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.kustaurant.kustaurant.admin.RestaurantSync.controller.dto;

public record NewCandidateAutoApproveResponse(
int totalPendingNew,
int approvedCount,
int failedCount
) {
}
Original file line number Diff line number Diff line change
@@ -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<String, ClosedAutoProcessJobState> jobs = new ConcurrentHashMap<>();

@Transactional
public ClosedCandidateAutoProcessResponse autoProcessClosedCandidates(String reviewedBy) {
List<RestaurantSyncCandidateEntity> 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<RestaurantSyncCandidateEntity> 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<RestaurantSyncCandidateEntity> 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
);
}
}
}
Loading
Loading