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
14 changes: 0 additions & 14 deletions .coderabbit.yaml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ public ResponseEntity<ApiResponse<Object>> 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())));
}
}
9 changes: 8 additions & 1 deletion src/main/java/com/kkinikong/be/store/domain/Store.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,6 +16,8 @@
public interface StoreRepository extends JpaRepository<Store, Long>, StoreRepositoryCustom {
Optional<Store> findStoreById(Long id);

List<Store> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import com.kkinikong.be.store.domain.Store;

public interface StoreJdbcRepository {
void saveAllByJdbcTemplate(List<Store> stores);
void upsertStores(List<Store> stores);

List<String> findExistingStoreKeys(List<String> keys);
void deleteMissingStores(String region);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,15 +19,21 @@ public class StoreJdbcRepositoryImpl implements StoreJdbcRepository {

private static final int BATCH_SIZE = 1000;

/// 새로운 가맹점 리스트를 Batch Insert
@Override
@Transactional
public void saveAllByJdbcTemplate(List<Store> stores) {
public void upsertStores(List<Store> 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,
Expand All @@ -42,30 +46,24 @@ public void saveAllByJdbcTemplate(List<Store> 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<String> findExistingStoreKeys(List<String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,42 +25,31 @@
import com.kkinikong.be.store.exception.errorcode.StoreErrorCode;
import com.kkinikong.be.store.repository.storeupload.StoreJdbcRepository;

@Slf4j
@Service
@RequiredArgsConstructor
public class StoreUploadService {
private final StoreJdbcRepository storeJdbcRepository;

@Transactional
public StoreUploadResponse upload(MultipartFile file) {
// CSV 파일을 파싱해서 Store 리스트로 변환
List<Store> stores = parseCsv(file);

// name|address 조합 key 생성
List<String> storeKeys =
stores.stream()
.map(store -> generateStoreKey(store.getName(), store.getAddress()))
.collect(Collectors.toList());
// CSV 파일을 파싱해서 Store 리스트로 변환
List<Store> newStores = parseCsv(file);

// 이미 존재하는 가맹점 key 조회
List<String> existingKeys = storeJdbcRepository.findExistingStoreKeys(storeKeys);
// 파일의 첫 번째 데이터에서 지역 정보 추출
String targetRegion = newStores.get(0).getRegion();

// 중복 제외하고 새로운 Store만 추출
List<Store> 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<Store> parseCsv(MultipartFile file) {
List<Store> stores = new ArrayList<>();
try (BufferedReader reader =
Expand All @@ -81,7 +70,7 @@ private List<Store> parseCsv(MultipartFile file) {
return stores;
}

/// CSV 한 줄을 Store 객체로 변환
// CSV 한 줄을 Store 객체로 변환
private Store toStore(CSVRecord record) {
return Store.builder()
.name(record.get(0).trim())
Expand All @@ -94,17 +83,12 @@ private Store toStore(CSVRecord record) {
.build();
}

/// 주소에서 "시 구" 부분만 추출
// 주소에서 "시 구" 부분만 추출
private String extractRegion(String address) {
String[] parts = address.split(" ");
if (parts.length < 2) {
throw new StoreException(StoreErrorCode.INVALID_ADDRESS_FORMAT);
}
return parts[0] + " " + parts[1];
}

/// name + address 조합으로 store 고유 key 생성
private String generateStoreKey(String name, String address) {
return name.trim() + "|" + address.trim();
}
}