Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6658782
feat: 스토리 스키마 추가
lvalentine6 Jul 14, 2025
2b3d6ce
feat: 스토리 도메인 구현
lvalentine6 Jul 14, 2025
529b7e8
chore: 에러 로그 추가
lvalentine6 Jul 15, 2025
8070da7
feat: 스토리 등록 record 구현
lvalentine6 Jul 15, 2025
4ab8d8c
feat: 스토리 목록 조회 record 구현
lvalentine6 Jul 15, 2025
8185eae
feat: 스토리 스켈레톤 클래스 구현
lvalentine6 Jul 15, 2025
90a90e6
fix: 기본 컨텐츠 타입 상수 처리
lvalentine6 Jul 16, 2025
111037a
feat: 이미지 업로드가 가능한 도메인 추가
lvalentine6 Jul 16, 2025
b25be03
fix: 업로드시 enum을 이용하도록 변경
lvalentine6 Jul 16, 2025
d17098f
feat: 카카오 api에 단건 가게 조회를 위한 메서드 구현
lvalentine6 Jul 16, 2025
919a41a
refactor: 빌더 패턴 적용 및 상속 적용
lvalentine6 Jul 16, 2025
4572bec
feat: 스토리 등록 기능 구현
lvalentine6 Jul 16, 2025
089df85
fix: 스토리에 스토어 정보를 들고있게 수정
lvalentine6 Jul 16, 2025
5103492
fix: 미구현 메서드 삭제
lvalentine6 Jul 16, 2025
bf0f4e3
chore: 임시 로그 추가
lvalentine6 Jul 16, 2025
0ac5a65
test: base 스토리 서비스 추가
lvalentine6 Jul 16, 2025
00483e4
test: 이미지 서비스 enum 이용하도록 수정
lvalentine6 Jul 16, 2025
36a230a
test: 스토리 컨트롤러 테스트 구현
lvalentine6 Jul 16, 2025
47a38bf
test: 스토리 도큐먼트 테스트 구현
lvalentine6 Jul 16, 2025
10f95c8
test: 스토리 서비스 테스트 구현
lvalentine6 Jul 16, 2025
d819c60
test: 스토리 도메인 테스트 구현
lvalentine6 Jul 16, 2025
510546d
test: 스토리 tag 추가
lvalentine6 Jul 16, 2025
5bc6fe2
fix: 예외 이름 변경
lvalentine6 Jul 16, 2025
d9d62ed
fix: 예외 이름 변경 적용
lvalentine6 Jul 16, 2025
d3b7bb9
test: 잘못된 참조 수정
lvalentine6 Jul 16, 2025
70beb7d
fix: 스토리 예외 적용
lvalentine6 Jul 16, 2025
f716899
style: 주석 제거
lvalentine6 Jul 16, 2025
4ae9785
fix: 빌더 생성자 허용 범위 변경
lvalentine6 Jul 17, 2025
e527cf5
fix: StoryRepository 실제 객체 사용으로 수정
lvalentine6 Jul 17, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package eatda.controller.story;

public record FilteredSearchResult(
String kakaoId,
String name,
String address,
String category
) {
}
13 changes: 13 additions & 0 deletions src/main/java/eatda/controller/story/StoriesResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package eatda.controller.story;

import java.util.List;

public record StoriesResponse(
List<StoryPreview> stories
) {
public record StoryPreview(
Long storyId,
String imageUrl
) {
}
}
28 changes: 28 additions & 0 deletions src/main/java/eatda/controller/story/StoryController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package eatda.controller.story;

import eatda.controller.web.auth.LoginMember;
import eatda.service.story.StoryService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
public class StoryController {

private final StoryService storyService;

@PostMapping("/api/stories")
public ResponseEntity<Void> registerStory(
@RequestPart("request") StoryRegisterRequest request,
@RequestPart("image") MultipartFile image,
LoginMember member
) {
storyService.registerStory(request, image, member.id());
return ResponseEntity.status(HttpStatus.CREATED).build();
}
Comment on lines +19 to +27
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[제안] POST 요청에서 StoryResponse를 응답하는 건 어떨까요?

  • POST 요청에서 어떻게 만들어졌는지 Response를 반환하는 게 일반적이라고 생각했어요.
  • 클라이언트가 해당 값을 사용하지 않을 거라면, (실용적인 입장에서) 추가하지 않아도 된다고 생각합니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 생각해보니 스토리 등록후에 어디로 리다이렉트 될지 명확하지 않은 상황이네요.
저는 스토리 생성 후 스토리 목록으로 돌아간다고 생각해서 바디를 넣지는 않았는데
이 부분은 프론트팀에 물어보고 필요하다면 수정할께요 :)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package eatda.controller.story;

public record StoryRegisterRequest(
String query,
String storeKakaoId,
String description
) {
}
11 changes: 11 additions & 0 deletions src/main/java/eatda/controller/story/StoryResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package eatda.controller.story;

public record StoryResponse(
String storeKakaoId,
String category,
String storeName,
String storeAddress,
String description,
String imageUrl
) {
}
129 changes: 129 additions & 0 deletions src/main/java/eatda/domain/story/Story.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package eatda.domain.story;

import eatda.domain.AuditingEntity;
import eatda.domain.member.Member;
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(name = "story")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Story extends AuditingEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;

@Column(name = "store_kakao_id", nullable = false)
private String storeKakaoId;

@Column(name = "store_name", nullable = false)
private String storeName;

@Column(name = "store_address", nullable = false)
private String storeAddress;

@Column(name = "store_category", nullable = false)
private String storeCategory;
Comment on lines +44 to +45
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category 는 StoreCategory 값이 들어가야 할 것 같은데요. 해당 부분은 Kakao API의 응답값에 따라 Enum으로 바꿔주는 작업이 필요합니다.
일단 넘어가신다면 제가 응원 등록 API 만들면서 같이 작업하도록 하겠습니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아! 이전에 만들어둔 StoreCategory enum이 있었네요
일단은 Kakao API 응답값 그대로 사용하는 쪽으로 진행했는데,
응원 등록 쪽 작업하시면서 해당 enum으로 매핑하신다면 좋을 것 같습니다.
필요하시면 이후 작업에도 같이 맞춰갈게요!


@Column(name = "description", nullable = false)
private String description;

@Column(name = "image_key", nullable = false)
private String imageKey;

@Builder
private Story(
Member member,
String storeKakaoId,
String storeName,
String storeAddress,
String storeCategory,
String description,
String imageKey
) {
validateMember(member);
validateStore(storeKakaoId, storeName, storeAddress, storeCategory);
validateStory(description, imageKey);

this.member = member;
this.storeKakaoId = storeKakaoId;
this.storeName = storeName;
this.storeAddress = storeAddress;
this.storeCategory = storeCategory;
this.description = description;
this.imageKey = imageKey;
}

private void validateMember(Member member) {
if (member == null) {
throw new BusinessException(BusinessErrorCode.STORY_MEMBER_REQUIRED);
}
}

private void validateStore(String storeKakaoId, String storeName, String storeAddress, String storeCategory) {
validateStoreKakaoId(storeKakaoId);
validateStoreName(storeName);
validateStoreAddress(storeAddress);
validateStoreCategory(storeCategory);
}
Comment on lines +82 to +87
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[검토] 외부 API를 통해 받는 값들은 검증을 하면 안된다고 생각하는데, Kakao API 문서를 참고하셨다면 크게 문제는 없다고 생각합니다. 혹시 모르니 한 번 더 확인 부탁드릴께요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오.. Kakao API 문서를 참고해서 작성하고 연동 테스트를 거쳤습니다!

외부 API에서 온 값들은 검증을 하지 않아야 한다는 이유가 무엇일까요?
저는 외부에서 들어오는 값이기 때문에 더 검증이 필요하다고 생각했는데요
외부 API 응답에 문제가 있거나 구조가 변경되는 상황을 고려하면,
도메인 로직이 그 영향에서 최대한 독립적이어야 하지 않을까 해서요.

만약 검증을 하지 않는다면
저희 서비스가 외부 API에 완전 종속적이고 장애에 그대로 영향을 받기에 최소한의 방어를 하고 싶었어요

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

외부에서 들어오는 값이기 때문에 더 검증이 필요하다고 생각했는데요

저도 외부에서 들어온 값을 검증해야 한다는 것에는 동의합니다. 그런데 "Kakao API 에서 들어온 값이 우리가 정한 도메인 규칙과 일치하지 않아서 가게가 등록되지 않는 상황"에 대한 염려가 큰 것 같아요. 외부 API에서 들어온 모든 정보가 일정한 형식이라는 보장이 되기는 힘드니까요;; 적어도 "특정 형식이 아닐 경우 설정할 기본값"이 있어야 할 것 같아요.
지금은 크게 문제 없을 것 같아 넘어가시죠! 코드 형식적으로 논의해볼 내용들은 제가 한 번 다 정리해서 이야기해보면 좋을 것 같아요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오... 그런 부분도 있겠네요
DTO 계층에서의 유연하게 방어할수 있는 기본값 설정을 하면 딱 좋을것 같네요 😄
2차 스프린트 이후에 리펙토링 시기에 고민해보시죠!


private void validateStory(String description, String imageKey) {
validateDescription(description);
validateImage(imageKey);
}

private void validateStoreKakaoId(String storeKakaoId) {
if (storeKakaoId == null || storeKakaoId.isBlank()) {
throw new BusinessException(BusinessErrorCode.INVALID_STORE_KAKAO_ID);
}
}

private void validateStoreName(String storeName) {
if (storeName == null || storeName.isBlank()) {
throw new BusinessException(BusinessErrorCode.INVALID_STORE_NAME);
}
}

private void validateStoreAddress(String storeAddress) {
if (storeAddress == null || storeAddress.isBlank()) {
throw new BusinessException(BusinessErrorCode.INVALID_STORE_ADDRESS);
}
}

private void validateStoreCategory(String storeCategory) {
if (storeCategory == null || storeCategory.isBlank()) {
throw new BusinessException(BusinessErrorCode.INVALID_STORE_CATEGORY);
}
}

private void validateDescription(String description) {
if (description == null || description.isBlank()) {
throw new BusinessException(BusinessErrorCode.INVALID_STORY_DESCRIPTION);
}
}

private void validateImage(String imageKey) {
if (imageKey == null || imageKey.isBlank()) {
throw new BusinessException(BusinessErrorCode.INVALID_STORY_IMAGE_KEY);
}
}
}
13 changes: 12 additions & 1 deletion src/main/java/eatda/exception/BusinessErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public enum BusinessErrorCode {
INVALID_STORE_COORDINATES_NULL("STO007", "좌표 값은 필수입니다."),
OUT_OF_SEOUL_LATITUDE_RANGE("STO010", "서비스 지역(서울)을 벗어난 위도 값입니다."),
OUT_OF_SEOUL_LONGITUDE_RANGE("STO011", "서비스 지역(서울)을 벗어난 경도 값입니다."),
STORE_NOT_FOUND("ST0012", "해당 가게 정보를 찾을수 없습니다."),

// Cheer
INVALID_CHEER_DESCRIPTION("CHE001", "응원 메시지는 필수입니다."),
Expand All @@ -40,7 +41,17 @@ public enum BusinessErrorCode {
FILE_UPLOAD_FAILED("SERVER002", "파일 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
FILE_URL_GENERATION_FAILED("SERVER003", "파일 URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
PRESIGNED_URL_GENERATION_FAILED("SERVER004", "Presigned URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
;

//story
INVALID_STORY_DESCRIPTION("STY001", "스토리 본문은 필수입니다."),
INVALID_STORY_IMAGE_KEY("STY002", "스토리 이미지 Key는 필수입니다."),
STORY_MEMBER_REQUIRED("STY003", "스토리 작성 시 회원 정보는 필수입니다."),
STORY_STORE_REQUIRED("STY004", "스토리 작성 시 가게 정보는 필수입니다."),
STORY_NOT_FOUND("STY005", "스토리를 찾을 수 없습니다."),
INVALID_STORE_ID("STY006", "유효하지 않은 가게 ID입니다."),
INVALID_STORE_KAKAO_ID("STY007", "스토어 Kakao ID는 필수입니다."),
INVALID_STORE_NAME("STY008", "스토어 이름은 필수입니다."),
INVALID_STORE_ADDRESS("STY009", "스토어 주소는 필수입니다.");

private final String code;
private final String message;
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/eatda/exception/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.ClientAbortException;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
Expand All @@ -15,6 +16,7 @@
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
Expand Down Expand Up @@ -77,13 +79,15 @@ public ResponseEntity<ErrorResponse> handleMissingServletRequestParameterExcepti

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException exception) {
log.error("[BusinessException] handled: {}", exception.getErrorCode());
ErrorResponse response = new ErrorResponse(exception.getErrorCode());
return ResponseEntity.status(exception.getStatus())
.body(response);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception exception) {
log.error("[Unhandled Exception] {}: {}", exception.getClass().getSimpleName(), exception.getMessage(), exception);
return toErrorResponse(EtcErrorCode.INTERNAL_SERVER_ERROR);
}

Expand Down
19 changes: 19 additions & 0 deletions src/main/java/eatda/repository/story/StoryRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package eatda.repository.story;

import eatda.domain.story.Story;
import eatda.exception.BusinessErrorCode;
import eatda.exception.BusinessException;
import java.util.Optional;
import org.springframework.data.repository.Repository;

public interface StoryRepository extends Repository<Story, Long> {

Story save(Story story);

Optional<Story> findById(Long id);

default Story getById(Long id) {
return findById(id)
.orElseThrow(() -> new BusinessException(BusinessErrorCode.STORY_NOT_FOUND));
}
}
15 changes: 15 additions & 0 deletions src/main/java/eatda/service/common/ImageDomain.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package eatda.service.common;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ImageDomain {
ARTICLE("article"),
STORE("store"),
MEMBER("member"),
STORY("story");

private final String name;
}
7 changes: 4 additions & 3 deletions src/main/java/eatda/service/common/ImageService.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
public class ImageService {

private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png");
private static final String DEFAULT_CONTENT_TYPE = "bin";
private static final String PATH_DELIMITER = "/";
private static final String EXTENSION_DELIMITER = ".";
private static final Duration PRESIGNED_URL_DURATION = Duration.ofMinutes(30);
Expand All @@ -37,11 +38,11 @@ public ImageService(
this.s3Presigner = s3Presigner;
}

public String upload(MultipartFile file, String domain) {
public String upload(MultipartFile file, ImageDomain domain) {
validateContentType(file);
String extension = getExtension(file.getOriginalFilename());
String uuid = UUID.randomUUID().toString();
String key = domain + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension;
String key = domain.getName() + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension;

try {
PutObjectRequest request = PutObjectRequest.builder()
Expand All @@ -66,7 +67,7 @@ private void validateContentType(MultipartFile file) {

private String getExtension(String filename) {
if (filename == null || filename.lastIndexOf(EXTENSION_DELIMITER) == -1 || filename.startsWith(EXTENSION_DELIMITER)) {
return "bin";
return DEFAULT_CONTENT_TYPE;
}
return filename.substring(filename.lastIndexOf(EXTENSION_DELIMITER) + 1);
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/eatda/service/store/StoreService.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ 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