Skip to content

[Feat] 나만의 맛집 코멘트 등록 API 구현 #90

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jul 23, 2025
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
41 changes: 41 additions & 0 deletions src/main/java/eatda/client/map/StoreSearchResult.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import eatda.domain.store.Store;
import eatda.domain.store.StoreCategory;
import java.util.Map;

@JsonIgnoreProperties(ignoreUnknown = true)
public record StoreSearchResult(
Expand All @@ -17,6 +20,17 @@ public record StoreSearchResult(
@JsonProperty("x") double longitude
) {

private static final Map<String, StoreCategory> PREFIX_TO_CATEGORY = Map.of(
"음식점 > 한식", StoreCategory.KOREAN,
"음식점 > 중식", StoreCategory.CHINESE,
"음식점 > 일식", StoreCategory.JAPANESE,
"음식점 > 양식", StoreCategory.WESTERN,
"음식점 > 카페", StoreCategory.CAFE,
"음식점 > 간식 > 제과,베이커리", StoreCategory.BAKERY,
"음식점 > 술집", StoreCategory.PUB,
"음식점 > 패스트푸드", StoreCategory.FAST_FOOD
);

public boolean isFoodStore() {
return "FD6".equals(categoryGroupCode);
}
Expand All @@ -27,4 +41,31 @@ public boolean isInSeoul() {
}
return lotNumberAddress.trim().startsWith("서울");
}

public StoreCategory getStoreCategory() {
if (categoryName == null) {
return StoreCategory.OTHER;
}

return PREFIX_TO_CATEGORY.entrySet()
.stream()
.filter(entry -> categoryName.startsWith(entry.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(StoreCategory.OTHER);
}

public Store toStore() {
return Store.builder()
.kakaoId(kakaoId)
.category(getStoreCategory())
.phoneNumber(phoneNumber)
.name(name)
.placeUrl(placeUrl)
.roadAddress(roadAddress)
.lotNumberAddress(lotNumberAddress)
.latitude(latitude)
.longitude(longitude)
.build();
}
}
14 changes: 14 additions & 0 deletions src/main/java/eatda/controller/store/CheerController.java
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
package eatda.controller.store;

import eatda.controller.web.auth.LoginMember;
import eatda.service.store.CheerService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
public class CheerController {

private final CheerService cheerService;

@PostMapping("/api/cheer")
public ResponseEntity<CheerResponse> registerCheer(@RequestPart("request") CheerRegisterRequest request,
@RequestPart(value = "image", required = false) MultipartFile image,
LoginMember member) {
CheerResponse response = cheerService.registerCheer(request, image, member.id());
return ResponseEntity.status(HttpStatus.CREATED)
.body(response);
}

@GetMapping("/api/cheer")
public ResponseEntity<CheersResponse> getCheers(@RequestParam @Min(1) @Max(50) int size) {
CheersResponse response = cheerService.getCheers(size);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package eatda.controller.store;

public record CheerRegisterRequest(
String storeKakaoId,
String storeName,
String description
) {
}
21 changes: 21 additions & 0 deletions src/main/java/eatda/controller/store/CheerResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package eatda.controller.store;

import eatda.domain.store.Cheer;
import eatda.domain.store.Store;

public record CheerResponse(
long storeId,
long cheerId,
String imageUrl,
String cheerDescription
) {

public CheerResponse(Cheer cheer, String imageUrl, Store store) {
this(
store.getId(),
cheer.getId(),
imageUrl,
cheer.getDescription()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package eatda.controller.story;

import eatda.domain.store.StoreCategory;

public record FilteredSearchResult(
String kakaoId,
String name,
String roadAddress,
String lotNumberAddress,
String category
StoreCategory category
) {
}
1 change: 0 additions & 1 deletion src/main/java/eatda/domain/store/Store.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
Expand Down
3 changes: 1 addition & 2 deletions src/main/java/eatda/domain/store/StoreCategory.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ public enum StoreCategory {
JAPANESE("일식"),
WESTERN("양식"),
CAFE("카페"),
DESSERT("디저트"),
BAKERY("베이커리"),
PUB("술집"),
FAST_FOOD("패스트푸드"),
CONVENIENCE("편의점"),
OTHER("기타");

private final String categoryName;
Expand Down
14 changes: 9 additions & 5 deletions src/main/java/eatda/domain/story/Story.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import eatda.domain.AuditingEntity;
import eatda.domain.member.Member;
import eatda.domain.store.StoreCategory;
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
Expand Down Expand Up @@ -44,8 +47,9 @@ public class Story extends AuditingEntity {
@Column(name = "store_lot_number_address", nullable = false)
private String storeLotNumberAddress;

@Enumerated(EnumType.STRING)
@Column(name = "store_category", nullable = false)
private String storeCategory;
private StoreCategory storeCategory;

@Column(name = "description", nullable = false)
private String description;
Expand All @@ -57,7 +61,7 @@ public class Story extends AuditingEntity {
private Story(
Member member,
String storeKakaoId,
String storeCategory,
StoreCategory storeCategory,
String storeName,
String storeRoadAddress,
String storeLotNumberAddress,
Expand Down Expand Up @@ -86,7 +90,7 @@ private void validateMember(Member member) {

private void validateStore(
String storeKakaoId,
String storeCategory,
StoreCategory storeCategory,
String storeName,
String roadAddress,
String lotNumberAddress
Expand Down Expand Up @@ -127,8 +131,8 @@ private void validateStoreLotNumberAddress(String lotNumberAddress) {
}
}

private void validateStoreCategory(String storeCategory) {
if (storeCategory == null || storeCategory.isBlank()) {
private void validateStoreCategory(StoreCategory storeCategory) {
if (storeCategory == null) {
throw new BusinessException(BusinessErrorCode.INVALID_STORE_CATEGORY);
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/eatda/exception/BusinessErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public enum BusinessErrorCode {
// Cheer
INVALID_CHEER_DESCRIPTION("CHE001", "응원 메시지는 필수입니다."),
INVALID_CHEER_IMAGE_KEY("CHE002", "응원 이미지 키가 비어 있습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
FULL_CHEER_SIZE_PER_MEMBER("CHE003", "회원당 응원 한도가 넘었습니다."),
ALREADY_CHEERED("CHE004", "이미 응원한 가게입니다."),

// Map
MAP_SERVER_ERROR("MAP001", "지도 서버와의 통신 오류입니다.", HttpStatus.INTERNAL_SERVER_ERROR),
Expand Down
13 changes: 9 additions & 4 deletions src/main/java/eatda/repository/store/CheerRepository.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package eatda.repository.store;

import eatda.domain.member.Member;
import eatda.domain.store.Cheer;
import eatda.domain.store.Store;
import java.util.List;
Expand All @@ -15,9 +16,13 @@ public interface CheerRepository extends Repository<Cheer, Long> {
List<Cheer> findAllByOrderByCreatedAtDesc(Pageable pageable);

@Query("""
SELECT c.imageKey FROM Cheer c
WHERE c.store = :store AND c.imageKey IS NOT NULL
ORDER BY c.createdAt DESC
LIMIT 1""")
SELECT c.imageKey FROM Cheer c
WHERE c.store = :store AND c.imageKey IS NOT NULL
ORDER BY c.createdAt DESC
LIMIT 1""")
Optional<String> findRecentImageKey(Store store);

int countByMember(Member member);

boolean existsByMemberAndStoreKakaoId(Member member, String storeKakaoId);
}
3 changes: 3 additions & 0 deletions src/main/java/eatda/repository/store/StoreRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import eatda.domain.store.Store;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.Repository;

public interface StoreRepository extends Repository<Store, Long> {

Store save(Store store);

Optional<Store> findByKakaoId(String kakaoId);

List<Store> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
4 changes: 3 additions & 1 deletion src/main/java/eatda/service/common/ImageDomain.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ public enum ImageDomain {
ARTICLE("article"),
STORE("store"),
MEMBER("member"),
STORY("story");
STORY("story"),
CHEER("cheer"),
;

private final String name;
}
44 changes: 43 additions & 1 deletion src/main/java/eatda/service/store/CheerService.java
Original file line number Diff line number Diff line change
@@ -1,22 +1,64 @@
package eatda.service.store;

import eatda.client.map.MapClient;
import eatda.client.map.StoreSearchResult;
import eatda.controller.store.CheerPreviewResponse;
import eatda.controller.store.CheerRegisterRequest;
import eatda.controller.store.CheerResponse;
import eatda.controller.store.CheersResponse;
import eatda.domain.member.Member;
import eatda.domain.store.Cheer;
import eatda.domain.store.Store;
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import eatda.repository.member.MemberRepository;
import eatda.repository.store.CheerRepository;
import eatda.repository.store.StoreRepository;
import eatda.service.common.ImageDomain;
import eatda.service.common.ImageService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
public class CheerService {

private final ImageService imageService;
private static final int MAX_CHEER_SIZE = 3;

private final MapClient mapClient;
private final StoreSearchFilter storeSearchFilter;
private final MemberRepository memberRepository;
private final StoreRepository storeRepository;
private final CheerRepository cheerRepository;
private final ImageService imageService;

@Transactional
public CheerResponse registerCheer(CheerRegisterRequest request, MultipartFile image, long memberId) {
Member member = memberRepository.getById(memberId);
validateRegisterCheer(member, request.storeKakaoId());

List<StoreSearchResult> searchResults = mapClient.searchShops(request.storeName());
StoreSearchResult result = storeSearchFilter.filterStoreByKakaoId(searchResults, request.storeKakaoId());
String imageKey = imageService.upload(image, ImageDomain.CHEER);

Store store = storeRepository.findByKakaoId(result.kakaoId())
.orElseGet(() -> storeRepository.save(result.toStore())); // TODO 상점 조회/저장 동시성 이슈 해결
Cheer cheer = cheerRepository.save(new Cheer(member, store, request.description(), imageKey));
return new CheerResponse(cheer, imageService.getPresignedUrl(imageKey), store);
}

private void validateRegisterCheer(Member member, String storeKakaoId) {
if (cheerRepository.countByMember(member) >= MAX_CHEER_SIZE) {
throw new BusinessException(BusinessErrorCode.FULL_CHEER_SIZE_PER_MEMBER);
}
if (cheerRepository.existsByMemberAndStoreKakaoId(member, storeKakaoId)) {
throw new BusinessException(BusinessErrorCode.ALREADY_CHEERED);
}
}

@Transactional(readOnly = true)
public CheersResponse getCheers(int size) {
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/eatda/service/store/StoreSearchFilter.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package eatda.service.store;

import eatda.client.map.StoreSearchResult;
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import java.util.List;
import org.springframework.stereotype.Component;

Expand All @@ -13,6 +15,14 @@ public List<StoreSearchResult> filterSearchedStores(List<StoreSearchResult> sear
.toList();
}

public StoreSearchResult filterStoreByKakaoId(List<StoreSearchResult> searchResults, String kakaoId) {
return searchResults.stream()
.filter(store -> store.kakaoId().equals(kakaoId))
.filter(this::isValidStore)
.findFirst()
.orElseThrow(() -> new BusinessException(BusinessErrorCode.STORE_NOT_FOUND));
}

private boolean isValidStore(StoreSearchResult store) {
return store.isFoodStore() && store.isInSeoul();
}
Expand Down
5 changes: 0 additions & 5 deletions src/main/java/eatda/service/store/StoreService.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,4 @@ public StoreSearchResponses searchStores(String query) {
List<StoreSearchResult> filteredResults = storeSearchFilter.filterSearchedStores(searchResults);
return StoreSearchResponses.from(filteredResults);
}

public List<StoreSearchResult> searchStoreResults(String query) {
List<StoreSearchResult> searchResults = mapClient.searchShops(query);
return storeSearchFilter.filterSearchedStores(searchResults);
}
}
Loading