diff --git a/.gitignore b/.gitignore index 673084a..8e18d97 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +application.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index dff8508..5dcf9cc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,7 @@ plugins { id 'java' + id 'org.springframework.boot' version '3.3.4' + id 'io.spring.dependency-management' version '1.1.6' } group = 'org.sopt' @@ -9,9 +11,30 @@ repositories { mavenCentral() } +java { + toolchain { + languageVersion=JavaLanguageVersion.of(17) + } +} + dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.h2database:h2' + testImplementation platform('org.junit:junit-bom:5.9.1') testImplementation 'org.junit.jupiter:junit-jupiter' + + //Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' } test { diff --git a/src/main/java/org/sopt/diary/DiaryApplication.java b/src/main/java/org/sopt/diary/DiaryApplication.java new file mode 100644 index 0000000..5aab625 --- /dev/null +++ b/src/main/java/org/sopt/diary/DiaryApplication.java @@ -0,0 +1,11 @@ +package org.sopt.diary; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DiaryApplication { + public static void main(String[] args) { + SpringApplication.run(DiaryApplication.class, args); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/diary/Repository/DiaryRepository.java b/src/main/java/org/sopt/diary/Repository/DiaryRepository.java new file mode 100644 index 0000000..61d598c --- /dev/null +++ b/src/main/java/org/sopt/diary/Repository/DiaryRepository.java @@ -0,0 +1,10 @@ +package org.sopt.diary.Repository; + + +import org.sopt.diary.domain.Diary; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface DiaryRepository extends JpaRepository { + List findTop10ByOrderByCreatedAtDesc(); +} diff --git a/src/main/java/org/sopt/diary/advice/GlobalExceptionHandler.java b/src/main/java/org/sopt/diary/advice/GlobalExceptionHandler.java new file mode 100644 index 0000000..87f39e7 --- /dev/null +++ b/src/main/java/org/sopt/diary/advice/GlobalExceptionHandler.java @@ -0,0 +1,39 @@ +package org.sopt.diary.advice; + +import org.sopt.diary.dto.common.ResponseDto; +import org.sopt.diary.exception.NotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity> handleNotFoundException(NotFoundException e) { + return ResponseEntity + .status(e.getErrorCode().getHttpStatus()) + .body(ResponseDto.fail(e.getErrorCode().getCode(),e.getErrorCode().getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidationExceptions(MethodArgumentNotValidException e) { + Map errors = new HashMap<>(); + + e.getBindingResult().getFieldErrors().forEach((FieldError error) -> { + String fieldName = error.getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ResponseDto.failValidate(errors)); + } +} diff --git a/src/main/java/org/sopt/diary/controller/DiaryController.java b/src/main/java/org/sopt/diary/controller/DiaryController.java new file mode 100644 index 0000000..5d41811 --- /dev/null +++ b/src/main/java/org/sopt/diary/controller/DiaryController.java @@ -0,0 +1,65 @@ +package org.sopt.diary.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.sopt.diary.dto.request.DiaryDetailsDto; +import org.sopt.diary.dto.request.DiaryCreateDto; +import org.sopt.diary.dto.request.DiaryUpdateDto; +import org.sopt.diary.dto.response.DiaryListResponse; +import org.sopt.diary.service.DiaryService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class DiaryController { + + private final DiaryService diaryService; + + // 일기 작성 + @PostMapping("/diary") + public ResponseEntity createDiary( + @RequestBody @Valid final DiaryCreateDto diaryCreateDto + ) { + return ResponseEntity.created(URI.create( + diaryService.createDiary(diaryCreateDto).getId().toString() + )).build(); + } + + // 일기 상세 조회 + @GetMapping("/diary/{diaryId}") + public ResponseEntity getDiary( + @PathVariable final Long diaryId + ){ + return ResponseEntity.ok(diaryService.getDiaryDetails(diaryId)); + } + + // 일기 목록 조회 + @GetMapping("/diary") + public ResponseEntity getDiaryList( + ){ + return ResponseEntity.ok(diaryService.getDiaryList()); + } + + // 일기 수정 + @PatchMapping("/diary/{diaryId}") + public ResponseEntity updateDiary( + @PathVariable final Long diaryId, + @RequestBody @Valid final DiaryUpdateDto diaryUpdateDto + ){ + diaryService.updateDiary(diaryId, diaryUpdateDto); + return ResponseEntity.noContent().build(); + } + + // 일기 제거 + @DeleteMapping("/diary/{diaryId}") + public ResponseEntity deleteDiary( + @PathVariable final Long diaryId + ) { + diaryService.removeDiary(diaryId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/org/sopt/diary/domain/Diary.java b/src/main/java/org/sopt/diary/domain/Diary.java new file mode 100644 index 0000000..ac2e6b7 --- /dev/null +++ b/src/main/java/org/sopt/diary/domain/Diary.java @@ -0,0 +1,35 @@ +package org.sopt.diary.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Diary { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + public Long id; + + public String title; + + public LocalDateTime createdAt; + + public String content; + + @Builder + private Diary(Long id, String title, String content, LocalDateTime createdAt){ + this.id = id; + this.title = title; + this.content = content; + this.createdAt = LocalDateTime.now(); + } + + public void updateDiary(String content){ + this.content = content; + } +} diff --git a/src/main/java/org/sopt/diary/dto/common/ResponseDto.java b/src/main/java/org/sopt/diary/dto/common/ResponseDto.java new file mode 100644 index 0000000..7b62ab8 --- /dev/null +++ b/src/main/java/org/sopt/diary/dto/common/ResponseDto.java @@ -0,0 +1,22 @@ +package org.sopt.diary.dto.common; + +public record ResponseDto ( + String code, + T data, + String message +) { + public static ResponseDto fail(String code, String message) { + return new ResponseDto<>(code, null, message); + } + + // Validation 실패 시 응답 형식 + public static ResponseDto failValidate(final T data) { + return new ResponseDto<>("fail", data, null); + } + + // 후에 아래처럼 success 에 대해서도 공통된 응답 형식을 반환할 수 있도록 하면 좋은 코드가 될 것 같다. + public static ResponseDto success(final T data) { + return new ResponseDto<>("success", data, null); + } + +} diff --git a/src/main/java/org/sopt/diary/dto/request/DiaryCreateDto.java b/src/main/java/org/sopt/diary/dto/request/DiaryCreateDto.java new file mode 100644 index 0000000..3d5f190 --- /dev/null +++ b/src/main/java/org/sopt/diary/dto/request/DiaryCreateDto.java @@ -0,0 +1,19 @@ +package org.sopt.diary.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public record DiaryCreateDto( + Long id, + + @NotNull(message = "제목을 입력해주세요.") + @Size(max = 10, message = "일기의 제목은 10자 이내여야 합니다.") + String title, + + @Size(max = 30, message = "일기의 내용은 30자 이내여야 합니다.") + String content, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/org/sopt/diary/dto/request/DiaryDetailsDto.java b/src/main/java/org/sopt/diary/dto/request/DiaryDetailsDto.java new file mode 100644 index 0000000..8f9a640 --- /dev/null +++ b/src/main/java/org/sopt/diary/dto/request/DiaryDetailsDto.java @@ -0,0 +1,14 @@ +package org.sopt.diary.dto.request; + +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record DiaryDetailsDto( + Long id, + String title, + String content, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/org/sopt/diary/dto/request/DiaryUpdateDto.java b/src/main/java/org/sopt/diary/dto/request/DiaryUpdateDto.java new file mode 100644 index 0000000..56c0975 --- /dev/null +++ b/src/main/java/org/sopt/diary/dto/request/DiaryUpdateDto.java @@ -0,0 +1,11 @@ +package org.sopt.diary.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +public record DiaryUpdateDto( + @Size(max = 30, message = "일기의 내용은 30자 이내여야 합니다.") + String content +) { +} \ No newline at end of file diff --git a/src/main/java/org/sopt/diary/dto/response/DiaryListResponse.java b/src/main/java/org/sopt/diary/dto/response/DiaryListResponse.java new file mode 100644 index 0000000..30b5f26 --- /dev/null +++ b/src/main/java/org/sopt/diary/dto/response/DiaryListResponse.java @@ -0,0 +1,18 @@ +package org.sopt.diary.dto.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record DiaryListResponse( + List diaryLists +) { + + @Builder + public record DiaryDto( + Long id, + String title + ){ + } +} diff --git a/src/main/java/org/sopt/diary/exception/ErrorCode.java b/src/main/java/org/sopt/diary/exception/ErrorCode.java new file mode 100644 index 0000000..944bba2 --- /dev/null +++ b/src/main/java/org/sopt/diary/exception/ErrorCode.java @@ -0,0 +1,18 @@ +package org.sopt.diary.exception; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode{ + NOT_FOUND_DIARY(HttpStatus.NOT_FOUND,"error","존재하지 않는 Diary 입니다."), + ; + + @JsonIgnore + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/org/sopt/diary/exception/NotFoundException.java b/src/main/java/org/sopt/diary/exception/NotFoundException.java new file mode 100644 index 0000000..034ce76 --- /dev/null +++ b/src/main/java/org/sopt/diary/exception/NotFoundException.java @@ -0,0 +1,10 @@ +package org.sopt.diary.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class NotFoundException extends RuntimeException { + private final ErrorCode errorCode; +} diff --git a/src/main/java/org/sopt/diary/service/DiaryService.java b/src/main/java/org/sopt/diary/service/DiaryService.java new file mode 100644 index 0000000..37f32af --- /dev/null +++ b/src/main/java/org/sopt/diary/service/DiaryService.java @@ -0,0 +1,72 @@ +package org.sopt.diary.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.diary.Repository.DiaryRepository; +import org.sopt.diary.domain.Diary; +import org.sopt.diary.dto.request.DiaryCreateDto; +import org.sopt.diary.dto.request.DiaryDetailsDto; +import org.sopt.diary.dto.request.DiaryUpdateDto; +import org.sopt.diary.dto.response.DiaryListResponse; +import org.sopt.diary.exception.ErrorCode; +import org.sopt.diary.exception.NotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DiaryService { + + private final DiaryRepository diaryRepository; + + @Transactional + public Diary createDiary(DiaryCreateDto diaryCreateDto){ + Diary diary = Diary.builder() + .title(diaryCreateDto.title()) + .content(diaryCreateDto.content()) + .build(); + return diaryRepository.save(diary); + } + + private Diary findById(final Long diaryId){ + return diaryRepository.findById(diaryId).orElseThrow( + () -> new NotFoundException(ErrorCode.NOT_FOUND_DIARY) + ); + } + + public DiaryDetailsDto getDiaryDetails(final Long diaryId){ + Diary diary = findById(diaryId); + + return DiaryDetailsDto.builder() + .id(diary.getId()) + .title(diary.getTitle()) + .content(diary.getContent()) + .createdAt(diary.getCreatedAt()) + .build(); + } + + public DiaryListResponse getDiaryList(){ + List diaryItems = diaryRepository.findTop10ByOrderByCreatedAtDesc() + .stream().map( + diary -> DiaryListResponse.DiaryDto.builder() + .id(diary.getId()) + .title(diary.getTitle()) + .build() + ).toList(); + return DiaryListResponse.builder().diaryLists(diaryItems).build(); + } + + @Transactional + public void updateDiary(final Long diaryId, DiaryUpdateDto diaryUpdateDto){ + Diary diary = findById(diaryId); + diary.updateDiary(diaryUpdateDto.content()); + } + + @Transactional + public void removeDiary(final Long diaryId){ + Diary diary = findById(diaryId); + diaryRepository.delete(diary); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/week1/DiaryRepository.java b/src/main/java/org/sopt/week1/DiaryRepository.java index 57067ef..db29ad0 100644 --- a/src/main/java/org/sopt/week1/DiaryRepository.java +++ b/src/main/java/org/sopt/week1/DiaryRepository.java @@ -14,12 +14,9 @@ public class DiaryRepository { private final Map deletedStorage = new ConcurrentHashMap<>(); private final Map patchCount = new ConcurrentHashMap<>(); //수정 횟수 private final Map patchDate = new ConcurrentHashMap<>(); // 마지막으로 수정한 날짜 - -public class DiaryRepository { - private final Map storage = new ConcurrentHashMap<>(); private final AtomicLong numbering = new AtomicLong(); - void save(final Diary diary){ + void save(final Diary diary) { // 채번 과정(1을 더한 값을 반환) final long id = numbering.addAndGet(1); @@ -39,7 +36,7 @@ List findAll() { final String body = storage.get(index); // (2-1) 불러온 값을 구성한 자료구조로 이관 - if(body != null) { + if (body != null) { diaryList.add(new Diary(index, body)); } } @@ -50,7 +47,7 @@ List findAll() { void delete(final Long id) { String removedDiary = storage.remove(id); - if(removedDiary != null){ + if (removedDiary != null) { deletedStorage.put(id, removedDiary); patchCount.remove(id); // 삭제 시 수정 횟수 제거 patchDate.remove(id); // 삭제 시 날짜 제거 @@ -61,14 +58,14 @@ void patch(final Long id, final String body) { LocalDate today = LocalDate.now(); LocalDate last = patchDate.getOrDefault(id, today); - if(!today.equals(last)){ + if (!today.equals(last)) { patchCount.put(id, 0); patchDate.put(id, today); } int count = patchCount.getOrDefault(id, 0); - if(count >= 2){ + if (count >= 2) { throw new LimitEditException(); } storage.replace(id, body); @@ -86,21 +83,11 @@ void restore(final Long id) { storage.remove(id); } - void patch(final Long id, final String body) { - /* - replace() : key 가 존재할 때에만 값을 변경 - put() : key 가 존재하지 않으면 새로운 key-value 쌍을 추가 - */ - storage.replace(id, body); - - } - - boolean existById(final Long id){ + boolean existById(final Long id) { return storage.containsKey(id); } - - boolean existByDeletedId(final Long id){ + boolean existByDeletedId(final Long id) { return deletedStorage.containsKey(id); } } diff --git a/src/main/java/org/sopt/week1/DiaryValidator.java b/src/main/java/org/sopt/week1/DiaryValidator.java index e45f161..6d4f4f8 100644 --- a/src/main/java/org/sopt/week1/DiaryValidator.java +++ b/src/main/java/org/sopt/week1/DiaryValidator.java @@ -11,7 +11,7 @@ public static void validate(final String body){ if(body.trim().isEmpty()){ throw new InvalidInputException(); } - if(body.length() > 30){ + if(countGraphemeClusters(body) > 30){ throw new DiaryBodyLengthException(); } }