diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index 2e8edd1..0000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,14 +0,0 @@ -language: "ko-KR" # 한국말로 설정 -early_access: false -reviews: - profile: "chill" # 리뷰 너무 빡세게는 안한다는 뜻 - request_changes_workflow: true # 코드래빗이 리뷰 끝나면 알아서 PR 승인 - high_level_summary: true - poem: true - review_status: true - collapse_walkthrough: false - auto_review: - enabled: true - drafts: false -chat: - auto_reply: true \ No newline at end of file diff --git a/src/main/java/com/kkinikong/be/store/controller/StoreUploadController.java b/src/main/java/com/kkinikong/be/store/controller/StoreUploadController.java index bfe01c2..d9af696 100644 --- a/src/main/java/com/kkinikong/be/store/controller/StoreUploadController.java +++ b/src/main/java/com/kkinikong/be/store/controller/StoreUploadController.java @@ -31,8 +31,6 @@ public ResponseEntity> uploadStoreCsv( @RequestPart("file") MultipartFile file) { StoreUploadResponse response = storeUploadService.upload(file); return ResponseEntity.ok( - ApiResponse.from( - String.format( - "CSV 파일 업로드 완료: 총 %d건 중 %d건 저장됨", response.totalCount(), response.saveCount()))); + ApiResponse.from(String.format("CSV 파일 업로드 완료: 총 %d건 저장됨", response.saveCount()))); } } diff --git a/src/main/java/com/kkinikong/be/store/domain/Store.java b/src/main/java/com/kkinikong/be/store/domain/Store.java index f75ae10..f20898f 100644 --- a/src/main/java/com/kkinikong/be/store/domain/Store.java +++ b/src/main/java/com/kkinikong/be/store/domain/Store.java @@ -15,7 +15,11 @@ @Table( name = "stores", - indexes = {@Index(name = "idx_lat_lon", columnList = "latitude, longitude")}) + indexes = { + @Index(name = "idx_lat_lon", columnList = "latitude, longitude"), + @Index(name = "idx_name_address", columnList = "name, address", unique = true), + @Index(name = "idx_region_is_updated", columnList = "region, is_updated") + }) @Entity @Getter @NoArgsConstructor @@ -60,6 +64,9 @@ public class Store extends BaseEntity { @Column(name = "updated_date", nullable = false) private LocalDate updatedDate; + @Column(name = "is_updated", nullable = false) + private boolean isUpdated = false; + @Transient private Boolean isScrapped; public void setIsScrapped(Boolean isScrapped) { diff --git a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepository.java b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepository.java index a73980f..1b7280a 100644 --- a/src/main/java/com/kkinikong/be/store/repository/store/StoreRepository.java +++ b/src/main/java/com/kkinikong/be/store/repository/store/StoreRepository.java @@ -1,5 +1,6 @@ package com.kkinikong.be.store.repository.store; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -15,6 +16,8 @@ public interface StoreRepository extends JpaRepository, StoreRepositoryCustom { Optional findStoreById(Long id); + List findByRegion(String region); + @Modifying(clearAutomatically = true) @Query("UPDATE Store s SET s.viewCount = s.viewCount + :count WHERE s.id = :storeId") void incrementViews(@Param("storeId") Long storeId, @Param("count") Long count); diff --git a/src/main/java/com/kkinikong/be/store/repository/storeupload/StoreJdbcRepository.java b/src/main/java/com/kkinikong/be/store/repository/storeupload/StoreJdbcRepository.java index df5ebf3..1854118 100644 --- a/src/main/java/com/kkinikong/be/store/repository/storeupload/StoreJdbcRepository.java +++ b/src/main/java/com/kkinikong/be/store/repository/storeupload/StoreJdbcRepository.java @@ -5,7 +5,7 @@ import com.kkinikong.be.store.domain.Store; public interface StoreJdbcRepository { - void saveAllByJdbcTemplate(List stores); + void upsertStores(List stores); - List findExistingStoreKeys(List keys); + void deleteMissingStores(String region); } diff --git a/src/main/java/com/kkinikong/be/store/repository/storeupload/StoreJdbcRepositoryImpl.java b/src/main/java/com/kkinikong/be/store/repository/storeupload/StoreJdbcRepositoryImpl.java index ef4dfce..7be8689 100644 --- a/src/main/java/com/kkinikong/be/store/repository/storeupload/StoreJdbcRepositoryImpl.java +++ b/src/main/java/com/kkinikong/be/store/repository/storeupload/StoreJdbcRepositoryImpl.java @@ -1,10 +1,8 @@ package com.kkinikong.be.store.repository.storeupload; -import java.time.LocalDate; import java.util.List; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -21,15 +19,21 @@ public class StoreJdbcRepositoryImpl implements StoreJdbcRepository { private static final int BATCH_SIZE = 1000; - /// 새로운 가맹점 리스트를 Batch Insert @Override @Transactional - public void saveAllByJdbcTemplate(List stores) { + public void upsertStores(List stores) { String sql = "INSERT INTO stores " + "(name, region, category, address, latitude, longitude, " - + "rating_avg, scrap_count, review_count, view_count, updated_date, created_date, modified_date) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + "rating_avg, scrap_count, review_count, view_count, updated_date, created_date, modified_date, is_updated) " + + "VALUES (?, ?, ?, ?, ?, ?, 0.0, 0, 0, 0, ?, NOW(), NOW(), TRUE) " + + "ON DUPLICATE KEY UPDATE " + + "category = VALUES(category), " + + "latitude = VALUES(latitude), " + + "longitude = VALUES(longitude), " + + "updated_date = VALUES(updated_date), " + + "modified_date = NOW()," + + "is_updated = TRUE"; jdbcTemplate.batchUpdate( sql, @@ -42,30 +46,24 @@ public void saveAllByJdbcTemplate(List stores) { ps.setString(4, store.getAddress()); ps.setDouble(5, store.getLatitude()); ps.setDouble(6, store.getLongitude()); - ps.setDouble(7, 0.0); - ps.setLong(8, 0L); - ps.setLong(9, 0L); - ps.setLong(10, 0L); - ps.setObject(11, store.getUpdatedDate()); - ps.setObject(12, LocalDate.now()); - ps.setObject(13, LocalDate.now()); + ps.setObject(7, store.getUpdatedDate()); }); } - /// 이미 존재하는 가맹점 키를 조회 (name | address) @Override - public List findExistingStoreKeys(List keys) { - if (keys.isEmpty()) { - return List.of(); - } + @Transactional + public void deleteMissingStores(String region) { + // 이번 파일에 없었던 (여전히 FALSE인) 가맹점 삭제 + String deleteSql = "DELETE FROM stores WHERE region = :region AND is_updated = FALSE"; - String sql = - """ - SELECT CONCAT(name, '|', address) AS store_key - FROM stores - WHERE CONCAT(name, '|', address) IN (:keys) - """; - MapSqlParameterSource params = new MapSqlParameterSource("keys", keys); - return namedParameterJdbcTemplate.query(sql, params, (rs, rowNum) -> rs.getString("store_key")); + // 다음 업로드를 위해 모든 가맹점을 다시 FALSE로 리셋 + String resetSql = "UPDATE stores SET is_updated = FALSE WHERE region = :region"; + + var params = + new org.springframework.jdbc.core.namedparam.MapSqlParameterSource() + .addValue("region", region); + + namedParameterJdbcTemplate.update(deleteSql, params); + namedParameterJdbcTemplate.update(resetSql, params); } } diff --git a/src/main/java/com/kkinikong/be/store/service/StoreUploadService.java b/src/main/java/com/kkinikong/be/store/service/StoreUploadService.java index 02bce35..c318de5 100644 --- a/src/main/java/com/kkinikong/be/store/service/StoreUploadService.java +++ b/src/main/java/com/kkinikong/be/store/service/StoreUploadService.java @@ -7,13 +7,13 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; @@ -25,6 +25,7 @@ import com.kkinikong.be.store.exception.errorcode.StoreErrorCode; import com.kkinikong.be.store.repository.storeupload.StoreJdbcRepository; +@Slf4j @Service @RequiredArgsConstructor public class StoreUploadService { @@ -32,35 +33,23 @@ public class StoreUploadService { @Transactional public StoreUploadResponse upload(MultipartFile file) { - // CSV 파일을 파싱해서 Store 리스트로 변환 - List stores = parseCsv(file); - // name|address 조합 key 생성 - List storeKeys = - stores.stream() - .map(store -> generateStoreKey(store.getName(), store.getAddress())) - .collect(Collectors.toList()); + // CSV 파일을 파싱해서 Store 리스트로 변환 + List newStores = parseCsv(file); - // 이미 존재하는 가맹점 key 조회 - List existingKeys = storeJdbcRepository.findExistingStoreKeys(storeKeys); + // 파일의 첫 번째 데이터에서 지역 정보 추출 + String targetRegion = newStores.get(0).getRegion(); - // 중복 제외하고 새로운 Store만 추출 - List newStores = - stores.stream() - .filter( - store -> - !existingKeys.contains(generateStoreKey(store.getName(), store.getAddress()))) - .collect(Collectors.toList()); + // 기존 데이터는 유지하며 정보 갱신, 신규 데이터는 추가 + storeJdbcRepository.upsertStores(newStores); - // 새로운 Store만 Batch Insert - if (!newStores.isEmpty()) { - storeJdbcRepository.saveAllByJdbcTemplate(newStores); - } + // 새로운 파일에 없는 가맹점 삭제 + storeJdbcRepository.deleteMissingStores(targetRegion); - return new StoreUploadResponse(stores.size(), newStores.size()); + return new StoreUploadResponse(newStores.size(), newStores.size()); } - /// CSV 파일을 읽어서 Store 객체 리스트로 변환 + // CSV 파일을 읽어서 Store 객체 리스트로 변환 private List parseCsv(MultipartFile file) { List stores = new ArrayList<>(); try (BufferedReader reader = @@ -81,7 +70,7 @@ private List parseCsv(MultipartFile file) { return stores; } - /// CSV 한 줄을 Store 객체로 변환 + // CSV 한 줄을 Store 객체로 변환 private Store toStore(CSVRecord record) { return Store.builder() .name(record.get(0).trim()) @@ -94,7 +83,7 @@ private Store toStore(CSVRecord record) { .build(); } - /// 주소에서 "시 구" 부분만 추출 + // 주소에서 "시 구" 부분만 추출 private String extractRegion(String address) { String[] parts = address.split(" "); if (parts.length < 2) { @@ -102,9 +91,4 @@ private String extractRegion(String address) { } return parts[0] + " " + parts[1]; } - - /// name + address 조합으로 store 고유 key 생성 - private String generateStoreKey(String name, String address) { - return name.trim() + "|" + address.trim(); - } }