diff --git a/.github/workflows/action-develop-cd.yml b/.github/workflows/action-develop-cd.yml index e654d6ad..8d607c04 100644 --- a/.github/workflows/action-develop-cd.yml +++ b/.github/workflows/action-develop-cd.yml @@ -6,7 +6,7 @@ on: branches: - develop - # 코드의 내용을 이 파일을 실행하여 action을 수행하는 주체(Github Actions에서 사용하는 VM)가 읽을 수 있도록 권한을 설정 + # 코드의 내용을 이 파일을 실행하여 action을 수행하는 주체(Github Actions에서 사용하는 VM)가 읽을 수 있도록 권한을 설정 permissions: contents: read diff --git a/build.gradle b/build.gradle index d7c05c48..de8f8cff 100644 --- a/build.gradle +++ b/build.gradle @@ -56,9 +56,12 @@ dependencies { //spring doc 추가 dependencies { - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' } + //Validation을 위해 추가! Spring Boot 2.3버전 이후부터는 web 의존성안에 있던 validation 관련 package가 아예 모듈로 빠짐 + implementation 'org.springframework.boot:spring-boot-starter-validation' + } test { diff --git a/src/main/java/com/nainga/nainga/domain/report/api/ReportApi.java b/src/main/java/com/nainga/nainga/domain/report/api/ReportApi.java new file mode 100644 index 00000000..7e5c4c3b --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/api/ReportApi.java @@ -0,0 +1,75 @@ +package com.nainga.nainga.domain.report.api; + +import com.nainga.nainga.domain.report.application.ReportService; +import com.nainga.nainga.domain.report.domain.Report; +import com.nainga.nainga.domain.report.dto.SaveNewStoreReportRequest; +import com.nainga.nainga.domain.report.dto.SaveSpecificStoreReportRequest; +import com.nainga.nainga.global.util.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class ReportApi { + private final ReportService reportService; + + //사용자의 신규 가게 등록 요청에 대한 제보를 저장 + @Tag(name = "[New] 사용자 제보") + @Operation(summary = "사용자의 신규 가게 등록 요청에 대한 제보를 서버에 저장", description = "사용자의 신규 가게 등록 요청에 대한 제보를 서버에 저장합니다.

" + + "[Request Body]
" + + "storeName: 등록 요청하는 가게 이름
" + + "formattedAddress: 등록 요청하는 가게 주소
" + + "certifications: 가게가 가지고 있는 인증제들의 이름을 담은 리스트. 착한가격업소, 모범음식점, 안심식당이 아닌 경우 예외 발생
" + + "[Response Body]
" + + "등록된 reportId
") + @PostMapping("api/report/newStore/v1") + public Result saveNewStoreReport(@Valid @RequestBody SaveNewStoreReportRequest saveNewStoreReportRequest) { + Long reportId = reportService.saveNewStoreReport(saveNewStoreReportRequest); + return new Result<>(Result.CODE_SUCCESS, Result.MESSAGE_OK, reportId); + } + + //사용자의 특정 가게에 대한 수정, 삭제 요청 정보를 저장 + @Tag(name = "[New] 사용자 제보") + @Operation(summary = "사용자의 특정 가게에 대한 정보 수정 혹은 삭제 요청에 대한 제보를 서버에 저장", description = "사용자의 특정 가게에 대한 정보 수정 혹은 삭제 요청에 대한 제보를 서버에 저장합니다.

" + + "[Request Body]
" + + "dtype: 제보 종류를 구분하기 위한 값. fix는 수정 요청, del은 삭제 요청. fix나 del이 아닌 경우 예외 발생
" + + "storeId: 수정 혹은 삭제를 요청하는 가게 id
" + + "contents: 제보 내용
" + + "[Response Body]
" + + "등록된 reportId
") + @PostMapping("api/report/specificStore/v1") + public Result saveSpecificStoreReport(@Valid @RequestBody SaveSpecificStoreReportRequest saveSpecificStoreReportRequest) { + Long reportId = reportService.saveSpecificStoreReport(saveSpecificStoreReportRequest); + return new Result<>(Result.CODE_SUCCESS, Result.MESSAGE_OK, reportId); + } + + //reportId를 가지고 사용자 제보 내용 조회 + @Tag(name = "[New] 사용자 제보") + @Operation(summary = "reportId를 가지고 사용자 제보 내용 조회", description = "reportId를 가지고 사용자 제보 내용을 조회합니다.

" + + "[Request Body]
" + + "reportId: 검색할 사용자 제보의 reportId. 유효하지 않은 reportId의 경우 예외 발생
" + + "[Response Body]
" + + "해당 reportId로 검색된 사용자 제보 내용
") + @GetMapping("api/report/byId/v1") + public Result findById(@NotNull @RequestParam(value = "reportId") Long reportId) { + Report report = reportService.findById(reportId); + return new Result<>(Result.CODE_SUCCESS, Result.MESSAGE_OK, report); + } + + //DB에 있는 모든 사용자 제보 내용 조회 + @Tag(name = "[New] 사용자 제보") + @Operation(summary = "DB에 있는 모든 사용자 제보 내용 조회", description = "DB에 있는 모든 사용자 제보 내용을 조회합니다.

" + + "[Response Body]
" + + "DB에 있는 모든 사용자 제보 내용
") + @GetMapping("api/report/all/v1") + public Result> findAll() { + List reports = reportService.findAll(); + return new Result<>(Result.CODE_SUCCESS, Result.MESSAGE_OK, reports); + } +} diff --git a/src/main/java/com/nainga/nainga/domain/report/application/ReportService.java b/src/main/java/com/nainga/nainga/domain/report/application/ReportService.java new file mode 100644 index 00000000..c06197b2 --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/application/ReportService.java @@ -0,0 +1,76 @@ +package com.nainga.nainga.domain.report.application; + +import com.nainga.nainga.domain.report.dao.ReportRepository; +import com.nainga.nainga.domain.report.domain.DelSpecificStoreReport; +import com.nainga.nainga.domain.report.domain.FixSpecificStoreReport; +import com.nainga.nainga.domain.report.domain.NewStoreReport; +import com.nainga.nainga.domain.report.domain.Report; +import com.nainga.nainga.domain.report.dto.SaveNewStoreReportRequest; +import com.nainga.nainga.domain.report.dto.SaveSpecificStoreReportRequest; +import com.nainga.nainga.global.exception.GlobalException; +import com.nainga.nainga.global.exception.ReportErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ReportService { + private final ReportRepository reportRepository; + + @Transactional + public Long saveNewStoreReport(SaveNewStoreReportRequest saveNewStoreReportRequest) throws GlobalException { //사용자의 신규 가게 등록 요청 + List certificationList = List.of("착한가격업소", "모범음식점", "안심식당"); //현재 App에서 사용중인 Certification 목록 + for (String certification : saveNewStoreReportRequest.getCertifications()) { + if (!certificationList.contains(certification)) { + throw new GlobalException(ReportErrorCode.INVALID_CERTIFICATION); //잘못된 인증제 값이 들어온 것이므로 예외 발생 + } + } + + NewStoreReport newStoreReport = NewStoreReport.builder() + .storeName(saveNewStoreReportRequest.getStoreName()) + .formattedAddress(saveNewStoreReportRequest.getFormattedAddress()) + .certifications(saveNewStoreReportRequest.getCertifications()) + .build(); + + return reportRepository.save(newStoreReport); + } + + @Transactional + public Long saveSpecificStoreReport(SaveSpecificStoreReportRequest saveSpecificStoreReportRequest) throws GlobalException { //사용자의 특정 가게에 대한 수정, 삭제 요청 + if (saveSpecificStoreReportRequest.getDtype().equals("fix")) { + FixSpecificStoreReport fixSpecificStoreReport = FixSpecificStoreReport.builder() + .storeId(saveSpecificStoreReportRequest.getStoreId()) + .contents(saveSpecificStoreReportRequest.getContents()) + .build(); + + return reportRepository.save(fixSpecificStoreReport); + } else if (saveSpecificStoreReportRequest.getDtype().equals("del")) { + DelSpecificStoreReport delSpecificStoreReport = DelSpecificStoreReport.builder() + .storeId(saveSpecificStoreReportRequest.getStoreId()) + .contents(saveSpecificStoreReportRequest.getContents()) + .build(); + + return reportRepository.save(delSpecificStoreReport); + } else { + throw new GlobalException(ReportErrorCode.INVALID_DTYPE); //잘못된 DTYPE이 들어왔을 경우에 Custom GlobalException 처리 + } + } + + public Report findById(Long id) throws GlobalException { //reportId를 가지고 report를 DB에서 조회하는 로직 + Optional report = reportRepository.findById(id); + if (report.isEmpty()) { + throw new GlobalException(ReportErrorCode.INVALID_REPORT_ID); //잘못된 reportId로 검색하는 경우에 Custom GlobalException 처리 + } else { + return report.get(); + } + } + + public List findAll() { //DB에 있는 모든 Report 조회 + return reportRepository.findAll(); + } +} diff --git a/src/main/java/com/nainga/nainga/domain/report/dao/ReportRepository.java b/src/main/java/com/nainga/nainga/domain/report/dao/ReportRepository.java new file mode 100644 index 00000000..5b6c3982 --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/dao/ReportRepository.java @@ -0,0 +1,33 @@ +package com.nainga.nainga.domain.report.dao; + +import com.nainga.nainga.domain.report.domain.Report; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ReportRepository { + + private final EntityManager em; + + public Long save(Report report) { + em.persist(report); + return report.getId(); + } + + public Optional findById(Long id) { + List result = em.createQuery("select r from Report r where r.id = :id", Report.class) + .setParameter("id", id) + .getResultList(); + return result.stream().findAny(); + } + + public List findAll() { + return em.createQuery("select r from Report r", Report.class) + .getResultList(); + } +} diff --git a/src/main/java/com/nainga/nainga/domain/report/domain/DelSpecificStoreReport.java b/src/main/java/com/nainga/nainga/domain/report/domain/DelSpecificStoreReport.java new file mode 100644 index 00000000..a2649b52 --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/domain/DelSpecificStoreReport.java @@ -0,0 +1,16 @@ +package com.nainga.nainga.domain.report.domain; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.*; + +@Entity +@Getter +@Builder +@DiscriminatorValue("del") +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DelSpecificStoreReport extends Report { + private Long storeId; //가게 id + private String contents; //신고 내용 +} diff --git a/src/main/java/com/nainga/nainga/domain/report/domain/FixSpecificStoreReport.java b/src/main/java/com/nainga/nainga/domain/report/domain/FixSpecificStoreReport.java new file mode 100644 index 00000000..19d4b088 --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/domain/FixSpecificStoreReport.java @@ -0,0 +1,16 @@ +package com.nainga.nainga.domain.report.domain; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.*; + +@Entity +@Getter +@Builder +@DiscriminatorValue("fix") +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FixSpecificStoreReport extends Report { + private Long storeId; //가게 id + private String contents; //신고 내용 +} diff --git a/src/main/java/com/nainga/nainga/domain/report/domain/NewStoreReport.java b/src/main/java/com/nainga/nainga/domain/report/domain/NewStoreReport.java new file mode 100644 index 00000000..af19f012 --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/domain/NewStoreReport.java @@ -0,0 +1,21 @@ +package com.nainga.nainga.domain.report.domain; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import lombok.*; + +import java.util.List; + +@Entity +@Getter +@Builder +@DiscriminatorValue("new") +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NewStoreReport extends Report { + private String storeName; //가게 이름 + private String formattedAddress; //가게 주소 + @ElementCollection + private List certifications; //가게가 가지고 있는 인증제 이름 리스트 +} diff --git a/src/main/java/com/nainga/nainga/domain/report/domain/Report.java b/src/main/java/com/nainga/nainga/domain/report/domain/Report.java new file mode 100644 index 00000000..71c03f7d --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/domain/Report.java @@ -0,0 +1,16 @@ +package com.nainga.nainga.domain.report.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "dtype") +public abstract class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long id; +} diff --git a/src/main/java/com/nainga/nainga/domain/report/dto/SaveNewStoreReportRequest.java b/src/main/java/com/nainga/nainga/domain/report/dto/SaveNewStoreReportRequest.java new file mode 100644 index 00000000..64ae7ee0 --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/dto/SaveNewStoreReportRequest.java @@ -0,0 +1,27 @@ +package com.nainga.nainga.domain.report.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +public class SaveNewStoreReportRequest { + @NotEmpty + @Schema(defaultValue = "가게 이름", description = "새로 등록하고자 하는 가게 이름") + private String storeName; //가게 이름 + @NotEmpty + @Schema(defaultValue = "주소", description = "새로 등록하고자 하는 가게 주소") + private String formattedAddress; //가게 주소 + @NotEmpty + @Schema(defaultValue = "[\"착한가격업소\", \"모범음식점\", \"안심식당\"]", description = "새로 등록할 가게가 가지고 있는 인증제들의 이름을 담은 리스트. 착한가격업소, 모범음식점, 안심식당이 아닌 경우 예외 발생") + private List certifications; //가게가 가지고 있는 인증제 이름 리스트 + + public SaveNewStoreReportRequest(String storeName, String formattedAddress, List certifications) { + this.storeName = storeName; + this.formattedAddress = formattedAddress; + this.certifications = certifications; + } +} \ No newline at end of file diff --git a/src/main/java/com/nainga/nainga/domain/report/dto/SaveSpecificStoreReportRequest.java b/src/main/java/com/nainga/nainga/domain/report/dto/SaveSpecificStoreReportRequest.java new file mode 100644 index 00000000..9f251106 --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/report/dto/SaveSpecificStoreReportRequest.java @@ -0,0 +1,25 @@ +package com.nainga.nainga.domain.report.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class SaveSpecificStoreReportRequest { + @NotEmpty + @Schema(defaultValue = "fix/del", description = "제보 종류를 구분하기 위한 값. fix는 수정 요청, del은 삭제 요청.") + private String dtype; //Report 종류를 구분하기 위한 type + @NotNull + @Schema(defaultValue = "0", description = "수정 혹은 삭제를 요청하는 가게 id") + private Long storeId; //가게 id + @NotEmpty + @Schema(defaultValue = "제보 내용", description = "제보 내용") + private String contents; //신고 내용 + + public SaveSpecificStoreReportRequest(String dtype, Long storeId, String contents) { + this.dtype = dtype; + this.storeId = storeId; + this.contents = contents; + } +} diff --git a/src/main/java/com/nainga/nainga/domain/store/api/StoreApi.java b/src/main/java/com/nainga/nainga/domain/store/api/StoreApi.java index a9a8b5af..dc5177fc 100644 --- a/src/main/java/com/nainga/nainga/domain/store/api/StoreApi.java +++ b/src/main/java/com/nainga/nainga/domain/store/api/StoreApi.java @@ -3,9 +3,11 @@ import com.nainga.nainga.domain.store.application.GoodPriceGoogleMapStoreService; import com.nainga.nainga.domain.store.application.MobeomGoogleMapStoreService; import com.nainga.nainga.domain.store.application.SafeGoogleMapStoreService; +import com.nainga.nainga.domain.store.application.StoreService; import com.nainga.nainga.domain.store.dto.CreateDividedGoodPriceStoresResponse; import com.nainga.nainga.domain.store.dto.CreateDividedMobeomStoresResponse; import com.nainga.nainga.domain.store.dto.CreateDividedSafeStoresResponse; +import com.nainga.nainga.domain.storecertification.dto.StoreCertificationsByLocationResponse; import com.nainga.nainga.global.util.Result; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; @@ -15,12 +17,15 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController @RequiredArgsConstructor public class StoreApi { private final MobeomGoogleMapStoreService mobeomGoogleMapStoreService; private final SafeGoogleMapStoreService safeGoogleMapStoreService; private final GoodPriceGoogleMapStoreService goodPriceGoogleMapStoreService; + private final StoreService storeService; @Hidden @Tag(name = "초기 Data 생성") @@ -78,5 +83,18 @@ public Result createDividedGoodPriceStores System.out.println("response = " + response); //편하게 콘솔 로그에서 확인하기 위한 용도 return new Result<>(Result.CODE_SUCCESS, Result.MESSAGE_OK, response); } + + //검색어를 이용해 가게 이름에 대해 검색하여 나온 검색 결과를 바탕으로 검색어를 자동 완성해서 최대 10개의 자동 완성된 검색어를 리턴 + @Tag(name = "[New] 검색어 자동 완성") + @Operation(summary = "사용자의 검색 키워드를 바탕으로 검색어 자동 완성", description = "사용자의 검색 키워드를 바탕으로 DB에서 매칭되는 가게 이름을 조회하여 최대 10개까지 검색어를 자동으로 완성하여 반환해줍니다.

" + + "[Request Body]
" + + "searchKeyword: 사용자의 검색 키워드
" + + "[Response Body]
" + + "자동으로 완성된 최대 10개의 검색어
") + @GetMapping("api/store/autocorrect/v1") + public Result> autocorrect(@RequestParam(value = "searchKeyword") String searchKeyword) { + List autocorrectResult = storeService.autocorrect(searchKeyword); + return new Result<>(Result.CODE_SUCCESS, Result.MESSAGE_OK, autocorrectResult); + } } diff --git a/src/main/java/com/nainga/nainga/domain/store/application/StoreService.java b/src/main/java/com/nainga/nainga/domain/store/application/StoreService.java new file mode 100644 index 00000000..ed025544 --- /dev/null +++ b/src/main/java/com/nainga/nainga/domain/store/application/StoreService.java @@ -0,0 +1,100 @@ +package com.nainga.nainga.domain.store.application; + +import com.nainga.nainga.domain.store.dao.StoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class StoreService { + private final StoreRepository storeRepository; + + public List autocorrect(String keyword) { //검색어 자동 완성 로직 + return storeRepository.findAllBySearchKeyword(keyword); + } + +// private final RedisSortedSetService redisSortedSetService; +// private final RedisHashService redisHashService; + + /* + Redis Hash 자료 구조를 활용한 새로운 검색어 자동 완성 로직입니다. + 아래 로직은 Case insensitive하게 검색될 수 있도록 구현하였습니다. + 지금은 MySQL이 성능이 더 좋아서 사용하지 않습니다. + */ + +// @PostConstruct +// public void init() { //이 Service Bean이 생성된 이후에 검색어 자동 완성 기능을 위한 데이터들을 Redis에 저장 (Redis는 인메모리 DB라 휘발성을 띄기 때문) +// redisHashService.removeAllOfHash(); +// saveAllDisplayName(storeRepository.findAllDisplayName()); //모든 가게명을 소문자로 변환한 것을 field, 원래 가게 이름을 value로 매핑시켜서 Redis Hash에 저장 +// } + +// public void saveAllDisplayName(List allDisplayName) { +// ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); //병렬 처리를 위한 스레드풀을 생성하는 과정 +// +// for (String displayName : allDisplayName) { +// executorService.submit(() -> { //submit 메서드를 사용해서 병렬 처리할 작업 추가 +// //Redis 내장 기능이 아닌 case insensitive 조회를 구현하기 위해 소문자로 변환한 field값과 원래 string인 value의 쌍을 Hash에 저장 +// redisHashService.addToHash(displayName.toLowerCase(), displayName); //case insensitive하게 검색어 자동 완성 기능을 직접 구현하기 위해 소문자로 통일해서 저장 +// }); +// } +// executorService.shutdown(); //작업이 모두 완료되면 스레드풀을 종료 +// } + +// public List autocorrect(String keyword) { //검색어 자동 완성 로직 +// Set allValuesContainingSearchKeyword = redisHashService.findAllValuesContainingSearchKeyword(keyword); //case insensitive하게 serachKeyword를 포함하는 가게 이름 최대 10개 반환 +// if(allValuesContainingSearchKeyword.isEmpty()) +// return new ArrayList<>(); +// else +// return new ArrayList<>(allValuesContainingSearchKeyword); //자동 완성 결과가 존재하면 ArrayList로 변환하여 리턴 +// } + + /* + 아래 주석처리 된 코드는 초기 검색어 자동 완성 로직입니다. + 검색어 자동 완성에 대한 요구 사항이 앞에서부터 매칭되는 글자가 아닌 Contains 개념으로 바뀌어서 주석 처리하여 임시로 남겨놓았습니다. + 지금은 MySQL이 성능이 더 좋기 때문에 사용하지 않는 코드입니다. + */ + +// private String suffix = "*"; //검색어 자동 완성 기능에서 실제 노출될 수 있는 완벽한 형태의 단어를 구분하기 위한 접미사 +// private int maxSize = 10; //검색어 자동 완성 기능 최대 개수 +// +// @PostConstruct +// public void init() { //이 Service Bean이 생성된 이후에 검색어 자동 완성 기능을 위한 데이터들을 Redis에 저장 (Redis는 인메모리 DB라 휘발성을 띄기 때문) +// redisSortedSetService.removeAllOfSortedSet(); +// saveAllSubstring(storeRepository.findAllDisplayName()); //MySQL DB에 저장된 모든 가게명을 음절 단위로 잘라 모든 Substring을 Redis에 저장해주는 로직 +// } +// +// public void saveAllSubstring(List allDisplayName) { +// ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); //병렬 처리를 위한 스레드풀을 생성하는 과정 +// +// for (String displayName : allDisplayName) { +// executorService.submit(() -> { //submit 메서드를 사용해서 병렬 처리할 작업 추가 +// redisSortedSetService.addToSortedSet(displayName + suffix); +// +// for (int i = displayName.length(); i > 0; --i) { +// redisSortedSetService.addToSortedSet(displayName.substring(0, i)); +// } +// }); +// } +// executorService.shutdown(); //작업이 모두 완료되면 스레드풀을 종료 +// } +// +// public List autocorrect(String keyword) { //검색어 자동 완성 기능 관련 로직 +// Long index = redisSortedSetService.findFromSortedSet(keyword); //사용자가 입력한 검색어를 바탕으로 Redis에서 조회한 결과 매칭되는 index +// +// if (index == null) { +// return new ArrayList<>(); //만약 사용자 검색어 바탕으로 자동 완성 검색어를 만들 수 없으면 Empty Array 리턴 +// } +// +// Set allValuesAfterIndexFromSortedSet = redisSortedSetService.findAllValuesAfterIndexFromSortedSet(index); //사용자 검색어 이후로 정렬된 Redis 데이터들 가져오기 +// +// return allValuesAfterIndexFromSortedSet.stream() +// .filter(value -> value.endsWith(suffix) && value.startsWith(keyword)) +// .map(value -> StringUtils.removeEnd(value, suffix)) +// .limit(maxSize) +// .toList(); //자동 완성을 통해 만들어진 최대 maxSize개의 키워드들 +// } +} diff --git a/src/main/java/com/nainga/nainga/domain/store/dao/StoreRepository.java b/src/main/java/com/nainga/nainga/domain/store/dao/StoreRepository.java index fb5be1d9..b6ead007 100644 --- a/src/main/java/com/nainga/nainga/domain/store/dao/StoreRepository.java +++ b/src/main/java/com/nainga/nainga/domain/store/dao/StoreRepository.java @@ -43,4 +43,16 @@ public List findAll() { return em.createQuery("select s from Store s", Store.class) .getResultList(); } + + public List findAllDisplayName() { + return em.createQuery("select s.displayName from Store s", String.class) + .getResultList(); + } + + public List findAllBySearchKeyword(String searchKeyword) { + return em.createQuery("select s.displayName from Store s where s.displayName like concat('%', :searchKeyword, '%')", String.class) + .setParameter("searchKeyword", searchKeyword) + .setMaxResults(10) + .getResultList(); + } } diff --git a/src/main/java/com/nainga/nainga/global/application/RedisHashService.java b/src/main/java/com/nainga/nainga/global/application/RedisHashService.java new file mode 100644 index 00000000..61e322c9 --- /dev/null +++ b/src/main/java/com/nainga/nainga/global/application/RedisHashService.java @@ -0,0 +1,54 @@ +//package com.nainga.nainga.global.application; +// +//import org.springframework.data.redis.core.Cursor; +//import org.springframework.data.redis.core.HashOperations; +//import org.springframework.data.redis.core.RedisTemplate; +//import org.springframework.data.redis.core.ScanOptions; +//import org.springframework.stereotype.Service; +// +//import java.util.HashSet; +//import java.util.Map; +//import java.util.Set; +//import java.util.TreeSet; +// +//@Service +//public class RedisHashService { +// private final HashOperations hashOperations; +// private final RedisTemplate redisTemplate; +// +// public RedisHashService(RedisTemplate redisTemplate) { +// this.hashOperations = redisTemplate.opsForHash(); +// this.redisTemplate = redisTemplate; +// } +// +// private String key = "autocorrect"; //검색어 자동 완성을 위한 Redis 데이터 +// +// //Hash에 field-value 쌍을 추가하는 메서드 +// public void addToHash(String field, String value) { +// hashOperations.put(key, field, value); +// } +// +// public Set findAllValuesContainingSearchKeyword(String searchKeyword) { +// //Redis에서는 case insensitive한 검색을 지원하는 내장 모듈이 없으므로 searchKeyword는 모두 소문자로 통일하여 검색하도록 구현 +// //당연히 초기 Redis에 field를 저장할 때도 모두 소문자로 변형하여 저장했고 원본 문자열은 value에 저장! +// Set result = new HashSet<>(); //searchKeyword를 포함하는 원래 가게 이름들의 리스트. 최대 maxSize개까지 저장. 중복 허용하지 않고, 자동 사전순 정렬하기 위해 사용 +// final int maxSize = 10; //최대 검색어 자동 완성 개수 +// +// ScanOptions scanOptions = ScanOptions.scanOptions().match("*" + searchKeyword + "*").build(); //searchKeyword를 포함하는지를 검사하기 위한 scanOption +// Cursor> cursor = hashOperations.scan(key, scanOptions); //기존 Redis Keys 로직의 성능 이슈를 해결하기 위해 10개 단위로 끊어서 조회하는 Scan 기능 사용 +// +// while (cursor.hasNext()) { //끊어서 조회하다보니 while loop로 조회 +// Map.Entry entry = cursor.next(); +// result.add(entry.getValue()); +// +// if(result.size() >= maxSize) //maxSize에 도달하면 scan 중단 +// break; +// } +// cursor.close(); +// return result; +// } +// +// public void removeAllOfHash() { +// redisTemplate.delete(key); +// } +//} diff --git a/src/main/java/com/nainga/nainga/global/application/RedisSortedSetService.java b/src/main/java/com/nainga/nainga/global/application/RedisSortedSetService.java new file mode 100644 index 00000000..52a575b9 --- /dev/null +++ b/src/main/java/com/nainga/nainga/global/application/RedisSortedSetService.java @@ -0,0 +1,34 @@ +//package com.nainga.nainga.global.application; +// +//import lombok.RequiredArgsConstructor; +//import org.springframework.data.redis.core.RedisCallback; +//import org.springframework.data.redis.core.RedisTemplate; +//import org.springframework.data.redis.core.ZSetOperations; +//import org.springframework.stereotype.Service; +// +//import java.util.List; +//import java.util.Set; +// +//@Service +//@RequiredArgsConstructor +//public class RedisSortedSetService { //검색어 자동 완성을 구현할 때 사용하는 Redis의 SortedSet 관련 서비스 레이어 +// private final RedisTemplate redisTemplate; +// private String key = "autocorrect"; //검색어 자동 완성을 위한 Redis 데이터 +// private int score = 0; //Score는 딱히 필요 없으므로 하나로 통일 +// +// public void addToSortedSet(String value) { //Redis SortedSet에 추가 +// redisTemplate.opsForZSet().add(key, value, score); +// } +// +// public Long findFromSortedSet(String value) { //Redis SortedSet에서 Value를 찾아 인덱스를 반환 +// return redisTemplate.opsForZSet().rank(key, value); +// } +// +// public Set findAllValuesAfterIndexFromSortedSet(Long index) { +// return redisTemplate.opsForZSet().range(key, index, index + 200); //전체를 다 불러오기 보다는 200개 정도만 가져와도 자동 완성을 구현하는 데 무리가 없으므로 200개로 rough하게 설정 +// } +// +// public void removeAllOfSortedSet() { +// redisTemplate.opsForZSet().removeRange(key, 0, -1); +// } +//} diff --git a/src/main/java/com/nainga/nainga/global/exception/ReportErrorCode.java b/src/main/java/com/nainga/nainga/global/exception/ReportErrorCode.java new file mode 100644 index 00000000..b2603b79 --- /dev/null +++ b/src/main/java/com/nainga/nainga/global/exception/ReportErrorCode.java @@ -0,0 +1,19 @@ +package com.nainga.nainga.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/* + Report와 관련된 도메인에서 발생할 수 있는 ErrorCode를 정의합니다. + */ +@Getter +@RequiredArgsConstructor +public enum ReportErrorCode implements ErrorCode { + INVALID_DTYPE(HttpStatus.NOT_FOUND, "There is a wrong dtype. You can only use a dtype such as fix or del."), + INVALID_CERTIFICATION(HttpStatus.NOT_FOUND, "There is a wrong certification. You can only use certifications such as 착한가격업소, 모범음식점, 안심식당."), + INVALID_REPORT_ID(HttpStatus.NOT_FOUND, "There is a wrong reportId."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/resources/backend-submodule b/src/main/resources/backend-submodule index 864e24ee..c741a715 160000 --- a/src/main/resources/backend-submodule +++ b/src/main/resources/backend-submodule @@ -1 +1 @@ -Subproject commit 864e24eecb92e71b9c8988e9684e4faf9802dcd8 +Subproject commit c741a71530d128886428736a347039c2d406bebc diff --git a/src/test/java/com/nainga/nainga/domain/report/application/ReportServiceTest.java b/src/test/java/com/nainga/nainga/domain/report/application/ReportServiceTest.java new file mode 100644 index 00000000..73ff0101 --- /dev/null +++ b/src/test/java/com/nainga/nainga/domain/report/application/ReportServiceTest.java @@ -0,0 +1,109 @@ +package com.nainga.nainga.domain.report.application; + +import com.nainga.nainga.domain.report.dao.ReportRepository; +import com.nainga.nainga.domain.report.domain.DelSpecificStoreReport; +import com.nainga.nainga.domain.report.domain.FixSpecificStoreReport; +import com.nainga.nainga.domain.report.domain.NewStoreReport; +import com.nainga.nainga.domain.report.domain.Report; +import com.nainga.nainga.domain.report.dto.SaveNewStoreReportRequest; +import com.nainga.nainga.domain.report.dto.SaveSpecificStoreReportRequest; +import com.nainga.nainga.global.exception.GlobalException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Transactional +class ReportServiceTest { + @Autowired + private ReportRepository reportRepository; + + @Autowired + private ReportService reportService; + + @Test + public void saveNewStoreReport() throws Exception { + //given + SaveNewStoreReportRequest saveNewStoreReportRequest1 = new SaveNewStoreReportRequest("가게1", "주소1", List.of("착한가격업소", "모범음식점")); //정상적인 테스트 케이스 + SaveNewStoreReportRequest saveNewStoreReportRequest2 = new SaveNewStoreReportRequest("가게2", "주소2", List.of("착한가격업")); //인증제 이름이 잘못되었을 때 + + //when + Long report1Id = reportService.saveNewStoreReport(saveNewStoreReportRequest1); + NewStoreReport report = (NewStoreReport) reportService.findById(report1Id); + + //then + assertArrayEquals(report.getCertifications().toArray(), saveNewStoreReportRequest1.getCertifications().toArray()); + assertThat(report.getStoreName()).isEqualTo(saveNewStoreReportRequest1.getStoreName()); + assertThat(report.getFormattedAddress()).isEqualTo(saveNewStoreReportRequest1.getFormattedAddress()); + assertThatThrownBy(() -> reportService.saveNewStoreReport(saveNewStoreReportRequest2)) //잘못된 인증제라서 예외가 터져야함 + .isInstanceOf(GlobalException.class); + } + + @Test + public void saveSpecificStoreReport() throws Exception { + //given + SaveSpecificStoreReportRequest saveSpecificStoreReportRequest1 = new SaveSpecificStoreReportRequest("del", 123L, "내용1"); //정상적인 테스트 케이스 + SaveSpecificStoreReportRequest saveSpecificStoreReportRequest2 = new SaveSpecificStoreReportRequest("fix", 1234L, "내용2"); //정상적인 테스트 케이스 + SaveSpecificStoreReportRequest saveSpecificStoreReportRequest3 = new SaveSpecificStoreReportRequest("xxx", 12345L, "내용3"); //잘못된 dtype + + //when + Long report1Id = reportService.saveSpecificStoreReport(saveSpecificStoreReportRequest1); + Long report2Id = reportService.saveSpecificStoreReport(saveSpecificStoreReportRequest2); + DelSpecificStoreReport report1 = (DelSpecificStoreReport) reportService.findById(report1Id); + FixSpecificStoreReport report2 = (FixSpecificStoreReport) reportService.findById(report2Id); + + //then + assertThat(report1.getStoreId()).isEqualTo(saveSpecificStoreReportRequest1.getStoreId()); + assertThat(report2.getContents()).isEqualTo(saveSpecificStoreReportRequest2.getContents()); + assertThatThrownBy(() -> reportService.saveSpecificStoreReport(saveSpecificStoreReportRequest3)) //잘못된 dtype이라서 예외가 터져야함 + .isInstanceOf(GlobalException.class); + } + + @Test + public void findById() throws Exception { + //given + SaveNewStoreReportRequest saveNewStoreReportRequest1 = new SaveNewStoreReportRequest("가게1", "주소1", List.of("착한가격업소", "모범음식점")); //정상적인 테스트 케이스 + SaveSpecificStoreReportRequest saveSpecificStoreReportRequest1 = new SaveSpecificStoreReportRequest("del", 123L, "내용1"); //정상적인 테스트 케이스 + + //when + Long report1Id = reportService.saveNewStoreReport(saveNewStoreReportRequest1); + Long report2Id = reportService.saveSpecificStoreReport(saveSpecificStoreReportRequest1); + NewStoreReport report1 = (NewStoreReport) reportService.findById(report1Id); + DelSpecificStoreReport report2 = (DelSpecificStoreReport) reportService.findById(report2Id); + + //then + assertThat(report1.getFormattedAddress()).isEqualTo(saveNewStoreReportRequest1.getFormattedAddress()); + assertArrayEquals(report1.getCertifications().toArray(), saveNewStoreReportRequest1.getCertifications().toArray()); + assertThat(report1.getStoreName()).isEqualTo(saveNewStoreReportRequest1.getStoreName()); + assertThat(report2.getContents()).isEqualTo(saveSpecificStoreReportRequest1.getContents()); + assertThat(report2.getStoreId()).isEqualTo(saveSpecificStoreReportRequest1.getStoreId()); + } + + @Test + public void findAll() throws Exception { + //given + SaveNewStoreReportRequest saveNewStoreReportRequest1 = new SaveNewStoreReportRequest("가게1", "주소1", List.of("착한가격업소", "모범음식점")); //정상적인 테스트 케이스 + SaveSpecificStoreReportRequest saveSpecificStoreReportRequest1 = new SaveSpecificStoreReportRequest("del", 123L, "내용1"); //정상적인 테스트 케이스 + + //when + Long report1Id = reportService.saveNewStoreReport(saveNewStoreReportRequest1); + Long report2Id = reportService.saveSpecificStoreReport(saveSpecificStoreReportRequest1); + + List reports = reportService.findAll(); + List reportIds = reports.stream().map(Report::getId).toList(); + + //then + assertArrayEquals(reportIds.toArray(), List.of(report1Id, report2Id).toArray()); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/nainga/nainga/domain/store/application/StoreServiceTest.java b/src/test/java/com/nainga/nainga/domain/store/application/StoreServiceTest.java new file mode 100644 index 00000000..cf87f9c0 --- /dev/null +++ b/src/test/java/com/nainga/nainga/domain/store/application/StoreServiceTest.java @@ -0,0 +1,49 @@ +package com.nainga.nainga.domain.store.application; + +import com.nainga.nainga.domain.store.dao.StoreRepository; +import com.nainga.nainga.domain.store.domain.Store; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Transactional +class StoreServiceTest { + + @Autowired + StoreService storeService; + + @Autowired + StoreRepository storeRepository; + + @Test + public void autocorrect() throws Exception { //검색어 자동 완성 기능에 대한 테스트 + //given + Store store1 = Store.builder() + .displayName("^") + .build(); + Store store2 = Store.builder() + .displayName("^^") + .build(); + Store store3 = Store.builder() + .displayName("^*^") + .build(); + storeRepository.save(store1); + storeRepository.save(store2); + storeRepository.save(store3); + + //when + List result1 = storeService.autocorrect("^"); + List result2 = storeService.autocorrect("^^"); + + //then + assertArrayEquals(result1.toArray(), List.of("^", "^^", "^*^").toArray()); + assertArrayEquals(result2.toArray(), List.of("^^").toArray()); + } +} \ No newline at end of file diff --git a/src/test/resources/backend-submodule b/src/test/resources/backend-submodule index 732770ba..c741a715 160000 --- a/src/test/resources/backend-submodule +++ b/src/test/resources/backend-submodule @@ -1 +1 @@ -Subproject commit 732770ba3e8a25abfb4212a7a75175249e2c099e +Subproject commit c741a71530d128886428736a347039c2d406bebc