diff --git a/build.gradle b/build.gradle index df75ad5..168305e 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,19 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + + /* Email */ + implementation 'org.springframework.boot:spring-boot-starter-mail' + + /* Thymeleaf */ + implementation'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' + + /* Redis */ + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + /* Swagger */ + implementation group: 'io.swagger.core.v3', name: 'swagger-core-jakarta', version: '2.2.7' } tasks.named('bootBuildImage') { diff --git a/src/main/java/com/api/readinglog/common/aws/AmazonS3Service.java b/src/main/java/com/api/readinglog/common/aws/AmazonS3Service.java index 43f2a0d..59ff934 100644 --- a/src/main/java/com/api/readinglog/common/aws/AmazonS3Service.java +++ b/src/main/java/com/api/readinglog/common/aws/AmazonS3Service.java @@ -7,9 +7,12 @@ import com.amazonaws.services.s3.model.PutObjectRequest; import com.api.readinglog.common.exception.ErrorCode; import com.api.readinglog.common.exception.custom.AwsS3Exception; +import com.api.readinglog.common.image.ImageUtil; import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.UUID; +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -26,61 +29,52 @@ public class AmazonS3Service { @Value("${cloud.aws.s3.bucket}") private String bucket; - public String uploadFile(MultipartFile profileImg) { - String fileName = generateFileName(profileImg.getOriginalFilename()); + @Getter + @Value("${cloud.aws.s3.default.profile.image}") + private String defaultProfileImg; + + public String uploadFile(MultipartFile file, DomainType type) { + + String ext = ImageUtil.getExt(file.getOriginalFilename()); // 확장자 + String fileName = UUID.randomUUID() + "." + ext; // 파일 이름 + 확장자 + String imageFilePath = generateFilePath(fileName, type); // 타입/날짜/파일이름 try { ObjectMetadata metadata = new ObjectMetadata(); - metadata.setContentLength(profileImg.getSize()); - metadata.setContentType(profileImg.getContentType()); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); - uploadToS3(bucket, fileName, profileImg, metadata); - return fileName; + uploadToS3(bucket, imageFilePath, file, metadata); + return imageFilePath; } catch (IOException e) { throw new AwsS3Exception(ErrorCode.AWS_S3_FILE_UPLOAD_FAIL); } } - // TODO: 회원, 책 이미지 업로드 시 공통으로 사용할 수 있게 리팩토링 필요함. - public String uploadBookCover(MultipartFile profileImg) { - String currentDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - String fileName = String.format("books/%s/%s", currentDate, profileImg.getOriginalFilename()); - - try { - ObjectMetadata metadata = new ObjectMetadata(); - metadata.setContentLength(profileImg.getSize()); - metadata.setContentType(profileImg.getContentType()); - - uploadToS3(bucket, fileName, profileImg, metadata); - return fileName; - - } catch (IOException e) { - throw new AwsS3Exception(ErrorCode.AWS_S3_FILE_UPLOAD_FAIL); - } + private void uploadToS3(String bucket, String filePath, MultipartFile file, ObjectMetadata metadata) + throws IOException { + s3Client.putObject(new PutObjectRequest(bucket, filePath, file.getInputStream(), metadata)); } - public void deleteFile(String fileName) { + public void deleteFile(String filePath) { try { - s3Client.deleteObject(new DeleteObjectRequest(bucket, fileName)); - log.debug("삭제한 이미지 파일 이름: {}", fileName); + s3Client.deleteObject(new DeleteObjectRequest(bucket, filePath)); + log.debug("삭제한 이미지 파일의 경로: {}", filePath); } catch (AmazonServiceException e) { throw new AwsS3Exception(ErrorCode.AWS_S3_FILE_DELETE_FAIL); } } - public String getFileUrl(String fileName) { - return s3Client.getUrl(bucket, fileName).toString(); - } - - private String generateFileName(String originalFileName) { - String currentDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - return String.format("members/%s/%s", currentDate, originalFileName); + // [type]/[yyyy_MM_dd]/[파일 이름] 형식의 경로 반환 메서드 + private String generateFilePath(String fileName, DomainType type) { + String currentDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy_MM_dd")); + return String.format("%s/%s/%s", type.getType(), currentDate, fileName); } - private void uploadToS3(String bucket, String fileName, MultipartFile file, ObjectMetadata metadata) - throws IOException { - s3Client.putObject(new PutObjectRequest(bucket, fileName, file.getInputStream(), metadata)); + // 전체 이미지 주소 반환 메서드 + public String getFileUrl(String fileName) { + return s3Client.getUrl(bucket, fileName).toString(); } } diff --git a/src/main/java/com/api/readinglog/common/aws/DomainType.java b/src/main/java/com/api/readinglog/common/aws/DomainType.java new file mode 100644 index 0000000..22c609b --- /dev/null +++ b/src/main/java/com/api/readinglog/common/aws/DomainType.java @@ -0,0 +1,13 @@ +package com.api.readinglog.common.aws; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum DomainType { + + MEMBERS("members"), BOOK("books"); + + private final String type; +} diff --git a/src/main/java/com/api/readinglog/common/exception/ErrorCode.java b/src/main/java/com/api/readinglog/common/exception/ErrorCode.java index 100d506..3ed2706 100644 --- a/src/main/java/com/api/readinglog/common/exception/ErrorCode.java +++ b/src/main/java/com/api/readinglog/common/exception/ErrorCode.java @@ -10,6 +10,9 @@ public enum ErrorCode { // 400 PASSWORD_MISMATCH("비밀번호가 일치하지 않습니다.", HttpStatus.BAD_REQUEST), + INVALID_CURRENT_PASSWORD("현재 비밀번호와 일치하지 않습니다.", HttpStatus.BAD_REQUEST), + INVALID_AUTH_CODE("이메일 인증에 실패했습니다.", HttpStatus.BAD_REQUEST), + NOT_FOUND_REFRESH_TOKEN_IN_COOKIE("리프레시 토큰이 쿠키에 없습니다.", HttpStatus.BAD_REQUEST), // 401 UNAUTHORIZED_LOGIN("로그인 실패: 인증에 실패하였습니다.", HttpStatus.UNAUTHORIZED), @@ -32,10 +35,12 @@ public enum ErrorCode { NOT_FOUND_MEMBER("회원이 존재하지 않습니다!", HttpStatus.NOT_FOUND), NOT_FOUND_SEARCH("검색 결과가 존재하지 않습니다!", HttpStatus.NOT_FOUND), NOT_FOUND_BOOK("등록된 책이 존재하지 않습니다!", HttpStatus.NOT_FOUND), - NOT_FOUND_HIGHLIGHT("등록된 책이 존재하지 않습니다!", HttpStatus.NOT_FOUND), + NOT_FOUND_HIGHLIGHT("등록된 하이라이트가 존재하지 않습니다!", HttpStatus.NOT_FOUND), NOT_FOUND_SUMMARY("등록된 한줄평이 존재하지 않습니다!", HttpStatus.NOT_FOUND), NOT_FOUND_REVIEW("서평이 존재하지 않습니다!", HttpStatus.NOT_FOUND), + NOT_FOUND_RECORD("독서 기록이 존재하지 않습니다!", HttpStatus.NOT_FOUND), NOT_FOUND_FEED("피드 목록이 존재하지 않습니다!", HttpStatus.NOT_FOUND), + NOT_FOUND_BOOK_LOGS("북로그 목록이 존재하지 않습니다!", HttpStatus.NOT_FOUND), // 409 MEMBER_ALREADY_EXISTS("이미 존재하는 회원입니다.", HttpStatus.CONFLICT), @@ -45,7 +50,8 @@ public enum ErrorCode { // 500 INTERNAL_SERVER_ERROR("서버 에러 발생!", HttpStatus.INTERNAL_SERVER_ERROR), AWS_S3_FILE_UPLOAD_FAIL("AWS S3 파일 업로드 실패", HttpStatus.INTERNAL_SERVER_ERROR), - AWS_S3_FILE_DELETE_FAIL("AWS S3 파일 삭제 실패", HttpStatus.INTERNAL_SERVER_ERROR),; + AWS_S3_FILE_DELETE_FAIL("AWS S3 파일 삭제 실패", HttpStatus.INTERNAL_SERVER_ERROR), + EMAIL_SEND_FAILED("이메일 발송 실패", HttpStatus.INTERNAL_SERVER_ERROR); private final String message; private final HttpStatus status; diff --git a/src/main/java/com/api/readinglog/common/exception/custom/EmailException.java b/src/main/java/com/api/readinglog/common/exception/custom/EmailException.java new file mode 100644 index 0000000..26539e1 --- /dev/null +++ b/src/main/java/com/api/readinglog/common/exception/custom/EmailException.java @@ -0,0 +1,11 @@ +package com.api.readinglog.common.exception.custom; + +import com.api.readinglog.common.exception.CustomException; +import com.api.readinglog.common.exception.ErrorCode; + +public class EmailException extends CustomException { + + public EmailException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/api/readinglog/common/exception/custom/RecordException.java b/src/main/java/com/api/readinglog/common/exception/custom/RecordException.java new file mode 100644 index 0000000..4b3f619 --- /dev/null +++ b/src/main/java/com/api/readinglog/common/exception/custom/RecordException.java @@ -0,0 +1,10 @@ +package com.api.readinglog.common.exception.custom; + +import com.api.readinglog.common.exception.CustomException; +import com.api.readinglog.common.exception.ErrorCode; + +public class RecordException extends CustomException { + public RecordException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/api/readinglog/common/image/ImageUtil.java b/src/main/java/com/api/readinglog/common/image/ImageUtil.java new file mode 100644 index 0000000..8268ead --- /dev/null +++ b/src/main/java/com/api/readinglog/common/image/ImageUtil.java @@ -0,0 +1,21 @@ +package com.api.readinglog.common.image; + +import java.util.Optional; +import org.springframework.web.multipart.MultipartFile; + +public class ImageUtil { + + // 파일의 확장자를 반환해주는 메서드 + public static String getExt(String fileName) { + return Optional.ofNullable(fileName) + .filter(f -> f.contains(".")) + .map(f -> f.substring(fileName.lastIndexOf(".") + 1)) + .orElse(""); + } + + // 이미지 존재 여부 확인 + public static boolean isNotEmptyImageFile(MultipartFile file) { + return !(file == null || file.isEmpty()); + } + +} diff --git a/src/main/java/com/api/readinglog/common/redis/config/RedisConfig.java b/src/main/java/com/api/readinglog/common/redis/config/RedisConfig.java new file mode 100644 index 0000000..37192e9 --- /dev/null +++ b/src/main/java/com/api/readinglog/common/redis/config/RedisConfig.java @@ -0,0 +1,36 @@ +package com.api.readinglog.common.redis.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${spring.redis.host}") + private String redisHost; + + @Value("${spring.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return redisTemplate; + } +} diff --git a/src/main/java/com/api/readinglog/common/redis/service/RedisService.java b/src/main/java/com/api/readinglog/common/redis/service/RedisService.java new file mode 100644 index 0000000..7f7e936 --- /dev/null +++ b/src/main/java/com/api/readinglog/common/redis/service/RedisService.java @@ -0,0 +1,34 @@ +package com.api.readinglog.common.redis.service; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor + +public class RedisService { + + private final RedisTemplate redisTemplate; + + + public void setData(String key, Object value, Long time, TimeUnit timeUnit) { + redisTemplate.opsForValue().set(key, value.toString(), time, timeUnit); + } + + public Object getData(String key) { + return redisTemplate.opsForValue().get(key); + } + + + public void deleteData(String key) { + redisTemplate.delete(key); + } + + + public void increaseData(String key) { + redisTemplate.opsForValue().increment(key); + } + +} diff --git a/src/main/java/com/api/readinglog/common/security/util/CookieUtils.java b/src/main/java/com/api/readinglog/common/security/util/CookieUtils.java index 852eba6..a863f07 100644 --- a/src/main/java/com/api/readinglog/common/security/util/CookieUtils.java +++ b/src/main/java/com/api/readinglog/common/security/util/CookieUtils.java @@ -1,27 +1,15 @@ package com.api.readinglog.common.security.util; +import com.api.readinglog.common.exception.ErrorCode; +import com.api.readinglog.common.exception.custom.JwtException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.Base64; -import java.util.Optional; import org.springframework.util.SerializationUtils; public class CookieUtils { - public static Optional getCookie(HttpServletRequest request, String name) { - Cookie[] cookies = request.getCookies(); - - if (cookies != null && cookies.length > 0) { - for (Cookie cookie : cookies) { - if (name.equals(cookie.getName())) { - return Optional.of(cookie); - } - } - } - return Optional.empty(); - } - public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { Cookie cookie = new Cookie(name, value); cookie.setPath("/"); @@ -31,19 +19,16 @@ public static void addCookie(HttpServletResponse response, String name, String v response.addCookie(cookie); } - public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) { + public static String extractRefreshToken(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); - - if (cookies != null && cookies.length > 0) { + if (cookies != null) { for (Cookie cookie : cookies) { - if (name.equals(cookie.getName())) { - cookie.setValue(""); - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); + if (cookie.getName().equals("refreshToken")) { + return cookie.getValue(); } } } + throw new JwtException(ErrorCode.NOT_FOUND_REFRESH_TOKEN_IN_COOKIE); } public static String serialize(Object obj) { diff --git a/src/main/java/com/api/readinglog/common/swagger/SwaggerConfig.java b/src/main/java/com/api/readinglog/common/swagger/SwaggerConfig.java new file mode 100644 index 0000000..014d687 --- /dev/null +++ b/src/main/java/com/api/readinglog/common/swagger/SwaggerConfig.java @@ -0,0 +1,45 @@ +package com.api.readinglog.common.swagger; + + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import java.util.Arrays; +import java.util.Collections; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@OpenAPIDefinition( + servers = {@Server(url = "/")}, + info = @Info( + title = "리딩로그 API 명세서", + description = "독서 기록 서비스 리딩로그의 API 명세서", + version = "v1.0") +) +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI(){ + String securityRequirementName = "Bearer를 제외한 accessToken값을 넣어주세요."; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(securityRequirementName); + + Components components = new Components() + .addSecuritySchemes(securityRequirementName, new SecurityScheme() + .name(securityRequirementName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .components(components) + .addSecurityItem(securityRequirement); + } +} diff --git a/src/main/java/com/api/readinglog/domain/book/controller/BookController.java b/src/main/java/com/api/readinglog/domain/book/controller/BookController.java index a7dfd7a..194ae4e 100644 --- a/src/main/java/com/api/readinglog/domain/book/controller/BookController.java +++ b/src/main/java/com/api/readinglog/domain/book/controller/BookController.java @@ -8,6 +8,8 @@ import com.api.readinglog.domain.book.dto.BookSearchApiResponse; import com.api.readinglog.domain.book.dto.BookModifyRequest; import com.api.readinglog.domain.book.service.BookService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,20 +27,23 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@RestController +@Tag(name = "Book", description = "Book API") @Slf4j +@RestController @RequestMapping("/api/books") @RequiredArgsConstructor public class BookController { private final BookService bookService; + @Operation(summary = "Find book by ID", description = "사용자가 등록한 책 정보 조회") @GetMapping("/{bookId}") public Response getBookInfo(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long bookId) { return Response.success(HttpStatus.OK, String.format("%d번 책 정보 응답 성공", bookId), bookService.getBookInfo(user.getId(), bookId)); } + @Operation(summary = "Search book", description = "책 검색") @GetMapping("/search") public Response searchBooks(@RequestParam(required = false) String q, @RequestParam(defaultValue = "1") int start) { @@ -46,6 +51,7 @@ public Response searchBooks(@RequestParam(required = fals return Response.success(HttpStatus.OK, "책 검색 성공", bookService.searchBooks(q, start)); } + @Operation(summary = "Add a new book after search", description = "책 검색 후 등록") @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public Response registerBookAfterSearch(@AuthenticationPrincipal CustomUserDetail user, @RequestBody @Valid BookRegisterRequest request) { @@ -54,6 +60,7 @@ public Response registerBookAfterSearch(@AuthenticationPrincipal CustomUse return Response.success(HttpStatus.CREATED, "책 등록 성공"); } + @Operation(summary = "Add a new book direct registration", description = "책 직접 등록") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Response registerBookDirect(@AuthenticationPrincipal CustomUserDetail user, @ModelAttribute @Valid BookDirectRequest request) { @@ -62,6 +69,7 @@ public Response registerBookDirect(@AuthenticationPrincipal CustomUserDeta return Response.success(HttpStatus.CREATED, "책 등록 성공"); } + @Operation(summary = "Modify book info", description = "책 정보 수정") @PatchMapping("/{bookId}") public Response modifyBook(@AuthenticationPrincipal CustomUserDetail user, @ModelAttribute BookModifyRequest bookModifyRequest, @@ -71,6 +79,7 @@ public Response modifyBook(@AuthenticationPrincipal CustomUserDetail user, return Response.success(HttpStatus.OK, "책 수정 성공"); } + @Operation(summary = "Delete book", description = "책 삭제") @DeleteMapping("/{bookId}") public Response deleteBook(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long bookId) { // TODO: 책이 삭제될 때 해당 책에 관한 기록, 포스트 함께 삭제? diff --git a/src/main/java/com/api/readinglog/domain/book/dto/BookSearchApiResponse.java b/src/main/java/com/api/readinglog/domain/book/dto/BookSearchApiResponse.java index 2ccffdc..3a39de5 100644 --- a/src/main/java/com/api/readinglog/domain/book/dto/BookSearchApiResponse.java +++ b/src/main/java/com/api/readinglog/domain/book/dto/BookSearchApiResponse.java @@ -9,7 +9,6 @@ @Setter public class BookSearchApiResponse { - // TODO: 무한 스크롤 구현에 필요한 데이터 추가 private int totalResults; // 전체 데이터 개수 private int startIndex; // 시작 페이지 private int itemsPerPage; // 페이지당 아이템 개수 diff --git a/src/main/java/com/api/readinglog/domain/book/entity/Book.java b/src/main/java/com/api/readinglog/domain/book/entity/Book.java index fc82d82..7a180f6 100644 --- a/src/main/java/com/api/readinglog/domain/book/entity/Book.java +++ b/src/main/java/com/api/readinglog/domain/book/entity/Book.java @@ -4,7 +4,13 @@ import com.api.readinglog.domain.book.dto.BookDirectRequest; import com.api.readinglog.domain.book.dto.BookModifyRequest; import com.api.readinglog.domain.book.dto.BookRegisterRequest; +import com.api.readinglog.domain.highlight.entity.Highlight; +import com.api.readinglog.domain.highlight.entity.Highlight; import com.api.readinglog.domain.member.entity.Member; +import com.api.readinglog.domain.record.entity.Record; +import com.api.readinglog.domain.review.entity.Review; +import com.api.readinglog.domain.summary.entity.Summary; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -13,6 +19,9 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -54,6 +63,18 @@ public class Book extends BaseTimeEntity { @Column(name = "book_cover") private String cover; + @OneToMany(mappedBy = "book", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List summaryList = new ArrayList<>(); + + @OneToMany(mappedBy = "book", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List HighlightList = new ArrayList<>(); + + @OneToMany(mappedBy = "book", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List reviewList = new ArrayList<>(); + + @OneToMany(mappedBy = "book", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List recordList = new ArrayList<>(); + @Builder private Book(Member member, Integer itemId, String title, String author, String publisher, String category, String cover) { this.member = member; diff --git a/src/main/java/com/api/readinglog/domain/book/service/BookService.java b/src/main/java/com/api/readinglog/domain/book/service/BookService.java index 2c1f92d..3adca68 100644 --- a/src/main/java/com/api/readinglog/domain/book/service/BookService.java +++ b/src/main/java/com/api/readinglog/domain/book/service/BookService.java @@ -1,9 +1,11 @@ package com.api.readinglog.domain.book.service; import com.api.readinglog.common.aws.AmazonS3Service; +import com.api.readinglog.common.aws.DomainType; import com.api.readinglog.common.exception.ErrorCode; import com.api.readinglog.common.exception.custom.BookException; import com.api.readinglog.common.exception.custom.MemberException; +import com.api.readinglog.common.image.ImageUtil; import com.api.readinglog.domain.book.dto.BookDetailResponse; import com.api.readinglog.domain.book.dto.BookDirectRequest; import com.api.readinglog.domain.book.dto.BookModifyRequest; @@ -17,7 +19,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; import org.springframework.web.reactive.function.client.WebClient; @Service @@ -51,15 +52,14 @@ public BookSearchApiResponse searchBooks(String query, int start) { throw new BookException(ErrorCode.EMPTY_SEARCH_KEYWORD); } - // TODO: 독서 기록이 있는 책인 경우 독서 기록도 응답에 포함시켜 전달. BookSearchApiResponse response = webClient.get() .uri(uriBuilder -> uriBuilder .path("/ItemSearch.aspx") - .queryParam("Query", query) // 검색어 - .queryParam("QueryType", "Keyword") // 제목 + 저자로 검색 + .queryParam("Query", query) + .queryParam("QueryType", "Title") // 제목으로 검색 .queryParam("SearchTarget", "Book") // 검색 대상: 도서 .queryParam("Start", start) // 시작 페이지: 1 - .queryParam("MaxResults", "20") // 페이지 당 검색 결과: 20개 + .queryParam("MaxResults", "10") // 페이지 당 검색 결과: 10개 .queryParam("Sort", "Accuracy") // 관련도순 정렬 .build() ) @@ -84,7 +84,7 @@ public void registerBookAfterSearch(Long memberId, BookRegisterRequest request) // 책 직접 등록 public void registerBookDirect(Long memberId, BookDirectRequest request) { Member member = memberService.getMemberById(memberId); - String cover = amazonS3Service.uploadBookCover(request.getCover()); + String cover = amazonS3Service.uploadFile(request.getCover(), DomainType.BOOK); bookRepository.save(Book.of(member, request, cover)); } @@ -93,12 +93,12 @@ public void modifyBook(Long memberId, Long bookId, BookModifyRequest bookModifyR Member member = memberService.getMemberById(memberId); Book book = getBookById(bookId); - // 파일이 존재하면 기존 이미지 삭제 후 새로운 이미지 업로드 String cover = book.getCover(); - MultipartFile coverImg = bookModifyRequest.getCover(); - if (!(coverImg == null || coverImg.isEmpty())) { + + // 파일이 존재하면 기존 이미지 삭제 후 새로운 이미지 업로드 + if (ImageUtil.isNotEmptyImageFile(bookModifyRequest.getCover())) { amazonS3Service.deleteFile(cover); - cover = amazonS3Service.uploadBookCover(bookModifyRequest.getCover()); + cover = amazonS3Service.uploadFile(bookModifyRequest.getCover(), DomainType.BOOK); } book.modify(bookModifyRequest, cover); diff --git a/src/main/java/com/api/readinglog/domain/booklog/controller/BookLogController.java b/src/main/java/com/api/readinglog/domain/booklog/controller/BookLogController.java new file mode 100644 index 0000000..9f0549f --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/booklog/controller/BookLogController.java @@ -0,0 +1,58 @@ +package com.api.readinglog.domain.booklog.controller; + +import com.api.readinglog.common.response.Response; +import com.api.readinglog.common.security.CustomUserDetail; +import com.api.readinglog.domain.booklog.controller.dto.BookLogResponse; +import com.api.readinglog.domain.booklog.service.BookLogService; +import com.api.readinglog.domain.summary.controller.dto.response.SummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "BookLogs", description = "북로그 API 목록입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/book-logs") +public class BookLogController { + + private final BookLogService bookLogService; + + @Operation(summary = "나의 로그 조회", description = "인증 토큰을 통해 특정 사용자가 작성한 로그 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "나의 로그 조회 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "나의 로그 조회 실패") + }) + @GetMapping("/{bookId}/me") + public Response myLogs(@AuthenticationPrincipal CustomUserDetail user, + @PathVariable Long bookId) { + return Response.success(HttpStatus.OK, "나의 로그 조회 성공", bookLogService.myLogs(user.getId(), bookId)); + } + + @Operation(summary = "북로그 조회", description = "리딩 로그 서비스의 모든 북로그를 조회합니다. 비회원도 조회가 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "북로그 조회 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "404", description = "북로그 목록이 존재하지 않습니다!") + }) + @GetMapping + public Response> bookLogs(@PageableDefault(sort = "createdAt", direction = Direction.DESC) + Pageable pageable) { + // TODO: querydsl 동적 쿼리 처리 + return Response.success(HttpStatus.OK, "북로그 조회 성공", bookLogService.bookLogs(pageable)); + } +} \ No newline at end of file diff --git a/src/main/java/com/api/readinglog/domain/booklog/controller/dto/BookLogResponse.java b/src/main/java/com/api/readinglog/domain/booklog/controller/dto/BookLogResponse.java new file mode 100644 index 0000000..c35ab5e --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/booklog/controller/dto/BookLogResponse.java @@ -0,0 +1,29 @@ +package com.api.readinglog.domain.booklog.controller.dto; + +import com.api.readinglog.domain.book.dto.BookDetailResponse; +import com.api.readinglog.domain.highlight.controller.dto.response.HighlightResponse; +import com.api.readinglog.domain.review.controller.dto.response.ReviewResponse; +import com.api.readinglog.domain.summary.controller.dto.response.MySummaryResponse; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class BookLogResponse { + + private final BookDetailResponse bookInfo; + private final MySummaryResponse summary; + private final List reviews; + private final List highlights; + + public static BookLogResponse of(BookDetailResponse bookInfo, MySummaryResponse summary, + List reviews, List highlights) { + return BookLogResponse.builder() + .bookInfo(bookInfo) + .summary(summary) + .reviews(reviews) + .highlights(highlights) + .build(); + } +} diff --git a/src/main/java/com/api/readinglog/domain/booklog/service/BookLogService.java b/src/main/java/com/api/readinglog/domain/booklog/service/BookLogService.java new file mode 100644 index 0000000..35e246e --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/booklog/service/BookLogService.java @@ -0,0 +1,92 @@ +package com.api.readinglog.domain.booklog.service; + +import com.api.readinglog.common.exception.ErrorCode; +import com.api.readinglog.common.exception.custom.SummaryException; +import com.api.readinglog.domain.book.dto.BookDetailResponse; +import com.api.readinglog.domain.book.entity.Book; +import com.api.readinglog.domain.book.service.BookService; +import com.api.readinglog.domain.booklog.controller.dto.BookLogResponse; +import com.api.readinglog.domain.highlight.controller.dto.response.HighlightResponse; +import com.api.readinglog.domain.highlight.repository.HighlightRepository; +import com.api.readinglog.domain.member.entity.Member; +import com.api.readinglog.domain.member.service.MemberService; +import com.api.readinglog.domain.review.controller.dto.response.ReviewResponse; +import com.api.readinglog.domain.review.repository.ReviewRepository; +import com.api.readinglog.domain.summary.controller.dto.response.MySummaryResponse; +import com.api.readinglog.domain.summary.controller.dto.response.SummaryResponse; +import com.api.readinglog.domain.summary.repository.SummaryRepository; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BookLogService { + + private final MemberService memberService; + private final BookService bookService; + private final SummaryRepository summaryRepository; + private final ReviewRepository reviewRepository; + private final HighlightRepository highlightRepository; + + @Transactional(readOnly = true) + public BookLogResponse myLogs(Long memberId, Long bookId) { + Member member = getMember(memberId); + Book book = getBook(bookId); + BookDetailResponse bookDetailResponse = bookService.getBookInfo(memberId, bookId); + + // 한 줄평은 반드시 존재 + MySummaryResponse summary = findSummaryResponse(member, book); + + // 서평과 하이라이트가 존재하지 않은 경우는 빈 리스트 반환 + List reviews = findReviewsResponse(member, book); + List highlights = findHighlightsResponse(member, book); + + return BookLogResponse.of(bookDetailResponse, summary, reviews, highlights); + } + + @Transactional(readOnly = true) + public Page bookLogs(Pageable pageable) { + Page bookLogs = summaryRepository.findAllBy(pageable).map(SummaryResponse::fromEntity); + + // 북로그가 존재하지 않는 경우 예외 처리 + if (bookLogs.getContent().isEmpty()) { + throw new SummaryException(ErrorCode.NOT_FOUND_BOOK_LOGS); + } + + return bookLogs; + } + + private Member getMember(Long memberId) { + return memberService.getMemberById(memberId); + } + + private Book getBook(Long bookId) { + return bookService.getBookById(bookId); + } + + private MySummaryResponse findSummaryResponse(Member member, Book book) { + return summaryRepository.findByMemberAndBook(member, book) + .map(MySummaryResponse::fromEntity) + .orElseThrow(() -> new SummaryException(ErrorCode.NOT_FOUND_SUMMARY)); // 한 줄평이 존재하지 않는 경우 예외 처리 + } + + private List findReviewsResponse(Member member, Book book) { + return reviewRepository.findAllByMemberAndBook(member, book) + .stream() + .map(ReviewResponse::fromEntity) + .collect(Collectors.toList()); + } + + private List findHighlightsResponse(Member member, Book book) { + return highlightRepository.findAllByMemberAndBook(member, book) + .stream() + .map(HighlightResponse::fromEntity) + .collect(Collectors.toList()); + } +} + diff --git a/src/main/java/com/api/readinglog/domain/email/config/EmailConfig.java b/src/main/java/com/api/readinglog/domain/email/config/EmailConfig.java new file mode 100644 index 0000000..af4aa77 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/email/config/EmailConfig.java @@ -0,0 +1,40 @@ +package com.api.readinglog.domain.email.config; + +import java.util.Properties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +public class EmailConfig { + @Value("${spring.mail.host}") + private String mailServerHost; + + @Value("${spring.mail.port}") + private String mailServerPort; + + @Value("${spring.mail.username}") + private String mailServerUsername; + + @Value("${spring.mail.password}") + private String mailServerPassword; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(mailServerHost); + mailSender.setPort(Integer.parseInt(mailServerPort)); + + mailSender.setUsername(mailServerUsername); + mailSender.setPassword(mailServerPassword); + + Properties properties = mailSender.getJavaMailProperties(); + properties.put("mail.transport.protocol", "smtp"); + properties.put("mail.smtp.auth", "true"); + properties.put("mail.smtp.starttls.enable", "true"); + + return mailSender; + } +} diff --git a/src/main/java/com/api/readinglog/domain/email/dto/AuthCodeVerificationRequest.java b/src/main/java/com/api/readinglog/domain/email/dto/AuthCodeVerificationRequest.java new file mode 100644 index 0000000..df526e4 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/email/dto/AuthCodeVerificationRequest.java @@ -0,0 +1,16 @@ +package com.api.readinglog.domain.email.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import lombok.Getter; + +@Getter +public class AuthCodeVerificationRequest { + + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Schema(description = "인증에 사용한 이메일") + private String email; + + @Schema(description = "이메일로 발급 받은 인증 코드") + private String authCode; +} diff --git a/src/main/java/com/api/readinglog/domain/email/dto/EmailRequest.java b/src/main/java/com/api/readinglog/domain/email/dto/EmailRequest.java new file mode 100644 index 0000000..c49aa94 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/email/dto/EmailRequest.java @@ -0,0 +1,13 @@ +package com.api.readinglog.domain.email.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import lombok.Getter; + +@Getter +public class EmailRequest { + + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Schema(description = "사용자 이메일") + private String email; +} diff --git a/src/main/java/com/api/readinglog/domain/email/service/EmailService.java b/src/main/java/com/api/readinglog/domain/email/service/EmailService.java new file mode 100644 index 0000000..4e61e83 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/email/service/EmailService.java @@ -0,0 +1,112 @@ +package com.api.readinglog.domain.email.service; + +import com.api.readinglog.common.exception.ErrorCode; +import com.api.readinglog.common.exception.custom.EmailException; +import com.api.readinglog.common.redis.service.RedisService; +import com.api.readinglog.domain.member.entity.Member; +import com.api.readinglog.domain.member.service.MemberService; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +@Service +@Slf4j +@EnableAsync +@RequiredArgsConstructor +@Transactional +public class EmailService { + + private final JavaMailSender javaMailSender; + private final SpringTemplateEngine templateEngine; + private final MemberService memberService; + private final RedisService redisService; + private final PasswordEncoder passwordEncoder; + + @Async + public void sendAuthCode(String toEmail) { + String authCode = createRandomCode(); + saveAuthCode(toEmail, authCode); + sendEmail(toEmail, authCode, "[리딩 로그] 이메일 인증 코드", "authCode.html"); + } + + @Async + public void sendTemporaryPassword(Long memberId, String toEmail) { + String tempPassword = createRandomCode(); + sendEmail(toEmail, tempPassword, "[리딩 로그] 임시 비밀번호", "tempPassword.html"); + + Member member = memberService.getMemberById(memberId); + member.updatePassword(passwordEncoder.encode(tempPassword)); + } + + @Async + public void sendEmail(String toEmail, String code, String subject, String templateName) { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + try { + MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + messageHelper.setTo(toEmail); + messageHelper.setSubject(subject); + messageHelper.setText(setContext(code, templateName), true); + + javaMailSender.send(mimeMessage); + + } catch (MessagingException e) { + throw new EmailException(ErrorCode.EMAIL_SEND_FAILED); + } + } + + public void verifyAuthCode(String email, String authCode) { + findByEmailAndAuthCode(authCode) + .filter(e -> e.equals(email)) + .orElseThrow(() -> new EmailException(ErrorCode.INVALID_AUTH_CODE)); + } + + // 인증번호 및 임시 비밀번호 생성 + private String createRandomCode() { + Random random = new Random(); + StringBuilder key = new StringBuilder(); + for (int i = 0; i < 8; i++) { + switch (random.nextInt(3)) { + case 0: + key.append((char) ((int) random.nextInt(26) + 97)); // 소문자 + break; + case 1: + key.append((char) ((int) random.nextInt(26) + 65)); // 대문자 + break; + case 2: + key.append(random.nextInt(10)); // 숫자 + break; + } + } + return key.toString(); + } + + private String setContext(String code, String templateName) { + Context context = new Context(); + log.info(code); + context.setVariable("code", code); + return templateEngine.process(templateName, context); + } + + private void saveAuthCode(String email, String authCode) { + redisService.setData(authCode, email, 5L, TimeUnit.MINUTES); // 5분 설정 + } + + private Optional findByEmailAndAuthCode(String authCode) { + Object email = redisService.getData(authCode); + return Optional.ofNullable(email != null ? email.toString() : null); + } +} + diff --git a/src/main/java/com/api/readinglog/domain/hightlight/controller/HighlightController.java b/src/main/java/com/api/readinglog/domain/highlight/controller/HighlightController.java similarity index 66% rename from src/main/java/com/api/readinglog/domain/hightlight/controller/HighlightController.java rename to src/main/java/com/api/readinglog/domain/highlight/controller/HighlightController.java index 9f6d451..e55a109 100644 --- a/src/main/java/com/api/readinglog/domain/hightlight/controller/HighlightController.java +++ b/src/main/java/com/api/readinglog/domain/highlight/controller/HighlightController.java @@ -1,19 +1,17 @@ -package com.api.readinglog.domain.hightlight.controller; +package com.api.readinglog.domain.highlight.controller; import com.api.readinglog.common.response.Response; import com.api.readinglog.common.security.CustomUserDetail; -import com.api.readinglog.domain.hightlight.controller.dto.request.ModifyRequest; -import com.api.readinglog.domain.hightlight.controller.dto.request.WriteRequest; -import com.api.readinglog.domain.hightlight.controller.dto.response.HighlightResponse; -import com.api.readinglog.domain.hightlight.service.HighlightService; +import com.api.readinglog.domain.highlight.controller.dto.request.ModifyRequest; +import com.api.readinglog.domain.highlight.controller.dto.request.WriteRequest; +import com.api.readinglog.domain.highlight.controller.dto.response.HighlightResponse; +import com.api.readinglog.domain.highlight.service.HighlightService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort.Direction; -import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -25,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Highlight", description = "Highlight API") @Slf4j @RestController @RequestMapping("/api/highlights") @@ -33,15 +32,16 @@ public class HighlightController { private final HighlightService highlightService; + @Operation(summary = "Find highlights", description = "내가 쓴 하이라이트 목록 조회") @GetMapping("/{bookId}/me") - public Response> highlights(@AuthenticationPrincipal CustomUserDetail user, - @PathVariable Long bookId, - @PageableDefault(sort = "createdAt", direction = Direction.DESC) Pageable pageable) { + public Response> highlights(@AuthenticationPrincipal CustomUserDetail user, + @PathVariable Long bookId) { - Page response = highlightService.highlights(user.getId(), bookId, pageable); - return Response.success(HttpStatus.OK, "하이라이트 목록 조회 성공", response); + List response = highlightService.highlights(user.getId(), bookId); + return Response.success(HttpStatus.OK, "내가 쓴 하이라이트 목록 조회 성공", response); } + @Operation(summary = "Add a new highlight", description = "하이라이트 작성") @PostMapping("/{bookId}") public Response write(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long bookId, @@ -52,6 +52,7 @@ public Response write(@AuthenticationPrincipal CustomUserDetail user, return Response.success(HttpStatus.CREATED, "하이라이트 작성 성공"); } + @Operation(summary = "Modify highlight", description = "하이라이트 수정") @PatchMapping("/{highlightId}") public Response modify(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long highlightId, @@ -61,6 +62,7 @@ public Response modify(@AuthenticationPrincipal CustomUserDetail user, return Response.success(HttpStatus.OK, "하이라이트 수정 성공"); } + @Operation(summary = "Delete highlight", description = "하이라이트 삭제") @DeleteMapping("/{highlightId}") public Response delete(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long highlightId) { diff --git a/src/main/java/com/api/readinglog/domain/hightlight/controller/dto/request/ModifyRequest.java b/src/main/java/com/api/readinglog/domain/highlight/controller/dto/request/ModifyRequest.java similarity index 87% rename from src/main/java/com/api/readinglog/domain/hightlight/controller/dto/request/ModifyRequest.java rename to src/main/java/com/api/readinglog/domain/highlight/controller/dto/request/ModifyRequest.java index 53d8bc7..062a20e 100644 --- a/src/main/java/com/api/readinglog/domain/hightlight/controller/dto/request/ModifyRequest.java +++ b/src/main/java/com/api/readinglog/domain/highlight/controller/dto/request/ModifyRequest.java @@ -1,4 +1,4 @@ -package com.api.readinglog.domain.hightlight.controller.dto.request; +package com.api.readinglog.domain.highlight.controller.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/api/readinglog/domain/hightlight/controller/dto/request/WriteRequest.java b/src/main/java/com/api/readinglog/domain/highlight/controller/dto/request/WriteRequest.java similarity index 87% rename from src/main/java/com/api/readinglog/domain/hightlight/controller/dto/request/WriteRequest.java rename to src/main/java/com/api/readinglog/domain/highlight/controller/dto/request/WriteRequest.java index dc40698..8c1c913 100644 --- a/src/main/java/com/api/readinglog/domain/hightlight/controller/dto/request/WriteRequest.java +++ b/src/main/java/com/api/readinglog/domain/highlight/controller/dto/request/WriteRequest.java @@ -1,4 +1,4 @@ -package com.api.readinglog.domain.hightlight.controller.dto.request; +package com.api.readinglog.domain.highlight.controller.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/api/readinglog/domain/hightlight/controller/dto/response/HighlightResponse.java b/src/main/java/com/api/readinglog/domain/highlight/controller/dto/response/HighlightResponse.java similarity index 79% rename from src/main/java/com/api/readinglog/domain/hightlight/controller/dto/response/HighlightResponse.java rename to src/main/java/com/api/readinglog/domain/highlight/controller/dto/response/HighlightResponse.java index a901c49..150fec0 100644 --- a/src/main/java/com/api/readinglog/domain/hightlight/controller/dto/response/HighlightResponse.java +++ b/src/main/java/com/api/readinglog/domain/highlight/controller/dto/response/HighlightResponse.java @@ -1,6 +1,6 @@ -package com.api.readinglog.domain.hightlight.controller.dto.response; +package com.api.readinglog.domain.highlight.controller.dto.response; -import com.api.readinglog.domain.hightlight.entity.Highlight; +import com.api.readinglog.domain.highlight.entity.Highlight; import java.time.LocalDateTime; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/api/readinglog/domain/hightlight/entity/Highlight.java b/src/main/java/com/api/readinglog/domain/highlight/entity/Highlight.java similarity index 87% rename from src/main/java/com/api/readinglog/domain/hightlight/entity/Highlight.java rename to src/main/java/com/api/readinglog/domain/highlight/entity/Highlight.java index 603fd71..4103a04 100644 --- a/src/main/java/com/api/readinglog/domain/hightlight/entity/Highlight.java +++ b/src/main/java/com/api/readinglog/domain/highlight/entity/Highlight.java @@ -1,14 +1,12 @@ -package com.api.readinglog.domain.hightlight.entity; +package com.api.readinglog.domain.highlight.entity; import com.api.readinglog.common.base.BaseTimeEntity; import com.api.readinglog.domain.book.entity.Book; -import com.api.readinglog.domain.hightlight.controller.dto.request.ModifyRequest; -import com.api.readinglog.domain.hightlight.controller.dto.request.WriteRequest; +import com.api.readinglog.domain.highlight.controller.dto.request.ModifyRequest; +import com.api.readinglog.domain.highlight.controller.dto.request.WriteRequest; import com.api.readinglog.domain.member.entity.Member; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; diff --git a/src/main/java/com/api/readinglog/domain/highlight/repository/HighlightRepository.java b/src/main/java/com/api/readinglog/domain/highlight/repository/HighlightRepository.java new file mode 100644 index 0000000..0ca9f2c --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/highlight/repository/HighlightRepository.java @@ -0,0 +1,12 @@ +package com.api.readinglog.domain.highlight.repository; + +import com.api.readinglog.domain.book.entity.Book; +import com.api.readinglog.domain.highlight.entity.Highlight; +import com.api.readinglog.domain.member.entity.Member; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HighlightRepository extends JpaRepository { + + List findAllByMemberAndBook(Member member, Book book); +} diff --git a/src/main/java/com/api/readinglog/domain/hightlight/service/HighlightService.java b/src/main/java/com/api/readinglog/domain/highlight/service/HighlightService.java similarity index 65% rename from src/main/java/com/api/readinglog/domain/hightlight/service/HighlightService.java rename to src/main/java/com/api/readinglog/domain/highlight/service/HighlightService.java index 301f63d..69dbe64 100644 --- a/src/main/java/com/api/readinglog/domain/hightlight/service/HighlightService.java +++ b/src/main/java/com/api/readinglog/domain/highlight/service/HighlightService.java @@ -1,19 +1,18 @@ -package com.api.readinglog.domain.hightlight.service; +package com.api.readinglog.domain.highlight.service; import com.api.readinglog.common.exception.ErrorCode; import com.api.readinglog.common.exception.custom.HighlightException; import com.api.readinglog.domain.book.entity.Book; import com.api.readinglog.domain.book.service.BookService; -import com.api.readinglog.domain.hightlight.controller.dto.request.ModifyRequest; -import com.api.readinglog.domain.hightlight.controller.dto.request.WriteRequest; -import com.api.readinglog.domain.hightlight.controller.dto.response.HighlightResponse; -import com.api.readinglog.domain.hightlight.entity.Highlight; -import com.api.readinglog.domain.hightlight.repository.HighlightRepository; +import com.api.readinglog.domain.highlight.controller.dto.request.ModifyRequest; +import com.api.readinglog.domain.highlight.controller.dto.request.WriteRequest; +import com.api.readinglog.domain.highlight.controller.dto.response.HighlightResponse; +import com.api.readinglog.domain.highlight.entity.Highlight; +import com.api.readinglog.domain.highlight.repository.HighlightRepository; import com.api.readinglog.domain.member.entity.Member; import com.api.readinglog.domain.member.service.MemberService; +import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,21 +26,29 @@ public class HighlightService { private final BookService bookService; @Transactional(readOnly = true) - public Page highlights(Long memberId, Long bookId, Pageable pageable) { + public List highlights(Long memberId, Long bookId) { Member member = memberService.getMemberById(memberId); Book book = bookService.getBookById(bookId); - return highlightRepository.findAllByMemberAndBook(member, book, pageable) - .map(HighlightResponse::fromEntity); - } + List highlights = highlightRepository.findAllByMemberAndBook(member, book) + .stream() + .map(HighlightResponse::fromEntity) + .toList(); + + // 하이라이트가 존재하지 않는 경우 예외 처리 + if(highlights.isEmpty()) { + throw new HighlightException(ErrorCode.NOT_FOUND_HIGHLIGHT); + } + return highlights; + } public void write(Long memberId, Long bookId, WriteRequest request) { Member member = memberService.getMemberById(memberId); Book book = bookService.getBookById(bookId); - Highlight highlight = Highlight.of(member, book, request); - highlightRepository.save(highlight); + Highlight highlight = highlightRepository.save(Highlight.of(member, book, request)); + book.getHighlightList().add(highlight); } public void modify(Long memberId, Long highlightId, ModifyRequest request) { diff --git a/src/main/java/com/api/readinglog/domain/hightlight/repository/HighlightRepository.java b/src/main/java/com/api/readinglog/domain/hightlight/repository/HighlightRepository.java deleted file mode 100644 index d34cdf4..0000000 --- a/src/main/java/com/api/readinglog/domain/hightlight/repository/HighlightRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.api.readinglog.domain.hightlight.repository; - -import com.api.readinglog.domain.book.entity.Book; -import com.api.readinglog.domain.hightlight.entity.Highlight; -import com.api.readinglog.domain.member.entity.Member; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface HighlightRepository extends JpaRepository { - - Page findAllByMemberAndBook(Member member, Book book, Pageable pageable); -} diff --git a/src/main/java/com/api/readinglog/domain/member/controller/MemberController.java b/src/main/java/com/api/readinglog/domain/member/controller/MemberController.java index 0577a88..1f71ea5 100644 --- a/src/main/java/com/api/readinglog/domain/member/controller/MemberController.java +++ b/src/main/java/com/api/readinglog/domain/member/controller/MemberController.java @@ -4,13 +4,24 @@ import com.api.readinglog.common.response.Response; import com.api.readinglog.common.security.CustomUserDetail; import com.api.readinglog.common.security.util.CookieUtils; +import com.api.readinglog.domain.email.dto.AuthCodeVerificationRequest; +import com.api.readinglog.domain.email.dto.EmailRequest; +import com.api.readinglog.domain.email.service.EmailService; import com.api.readinglog.domain.member.controller.dto.request.DeleteRequest; +import com.api.readinglog.domain.member.controller.dto.request.JoinNicknameRequest; import com.api.readinglog.domain.member.controller.dto.request.JoinRequest; import com.api.readinglog.domain.member.controller.dto.request.LoginRequest; +import com.api.readinglog.domain.member.controller.dto.request.UpdatePasswordRequest; import com.api.readinglog.domain.member.controller.dto.request.UpdateProfileRequest; import com.api.readinglog.domain.member.controller.dto.response.MemberDetailsResponse; import com.api.readinglog.domain.member.service.MemberService; -import jakarta.servlet.http.Cookie; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -27,86 +38,194 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@RestController +@Tag(name = "Members", description = "회원 API 목록입니다.") @Slf4j -@RequiredArgsConstructor +@RestController @RequestMapping("/api/members") +@RequiredArgsConstructor public class MemberController { private final MemberService memberService; + private final EmailService emailService; + @Operation(summary = "닉네임 중복 검사", description = "회원 가입 전, 닉네임 중복을 검사합니다.", + parameters = { + @Parameter(name = "nickname", description = "닉네임", required = true, + schema = @Schema(type = "string", implementation = String.class)) + }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "닉네임 중복 검사 통과", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "닉네임 중복 검사 실패") + }) + @PostMapping("/join-nickname") + public Response join_nickname(@ModelAttribute @Valid JoinNicknameRequest request) { + memberService.joinNickname(request); + return Response.success(HttpStatus.OK, "닉네임 중복 검사 통과"); + } + + @Operation(summary = "회원 가입", description = "일반 이메일 회원 가입입니다.", + parameters = { + @Parameter(name = "email", description = "이메일", example = "test@test.com", required = true, + schema = @Schema(type = "string", implementation = String.class)), + @Parameter(name = "password", description = "비밀번호", example = "Password123!", required = true, + schema = @Schema(type = "string", implementation = String.class)), + @Parameter(name = "passwordConfirm", description = "비밀번호 확인", example = "Password123!", required = true, + schema = @Schema(type = "string", implementation = String.class)), + @Parameter(name = "nickname", description = "닉네임", example = "테스트닉네임", required = true, + schema = @Schema(type = "string", implementation = String.class)), + @Parameter(name = "profileImage", description = "프로필 이미지", required = false, + schema = @Schema(type = "string", implementation = String.class)), + }) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "회원 가입 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "회원 가입 실패 (필수 입력값을 입력하지 않은 경우, 비밀번호와 비밀번호 확인이 일치하지 않는 경우)") + }) @PostMapping("/join") public Response join(@ModelAttribute @Valid JoinRequest request) { memberService.join(request); - return Response.success(HttpStatus.CREATED, "회원 가입 완료"); + return Response.success(HttpStatus.CREATED, "회원 가입 성공"); } + @Operation(summary = "로그인", description = "일반 로그인") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "401", description = "로그인 실패: 인증에 실패하였습니다.") + }) @PostMapping("/login") public Response login(@RequestBody LoginRequest request, HttpServletResponse response) { JwtToken jwtToken = memberService.login(request); response.addHeader("Authorization", jwtToken.getAccessToken()); CookieUtils.addCookie(response, "refreshToken", jwtToken.getRefreshToken(), 24 * 60 * 60 * 7); - return Response.success(HttpStatus.OK, "로그인 성공!"); + return Response.success(HttpStatus.OK, "로그인 성공"); } + @Operation(summary = "회원 정보 조회", description = "인증 토큰을 사용하여 회원 정보를 조회합니다.") @GetMapping("/me") public Response findMember(@AuthenticationPrincipal CustomUserDetail user) { MemberDetailsResponse member = memberService.getMemberDetails(user.getId()); - return Response.success(HttpStatus.OK, "회원 조회 성공!", member); + return Response.success(HttpStatus.OK, "회원 조회 성공", member); } + @Operation(summary = "회원 정보 수정", description = "회원 정보를 수정합니다.", + parameters = { + @Parameter(name = "nickname", description = "닉네임", example = "새로운닉네임", required = true, + schema = @Schema(type = "string", implementation = String.class)), + @Parameter(name = "profileImage", description = "프로필 이미지", required = false, + schema = @Schema(type = "string", implementation = String.class)) + }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원 수정 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "401", description = "로그인 실패: 인증에 실패하였습니다.") + }) @PatchMapping("/me") public Response updateProfile(@AuthenticationPrincipal CustomUserDetail user, @ModelAttribute @Valid UpdateProfileRequest request) { memberService.updateProfile(user.getId(), request); - return Response.success(HttpStatus.OK, "회원 수정 성공!"); + return Response.success(HttpStatus.OK, "회원 수정 성공"); } + @Operation(summary = "로그아웃", description = "쿠키에 저장된 리프레시 토큰을 통해 로그아웃 합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "리프레시 토큰이 쿠키에 없습니다.") + }) @PostMapping("/logout") public Response logout(HttpServletRequest request, HttpServletResponse response) { - String refreshToken = extractRefreshToken(request); + String refreshToken = CookieUtils.extractRefreshToken(request); memberService.logout(refreshToken, response); - return Response.success(HttpStatus.OK, "로그아웃 성공!"); + return Response.success(HttpStatus.OK, "로그아웃 성공"); } + @Operation(summary = "일반 회원 탈퇴", description = "일반 회원은 비밀번호 확인을 통해 회원 검증 후, 서비스를 탈퇴할 수 있습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "일반 회원 탈퇴 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "회원이 존재하지 않습니다!") + }) @DeleteMapping("/me") public Response deleteMember(@AuthenticationPrincipal CustomUserDetail user, @RequestBody DeleteRequest request) { memberService.deleteMember(user.getId(), request); - return Response.success(HttpStatus.OK, "일반 회원 탈퇴 성공!"); + return Response.success(HttpStatus.OK, "일반 회원 탈퇴 성공"); } + @Operation(summary = "소셜 회원 탈퇴", description = "소셜 회원은 재로그인을 통해 회원 검증 후, 재발급 받은 액세스 토큰을 통해 서비스를 탈퇴할 수 있습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "소셜 회원 탈퇴 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "회원이 존재하지 않습니다!") + }) @DeleteMapping("/social/me") public Response deleteSocialMember(@AuthenticationPrincipal CustomUserDetail user) { memberService.deleteSocialMember(user.getId()); - return Response.success(HttpStatus.OK, "소셜 회원 탈퇴 성공!"); + return Response.success(HttpStatus.OK, "소셜 회원 탈퇴 성공"); } + @Operation(summary = "토큰 재발급", description = "액세스 토큰이 만료된 경우, 리프레시 토큰을 이용하여 재발급 받을 수 있습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "리프레시 토큰이 쿠키에 없습니다.") + }) @GetMapping("/reissue") public Response reissue(HttpServletRequest request, HttpServletResponse response) { - // 쿠키에서 리프레시 토큰 가져오기 - String refreshToken = extractRefreshToken(request); - - // 리프레시 토큰을 사용하여 새로운 토큰 재발급 + String refreshToken = CookieUtils.extractRefreshToken(request); JwtToken newToken = memberService.reissueToken(refreshToken); - - // 재발급된 토큰 반환 response.addHeader("Authorization", newToken.getAccessToken()); CookieUtils.addCookie(response, "refreshToken", newToken.getRefreshToken(), 24 * 60 * 60 * 7); - return Response.success(HttpStatus.OK, "토큰 재발급 성공!"); + return Response.success(HttpStatus.OK, "토큰 재발급 성공"); + } + + @Operation(summary = "비밀번호 변경", description = "비밀번호 변경입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "비밀번호 변경 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "비밀번호 변경 실패") + }) + @PatchMapping("/password") + public Response updatePassword(@AuthenticationPrincipal CustomUserDetail user, + @RequestBody @Valid UpdatePasswordRequest request) { + memberService.updatePassword(user.getId(), request); + return Response.success(HttpStatus.OK, "비밀번호 변경 성공"); + } + + @Operation(summary = "이메일 인증 코드 전송", description = "사용자 이메일로 인증 코드를 전송합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송 완료", + content = {@Content(schema = @Schema(implementation = Response.class))}) + }) + @PostMapping("/send-authCode") + public Response sendEmailAuthCode(@RequestBody @Valid EmailRequest request) { + emailService.sendAuthCode(request.getEmail()); + return Response.success(HttpStatus.OK, "이메일 인증 코드 전송 완료"); + } + + @Operation(summary = "이메일 인증", description = "사용자 이메일로 보낸 인증 코드를 검증하여 이메일을 인증합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 성공", + content = {@Content(schema = @Schema(implementation = Response.class))}), + @ApiResponse(responseCode = "400", description = "이메일 인증에 실패하였습니다.") + }) + @PostMapping("/verify-authCode") + public Response verifyAuthCode(@RequestBody @Valid AuthCodeVerificationRequest request) { + emailService.verifyAuthCode(request.getEmail(), request.getAuthCode()); + return Response.success(HttpStatus.OK, "이메일 인증 성공"); } - // HttpServletRequest에서 리프레시 토큰을 추출하는 메서드 - private String extractRefreshToken(HttpServletRequest request) { - // 쿠키에서 리프레`시 토큰 이름으로 검색 - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - for (Cookie cookie : cookies) { - if (cookie.getName().equals("refreshToken")) { - return cookie.getValue(); - } - } - } - throw new IllegalStateException("리프레시 토큰이 쿠키에 없습니다."); + @Operation(summary = "임시 비밀번호 전송", description = "사용자 이메일로 임시 비밀번호를 전송합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "임시 비밀번호 전송 완료", + content = {@Content(schema = @Schema(implementation = Response.class))}) + }) + @PostMapping("/send-temporaryPassword") + public Response sendEmailTempPassword(@AuthenticationPrincipal CustomUserDetail user, + @RequestBody @Valid EmailRequest request) { + emailService.sendTemporaryPassword(user.getId(), request.getEmail()); + return Response.success(HttpStatus.OK, "임시 비밀번호 전송 완료"); } } diff --git a/src/main/java/com/api/readinglog/domain/member/controller/dto/request/DeleteRequest.java b/src/main/java/com/api/readinglog/domain/member/controller/dto/request/DeleteRequest.java index b4b4d1f..0345890 100644 --- a/src/main/java/com/api/readinglog/domain/member/controller/dto/request/DeleteRequest.java +++ b/src/main/java/com/api/readinglog/domain/member/controller/dto/request/DeleteRequest.java @@ -1,5 +1,6 @@ package com.api.readinglog.domain.member.controller.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -8,5 +9,7 @@ @NoArgsConstructor @AllArgsConstructor public class DeleteRequest { + + @Schema(description = "회원 탈퇴에 필요한 비밀번호") private String password; } diff --git a/src/main/java/com/api/readinglog/domain/member/controller/dto/request/JoinNicknameRequest.java b/src/main/java/com/api/readinglog/domain/member/controller/dto/request/JoinNicknameRequest.java new file mode 100644 index 0000000..a56ebd4 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/member/controller/dto/request/JoinNicknameRequest.java @@ -0,0 +1,19 @@ +package com.api.readinglog.domain.member.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +public class JoinNicknameRequest { + + @NotBlank(message = "닉네임은 필수 입력 값입니다.") + @Size(max = 8, message = "닉네임은 8자 이하로 입력해야 합니다.") + @Pattern(regexp = "^[\\p{L}0-9]+$", message = "닉네임에는 특수 문자를 사용할 수 없습니다.") + private String nickname; +} diff --git a/src/main/java/com/api/readinglog/domain/member/controller/dto/request/LoginRequest.java b/src/main/java/com/api/readinglog/domain/member/controller/dto/request/LoginRequest.java index 13a0516..0d1405f 100644 --- a/src/main/java/com/api/readinglog/domain/member/controller/dto/request/LoginRequest.java +++ b/src/main/java/com/api/readinglog/domain/member/controller/dto/request/LoginRequest.java @@ -1,10 +1,14 @@ package com.api.readinglog.domain.member.controller.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; @Getter public class LoginRequest { + @Schema(description = "이메일", example = "test@test.com") private String email; + + @Schema(description = "비밀번호", example = "Password123!") private String password; } diff --git a/src/main/java/com/api/readinglog/domain/member/controller/dto/request/UpdatePasswordRequest.java b/src/main/java/com/api/readinglog/domain/member/controller/dto/request/UpdatePasswordRequest.java new file mode 100644 index 0000000..2099f4e --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/member/controller/dto/request/UpdatePasswordRequest.java @@ -0,0 +1,24 @@ +package com.api.readinglog.domain.member.controller.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +public class UpdatePasswordRequest { + private String currentPassword; + + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&]).{8,20}$", message = "비밀번호는 8~20자의 영문 대소문자, 숫자, 특수문자를 포함해야 합니다.") + @Schema(description = "새로운 비밀번호") + private String newPassword; + + @NotBlank(message = "비밀번호 확인은 필수입니다.") + @Schema(description = "새로운 비밀번호 확인") + private String newPasswordConfirm; +} diff --git a/src/main/java/com/api/readinglog/domain/member/entity/Member.java b/src/main/java/com/api/readinglog/domain/member/entity/Member.java index dbdd5dd..beffcea 100644 --- a/src/main/java/com/api/readinglog/domain/member/entity/Member.java +++ b/src/main/java/com/api/readinglog/domain/member/entity/Member.java @@ -77,4 +77,8 @@ public void updateProfile(String nickname, String picture) { this.nickname = nickname; this.profileImg = picture; } + + public void updatePassword(String password) { + this.password = password; + } } diff --git a/src/main/java/com/api/readinglog/domain/member/service/MemberService.java b/src/main/java/com/api/readinglog/domain/member/service/MemberService.java index 2397a57..0e80293 100644 --- a/src/main/java/com/api/readinglog/domain/member/service/MemberService.java +++ b/src/main/java/com/api/readinglog/domain/member/service/MemberService.java @@ -1,35 +1,32 @@ package com.api.readinglog.domain.member.service; import com.api.readinglog.common.aws.AmazonS3Service; +import com.api.readinglog.common.aws.DomainType; import com.api.readinglog.common.exception.ErrorCode; -import com.api.readinglog.common.exception.custom.JwtException; import com.api.readinglog.common.exception.custom.MemberException; +import com.api.readinglog.common.image.ImageUtil; import com.api.readinglog.common.jwt.JwtToken; import com.api.readinglog.common.jwt.JwtTokenProvider; import com.api.readinglog.common.oauth.OAuth2RevokeService; -import com.api.readinglog.common.security.CustomUserDetail; import com.api.readinglog.common.security.util.JwtUtils; import com.api.readinglog.domain.member.controller.dto.request.DeleteRequest; +import com.api.readinglog.domain.member.controller.dto.request.JoinNicknameRequest; import com.api.readinglog.domain.member.controller.dto.request.JoinRequest; import com.api.readinglog.domain.member.controller.dto.request.LoginRequest; +import com.api.readinglog.domain.member.controller.dto.request.UpdatePasswordRequest; import com.api.readinglog.domain.member.controller.dto.request.UpdateProfileRequest; import com.api.readinglog.domain.member.controller.dto.response.MemberDetailsResponse; import com.api.readinglog.domain.member.entity.Member; import com.api.readinglog.domain.member.entity.MemberRole; -import com.api.readinglog.domain.token.repository.RefreshTokenRepository; import com.api.readinglog.domain.token.repository.SocialAccessTokenRepository; import com.api.readinglog.domain.member.repository.MemberRepository; import jakarta.servlet.http.HttpServletResponse; -import java.util.Collection; -import java.util.Collections; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -51,15 +48,19 @@ public class MemberService { private final OAuth2RevokeService oAuth2RevokeService; private final JwtUtils jwtUtils; + public void joinNickname(JoinNicknameRequest request) { + validateExistingNickname(request.getNickname()); + } + public void join(JoinRequest request) { - validateExistingMember(request.getEmail(), request.getNickname()); + validateExistingMember(request.getEmail()); + validateExistingNickname(request.getNickname()); validatePassword(request.getPassword(), request.getPasswordConfirm()); String encodedPassword = passwordEncoder.encode(request.getPassword()); - String uploadFileName = determineProfileImageUrl(request.getProfileImage()); + String uploadFileName = determineProfileImgUrl(request.getProfileImage()); Member member = Member.of(request, encodedPassword, uploadFileName); - log.debug("회원 프로필 사진 이름: {}", uploadFileName); memberRepository.save(member); } @@ -81,7 +82,6 @@ public MemberDetailsResponse getMemberDetails(Long memberId) { Member member = getMemberById(memberId); String profileImg = member.getProfileImg(); - // 소셜 로그인 한 회원이 아닌 경우에만 S3에서 이미지 객체 URL을 가져옴 if (member.getRole().equals(MemberRole.MEMBER_NORMAL)) { profileImg = amazonS3Service.getFileUrl(member.getProfileImg()); } @@ -91,13 +91,12 @@ public MemberDetailsResponse getMemberDetails(Long memberId) { public void updateProfile(Long memberId, UpdateProfileRequest request) { Member member = getMemberById(memberId); - // 기존 이미지를 기본 값으로 설정 String updatedFileName = member.getProfileImg(); - if (!isEmptyProfileImg(request.getProfileImg())) { + if (ImageUtil.isNotEmptyImageFile(request.getProfileImg())) { // 수정할 이미지 데이터가 존재할 경우, 기존 이미지 삭제 후 새 이미지 업로드 amazonS3Service.deleteFile(member.getProfileImg()); - updatedFileName = amazonS3Service.uploadFile(request.getProfileImg()); + updatedFileName = amazonS3Service.uploadFile(request.getProfileImg(), DomainType.MEMBERS); } member.updateProfile(request.getNickname(), updatedFileName); } @@ -130,7 +129,17 @@ public JwtToken reissueToken(String refreshToken) { return jwtTokenProvider.reissueTokenByRefreshToken(refreshToken); } - // 소셜 계정 연동 해제 처리를 별도의 메서드로 추출 + public void updatePassword(Long memberId, UpdatePasswordRequest request) { + Member member = getMemberById(memberId); + + if (!passwordEncoder.matches(request.getCurrentPassword(), member.getPassword())) { + throw new MemberException(ErrorCode.INVALID_CURRENT_PASSWORD); + } + + validatePassword(request.getNewPassword(), request.getNewPasswordConfirm()); + member.updatePassword(passwordEncoder.encode(request.getNewPassword())); + } + private void revokeSocialAccessToken(Member member, String socialAccessToken) { switch (member.getRole()) { case MEMBER_KAKAO -> oAuth2RevokeService.revokeKakao(socialAccessToken); @@ -139,11 +148,13 @@ private void revokeSocialAccessToken(Member member, String socialAccessToken) { } } - private void validateExistingMember(String email, String nickname) { + private void validateExistingMember(String email) { if (memberRepository.findByEmailAndRole(email, MemberRole.MEMBER_NORMAL).isPresent()) { throw new MemberException(ErrorCode.MEMBER_ALREADY_EXISTS); } + } + private void validateExistingNickname(String nickname) { if (memberRepository.findByNickname(nickname).isPresent()) { throw new MemberException(ErrorCode.NICKNAME_ALREADY_EXISTS); } @@ -165,16 +176,12 @@ private Authentication getUserAuthentication(LoginRequest request) { } } - private String determineProfileImageUrl(MultipartFile profileImage) { - /* TODO: 프로필 사진 요청이 없는 경우, 기본 프로필 저장 */ - if (profileImage == null || profileImage.isEmpty()) { - return "기본 프로필 이미지 URL"; + private String determineProfileImgUrl(MultipartFile profileImg) { + if (profileImg == null || profileImg.isEmpty()) { + return amazonS3Service.getDefaultProfileImg(); } else { - return amazonS3Service.uploadFile(profileImage); + return amazonS3Service.uploadFile(profileImg, DomainType.MEMBERS); } } - - private boolean isEmptyProfileImg(MultipartFile profileImg) { - return (profileImg == null || profileImg.isEmpty()); - } + } diff --git a/src/main/java/com/api/readinglog/domain/record/controller/RecordController.java b/src/main/java/com/api/readinglog/domain/record/controller/RecordController.java new file mode 100644 index 0000000..3ee2189 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/record/controller/RecordController.java @@ -0,0 +1,71 @@ +package com.api.readinglog.domain.record.controller; + +import com.api.readinglog.common.response.Response; +import com.api.readinglog.common.security.CustomUserDetail; +import com.api.readinglog.domain.record.controller.dto.request.RecordModifyRequest; +import com.api.readinglog.domain.record.controller.dto.request.RecordWriteRequest; +import com.api.readinglog.domain.record.controller.dto.response.RecordResponse; +import com.api.readinglog.domain.record.service.RecordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Record", description = "Record API") +@RestController +@RequestMapping("/api/records") +@RequiredArgsConstructor +@Slf4j +public class RecordController { + + private final RecordService recordService; + + @Operation(summary = "Find records", description = "독서 기록 조회") + @GetMapping("/{bookId}") + public Response> getRecord(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long bookId) { + + List response = recordService.getRecord(user.getId(), bookId); + return Response.success(HttpStatus.OK, "독서 기록 조회 성공", response); + } + + @Operation(summary = "Add a new record", description = "독서 기록 추가") + @PostMapping("/{bookId}") + public Response addRecord(@AuthenticationPrincipal CustomUserDetail user, + @PathVariable Long bookId, + @RequestBody @Valid RecordWriteRequest recordWriteRequest) { + + recordService.write(user.getId(), bookId, recordWriteRequest); + return Response.success(HttpStatus.OK, "독서 기록 추가 성공"); + } + + @Operation(summary = "Modify record", description = "독서 기록 수정") + @PatchMapping("/{recordId}") + public Response modify(@AuthenticationPrincipal CustomUserDetail user, + @PathVariable Long recordId, + @RequestBody RecordModifyRequest recordModifyRequest) { + + recordService.modify(user.getId(), recordId, recordModifyRequest); + return Response.success(HttpStatus.OK, "독서 기록 수정 성공"); + } + + @Operation(summary = "Delete record", description = "독서 기록 삭제") + @DeleteMapping("/{recordId}") + public Response delete(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long recordId) { + + recordService.delete(user.getId(), recordId); + return Response.success(HttpStatus.OK, "독서 기록 삭제 성공"); + } + +} diff --git a/src/main/java/com/api/readinglog/domain/record/controller/dto/request/RecordModifyRequest.java b/src/main/java/com/api/readinglog/domain/record/controller/dto/request/RecordModifyRequest.java new file mode 100644 index 0000000..7b58981 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/record/controller/dto/request/RecordModifyRequest.java @@ -0,0 +1,15 @@ +package com.api.readinglog.domain.record.controller.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +public class RecordModifyRequest { + + @NotNull(message = "독서 시작일은 필수값 입니다.") + private LocalDateTime startDate; + + @NotNull(message = "독서 종료일은 필수값 입니다.") + private LocalDateTime endDate; +} diff --git a/src/main/java/com/api/readinglog/domain/record/controller/dto/request/RecordWriteRequest.java b/src/main/java/com/api/readinglog/domain/record/controller/dto/request/RecordWriteRequest.java new file mode 100644 index 0000000..9e9b09d --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/record/controller/dto/request/RecordWriteRequest.java @@ -0,0 +1,15 @@ +package com.api.readinglog.domain.record.controller.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +public class RecordWriteRequest { + + @NotNull(message = "독서 시작일은 필수값 입니다.") + private LocalDateTime startDate; + + @NotNull(message = "독서 종료일은 필수값 입니다.") + private LocalDateTime endDate; +} diff --git a/src/main/java/com/api/readinglog/domain/record/controller/dto/response/RecordResponse.java b/src/main/java/com/api/readinglog/domain/record/controller/dto/response/RecordResponse.java new file mode 100644 index 0000000..5da3e02 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/record/controller/dto/response/RecordResponse.java @@ -0,0 +1,27 @@ +package com.api.readinglog.domain.record.controller.dto.response; + +import com.api.readinglog.domain.record.entity.Record; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class RecordResponse { + + private Long memberId; + private Long bookId; + private Long recordId; + private LocalDateTime startDate; + private LocalDateTime endDate; + + public static RecordResponse fromEntity(Record record) { + return RecordResponse.builder() + .memberId((record.getMember().getId())) + .bookId(record.getBook().getId()) + .recordId(record.getId()) + .startDate(record.getStartDate()) + .endDate(record.getEndDate()) + .build(); + } +} diff --git a/src/main/java/com/api/readinglog/domain/record/entity/Record.java b/src/main/java/com/api/readinglog/domain/record/entity/Record.java new file mode 100644 index 0000000..15c984e --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/record/entity/Record.java @@ -0,0 +1,72 @@ +package com.api.readinglog.domain.record.entity; + + +import com.api.readinglog.common.base.BaseTimeEntity; +import com.api.readinglog.domain.book.entity.Book; +import com.api.readinglog.domain.member.entity.Member; +import com.api.readinglog.domain.record.controller.dto.request.RecordModifyRequest; +import com.api.readinglog.domain.record.controller.dto.request.RecordWriteRequest; +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 java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE record SET deleted_at = NOW() WHERE record_id = ?") +@Where(clause = "deleted_at IS NULL") +public class Record extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "record_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "book_id") + private Book book; + + @Column(name = "record_start_date", nullable = false) + private LocalDateTime startDate; + + @Column(name = "record_end_date", nullable = false) + private LocalDateTime endDate; + + @Builder + public Record(Member member, Book book, LocalDateTime startDate, LocalDateTime endDate) { + this.member = member; + this.book = book; + this.startDate = startDate; + this.endDate = endDate; + } + + public static Record of(Member member, Book book, RecordWriteRequest request) { + return Record.builder() + .member(member) + .book(book) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .build(); + } + + public void modify(RecordModifyRequest request) { + this.startDate = request.getStartDate(); + this.endDate = request.getEndDate(); + } +} diff --git a/src/main/java/com/api/readinglog/domain/record/repository/RecordRepository.java b/src/main/java/com/api/readinglog/domain/record/repository/RecordRepository.java new file mode 100644 index 0000000..48a9236 --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/record/repository/RecordRepository.java @@ -0,0 +1,12 @@ +package com.api.readinglog.domain.record.repository; + +import com.api.readinglog.domain.book.entity.Book; +import com.api.readinglog.domain.member.entity.Member; +import com.api.readinglog.domain.record.entity.Record; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecordRepository extends JpaRepository { + + List findAllByMemberAndBook(Member member, Book book); +} diff --git a/src/main/java/com/api/readinglog/domain/record/service/RecordService.java b/src/main/java/com/api/readinglog/domain/record/service/RecordService.java new file mode 100644 index 0000000..0f855ed --- /dev/null +++ b/src/main/java/com/api/readinglog/domain/record/service/RecordService.java @@ -0,0 +1,80 @@ +package com.api.readinglog.domain.record.service; + +import com.api.readinglog.common.exception.ErrorCode; +import com.api.readinglog.common.exception.custom.RecordException; +import com.api.readinglog.domain.book.entity.Book; +import com.api.readinglog.domain.book.service.BookService; +import com.api.readinglog.domain.member.entity.Member; +import com.api.readinglog.domain.member.service.MemberService; +import com.api.readinglog.domain.record.controller.dto.request.RecordModifyRequest; +import com.api.readinglog.domain.record.controller.dto.request.RecordWriteRequest; +import com.api.readinglog.domain.record.controller.dto.response.RecordResponse; +import com.api.readinglog.domain.record.entity.Record; +import com.api.readinglog.domain.record.repository.RecordRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class RecordService { + + private final RecordRepository recordRepository; + private final MemberService memberService; + private final BookService bookService; + + @Transactional(readOnly = true) + public List getRecord(Long memberId, Long bookId) { + Member member = memberService.getMemberById(memberId); + Book book = bookService.getBookById(bookId); + + List records = recordRepository.findAllByMemberAndBook(member, book) + .stream() + + .map(RecordResponse::fromEntity) + .toList(); + + if (records.isEmpty()) { + throw new RecordException(ErrorCode.NOT_FOUND_RECORD); + } + + return records; + } + + public void write(long memberId, long bookId, RecordWriteRequest request) { + Member member = memberService.getMemberById(memberId); + Book book = bookService.getBookById(bookId); + + Record record = recordRepository.save(Record.of(member, book, request)); + book.getRecordList().add(record); + } + + public void modify(Long memberId, Long recordId, RecordModifyRequest request) { + Member member = memberService.getMemberById(memberId); + Record record = getRecordById(recordId); + + if (record.getMember() != member) { + throw new RecordException(ErrorCode.FORBIDDEN_MODIFY); + } + + record.modify(request); + } + + public void delete(Long memberId, Long recordId) { + Member member = memberService.getMemberById(memberId); + Record record = getRecordById(recordId); + + if (record.getMember() != member) { + throw new RecordException(ErrorCode.FORBIDDEN_DELETE); + } + + recordRepository.delete(record); + } + + public Record getRecordById(Long recordId) { + return recordRepository.findById(recordId).orElseThrow(() -> new RecordException(ErrorCode.NOT_FOUND_RECORD)); + } + +} diff --git a/src/main/java/com/api/readinglog/domain/review/controller/ReviewController.java b/src/main/java/com/api/readinglog/domain/review/controller/ReviewController.java index 91bf54e..f9090c9 100644 --- a/src/main/java/com/api/readinglog/domain/review/controller/ReviewController.java +++ b/src/main/java/com/api/readinglog/domain/review/controller/ReviewController.java @@ -6,12 +6,11 @@ import com.api.readinglog.domain.review.controller.dto.request.WriteRequest; import com.api.readinglog.domain.review.controller.dto.response.ReviewResponse; import com.api.readinglog.domain.review.service.ReviewService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort.Direction; -import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -23,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Review", description = "Review API") @RestController @RequestMapping("/api/reviews") @RequiredArgsConstructor @@ -30,15 +30,16 @@ public class ReviewController { private final ReviewService reviewService; + @Operation(summary = "Find reviews", description = "내가 쓴 서평 목록 조회") @GetMapping("/{bookId}/me") - public Response> reviews(@AuthenticationPrincipal CustomUserDetail user, - @PathVariable Long bookId, - @PageableDefault(sort = "createdAt", direction = Direction.DESC) Pageable pageable) { + public Response> reviews(@AuthenticationPrincipal CustomUserDetail user, + @PathVariable Long bookId) { - Page response = reviewService.reviews(user.getId(), bookId, pageable); + List response = reviewService.reviews(user.getId(), bookId); return Response.success(HttpStatus.OK, "내가 쓴 서평 목록 조회 성공", response); } + @Operation(summary = "Add a new review", description = "서평 작성") @PostMapping("/{bookId}") public Response write(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long bookId, @@ -48,6 +49,7 @@ public Response write(@AuthenticationPrincipal CustomUserDetail user, return Response.success(HttpStatus.CREATED, "서평 작성 성공"); } + @Operation(summary = "Modify review", description = "서평 수정") @PatchMapping("/{reviewId}") public Response modify(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long reviewId, @@ -57,6 +59,7 @@ public Response modify(@AuthenticationPrincipal CustomUserDetail user, return Response.success(HttpStatus.OK, "서평 수정 성공"); } + @Operation(summary = "Delete review", description = "서평 삭제") @DeleteMapping("/{reviewId}") public Response modify(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long reviewId) { diff --git a/src/main/java/com/api/readinglog/domain/review/repository/ReviewRepository.java b/src/main/java/com/api/readinglog/domain/review/repository/ReviewRepository.java index b540c86..ddf2c43 100644 --- a/src/main/java/com/api/readinglog/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/api/readinglog/domain/review/repository/ReviewRepository.java @@ -3,11 +3,10 @@ import com.api.readinglog.domain.book.entity.Book; import com.api.readinglog.domain.member.entity.Member; import com.api.readinglog.domain.review.entity.Review; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface ReviewRepository extends JpaRepository { - Page findAllByMemberAndBook(Member member, Book book, Pageable pageable); + List findAllByMemberAndBook(Member member, Book book); } diff --git a/src/main/java/com/api/readinglog/domain/review/service/ReviewService.java b/src/main/java/com/api/readinglog/domain/review/service/ReviewService.java index 91d6cf8..87f156a 100644 --- a/src/main/java/com/api/readinglog/domain/review/service/ReviewService.java +++ b/src/main/java/com/api/readinglog/domain/review/service/ReviewService.java @@ -11,9 +11,8 @@ import com.api.readinglog.domain.review.controller.dto.response.ReviewResponse; import com.api.readinglog.domain.review.entity.Review; import com.api.readinglog.domain.review.repository.ReviewRepository; +import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,14 +26,16 @@ public class ReviewService { private final BookService bookService; @Transactional(readOnly = true) - public Page reviews(Long memberId, Long bookId, Pageable pageable) { + public List reviews(Long memberId, Long bookId) { Member member = memberService.getMemberById(memberId); Book book = bookService.getBookById(bookId); - Page reviews = reviewRepository.findAllByMemberAndBook(member, book, pageable) - .map(ReviewResponse::fromEntity); + List reviews = reviewRepository.findAllByMemberAndBook(member, book) + .stream() + .map(ReviewResponse::fromEntity) + .toList(); - if (reviews.getContent().isEmpty()) { + if (reviews.isEmpty()) { throw new ReviewException(ErrorCode.NOT_FOUND_REVIEW); } @@ -45,8 +46,8 @@ public void write(Long memberId, Long bookId, WriteRequest request) { Member member = memberService.getMemberById(memberId); Book book = bookService.getBookById(bookId); - Review review = Review.of(member, book, request); - reviewRepository.save(review); + Review review = reviewRepository.save(Review.of(member, book, request)); + book.getReviewList().add(review); } public void modify(Long memberId, Long reviewId, ModifyRequest request) { diff --git a/src/main/java/com/api/readinglog/domain/summary/controller/SummaryController.java b/src/main/java/com/api/readinglog/domain/summary/controller/SummaryController.java index 6a699d9..8da1b69 100644 --- a/src/main/java/com/api/readinglog/domain/summary/controller/SummaryController.java +++ b/src/main/java/com/api/readinglog/domain/summary/controller/SummaryController.java @@ -4,19 +4,14 @@ import com.api.readinglog.common.security.CustomUserDetail; import com.api.readinglog.domain.summary.controller.dto.request.ModifyRequest; import com.api.readinglog.domain.summary.controller.dto.request.WriteRequest; -import com.api.readinglog.domain.summary.controller.dto.response.SummaryResponse; -import com.api.readinglog.domain.summary.controller.dto.response.MySummaryResponse; import com.api.readinglog.domain.summary.service.SummaryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort.Direction; -import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -24,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Summary", description = "Summary API") @RestController @RequestMapping("/api/summaries") @RequiredArgsConstructor @@ -31,20 +27,7 @@ public class SummaryController { private final SummaryService summaryService; - @GetMapping("/feed") - public Response> feed(@PageableDefault(sort = "createdAt", direction = Direction.DESC) Pageable pageable) { - // TODO: querydsl 동적 쿼리 처리 - return Response.success(HttpStatus.OK, "피드 목록 조회 성공", summaryService.feed(pageable)); - } - - @GetMapping("/{bookId}/me") - public Response mySummary(@AuthenticationPrincipal CustomUserDetail user, - @PathVariable Long bookId) { - - MySummaryResponse response = summaryService.mySummary(user.getId(), bookId); - return Response.success(HttpStatus.OK, "내 한줄평 조회 성공", response); - } - + @Operation(summary = "Add a new summary", description = "한줄평 작성") @PostMapping("/{bookId}") public Response write(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long bookId, @@ -54,6 +37,7 @@ public Response write(@AuthenticationPrincipal CustomUserDetail user, return Response.success(HttpStatus.CREATED, "한줄평 작성 성공"); } + @Operation(summary = "Modify summary", description = "한줄평 수정") @PatchMapping("/{summaryId}") public Response modify(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long summaryId, @@ -63,6 +47,7 @@ public Response modify(@AuthenticationPrincipal CustomUserDetail user, return Response.success(HttpStatus.OK, "한줄평 수정 성공"); } + @Operation(summary = "Delete summary", description = "한줄평 삭제") @DeleteMapping("/{summaryId}") public Response delete(@AuthenticationPrincipal CustomUserDetail user, @PathVariable Long summaryId) { diff --git a/src/main/java/com/api/readinglog/domain/summary/service/SummaryService.java b/src/main/java/com/api/readinglog/domain/summary/service/SummaryService.java index d231e3e..392d323 100644 --- a/src/main/java/com/api/readinglog/domain/summary/service/SummaryService.java +++ b/src/main/java/com/api/readinglog/domain/summary/service/SummaryService.java @@ -8,13 +8,9 @@ import com.api.readinglog.domain.member.service.MemberService; import com.api.readinglog.domain.summary.controller.dto.request.ModifyRequest; import com.api.readinglog.domain.summary.controller.dto.request.WriteRequest; -import com.api.readinglog.domain.summary.controller.dto.response.SummaryResponse; -import com.api.readinglog.domain.summary.controller.dto.response.MySummaryResponse; import com.api.readinglog.domain.summary.entity.Summary; import com.api.readinglog.domain.summary.repository.SummaryRepository; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,30 +23,6 @@ public class SummaryService { private final MemberService memberService; private final BookService bookService; - @Transactional(readOnly = true) - public Page feed(Pageable pageable) { - Page feed = summaryRepository.findAllBy(pageable).map(SummaryResponse::fromEntity); - - // 피드가 존재하지 않는 경우 예외 처리 - if (feed.getContent().isEmpty()) { - throw new SummaryException(ErrorCode.NOT_FOUND_FEED); - } - - return feed; - } - - @Transactional(readOnly = true) - public MySummaryResponse mySummary(Long memberId, Long bookId) { - Member member = memberService.getMemberById(memberId); - Book book = bookService.getBookById(bookId); - - // 해당 책에 대한 한줄평이 존재하면 반환 - Summary summary = summaryRepository.findByMemberAndBook(member, book) - .orElseThrow(() -> new SummaryException(ErrorCode.NOT_FOUND_SUMMARY)); - - return MySummaryResponse.fromEntity(summary); - } - public void write(Long memberId, Long bookId, WriteRequest request) { Member member = memberService.getMemberById(memberId); Book book = bookService.getBookById(bookId); @@ -60,8 +32,8 @@ public void write(Long memberId, Long bookId, WriteRequest request) { throw new SummaryException(ErrorCode.SUMMARY_ALREADY_EXISTS); }); - Summary summary = Summary.of(member, book, request); - summaryRepository.save(summary); + Summary summary = summaryRepository.save(Summary.of(member, book, request)); + book.getSummaryList().add(summary); } public void modify(Long memberId, Long summaryId, ModifyRequest request) { diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..9825368 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,65 @@ +insert into member(member_id, member_email, member_nickname, member_password, member_profile_img, member_role, + created_at, modified_at, deleted_at) +values (1, 'dongmin@naver.com', '동민', '{noop}1234', 'default.png', 'member_normal', now(), now(), null); + +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (1, 1, null, 'Report, The (Gozaresh)', 'hmival0', 'Anheuser-Busch Inbev SA', 'Oyondu', 'http://dummyimage.com/159x100.png/ff4444/ffffff', '2024-03-13 07:37:09', '2024-03-05 20:23:56', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (2, 1, null, 'Formula, The', 'tcrookshank1', 'Viad Corp', 'Mydeo', 'http://dummyimage.com/116x100.png/dddddd/000000', '2024-03-12 23:27:43', '2024-03-09 00:25:25', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (3, 1, null, 'He Got Game', 'afouch2', 'CyberOptics Corporation', 'Quimm', 'http://dummyimage.com/145x100.png/5fa2dd/ffffff', '2024-03-07 00:11:29', '2024-03-14 14:18:40', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (4, 1, null, 'Lone Ranger and the Lost City of Gold, The', 'mshanks3', 'Amedica Corporation', 'Skinte', 'http://dummyimage.com/131x100.png/ff4444/ffffff', '2024-03-07 04:37:17', '2024-03-06 01:11:36', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (5, 1, null, 'Detropia', 'uweld4', 'Civitas Solutions, Inc.', 'Skiba', 'http://dummyimage.com/229x100.png/5fa2dd/ffffff', '2024-03-20 01:48:37', '2024-03-21 13:40:16', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (6, 1, null, 'Murph: The Protector', 'ddovydenas5', 'Deluxe Corporation', 'Jatri', 'http://dummyimage.com/142x100.png/ff4444/ffffff', '2024-03-03 17:31:32', '2024-03-24 04:39:18', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (7, 1, null, 'But Forever in My Mind', 'mnewsham6', 'TFS Financial Corporation', 'Centizu', 'http://dummyimage.com/131x100.png/ff4444/ffffff', '2024-03-21 17:54:32', '2024-03-22 17:02:55', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (8, 1, null, 'Always', 'bterrelly7', 'Biostage, Inc.', 'Chatterbridge', 'http://dummyimage.com/181x100.png/5fa2dd/ffffff', '2024-03-21 16:53:22', '2024-03-11 02:39:23', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (9, 1, null, 'Not Fade Away', 'jstrainge8', 'Rush Enterprises, Inc.', 'Yakitri', 'http://dummyimage.com/189x100.png/5fa2dd/ffffff', '2024-03-08 00:40:02', '2024-03-03 14:06:00', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (10, 1, null, 'Caddyshack', 'jwhalebelly9', 'Capitol Federal Financial, Inc.', 'Photolist', 'http://dummyimage.com/134x100.png/5fa2dd/ffffff', '2024-03-21 05:26:57', '2024-03-02 01:27:43', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (11, 1, null, 'Marina Abramovic: The Artist Is Present', 'swalasa', 'Diana Shipping inc.', 'Blogtag', 'http://dummyimage.com/131x100.png/ff4444/ffffff', '2024-03-12 09:37:19', '2024-03-06 02:40:11', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (12, 1, null, 'The Legend of Sarila', 'bmccrillisb', 'Duke Energy Corporation', 'Livepath', 'http://dummyimage.com/134x100.png/ff4444/ffffff', '2024-03-19 19:21:41', '2024-03-20 13:44:13', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (13, 1, null, 'No Man''s Land', 'abeckenhamc', 'Frontier Communications Corporation', 'Buzzdog', 'http://dummyimage.com/103x100.png/ff4444/ffffff', '2024-03-10 13:03:22', '2024-03-21 10:17:48', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (14, 1, null, 'Submarine', 'mlindd', 'CECO Environmental Corp.', 'Zava', 'http://dummyimage.com/238x100.png/ff4444/ffffff', '2024-03-09 16:28:03', '2024-03-04 04:59:15', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (15, 1, null, 'Hard Way, The', 'dhuburne', 'SuperValu Inc.', 'Izio', 'http://dummyimage.com/241x100.png/dddddd/000000', '2024-03-21 21:56:51', '2024-03-23 06:28:12', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (16, 1, null, 'Beerfest', 'epeppinf', 'Galmed Pharmaceuticals Ltd.', 'Meembee', 'http://dummyimage.com/119x100.png/dddddd/000000', '2024-03-01 05:48:07', '2024-03-25 22:05:39', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (17, 1, null, 'Trailer Park Boys: Countdown to Liquor Day', 'bdunstallg', 'CBIZ, Inc.', 'Zoombox', 'http://dummyimage.com/219x100.png/5fa2dd/ffffff', '2024-03-21 17:50:09', '2024-03-25 23:47:07', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (18, 1, null, 'Watcher in the Woods, The', 'tbanbrookh', 'Cushing Renaissance Fund (The)', 'Jamia', 'http://dummyimage.com/144x100.png/cc0000/ffffff', '2024-03-25 07:25:33', '2024-03-19 02:03:49', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (19, 1, null, 'Zouzou', 'dcuddyi', 'Morgan Stanley Emerging Markets Debt Fund, Inc.', 'Zoomdog', 'http://dummyimage.com/201x100.png/dddddd/000000', '2024-03-22 11:21:04', '2024-03-11 01:10:12', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (20, 1, null, 'Movie Days (Bíódagar)', 'flanchberyj', 'Arconic Inc.', 'Flipopia', 'http://dummyimage.com/237x100.png/5fa2dd/ffffff', '2024-03-09 01:08:44', '2024-03-21 00:34:04', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (21, 1, null, 'Mon Oncle Antoine', 'bmeakesk', 'KKR & Co. L.P.', 'Meevee', 'http://dummyimage.com/118x100.png/5fa2dd/ffffff', '2024-03-25 18:52:16', '2024-03-02 13:05:48', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (22, 1, null, 'Bandslam', 'aromneyl', 'Martin Midstream Partners L.P.', 'Midel', 'http://dummyimage.com/126x100.png/cc0000/ffffff', '2024-03-26 14:44:33', '2024-03-17 23:21:25', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (23, 1, null, 'Nil By Mouth', 'mbagniukm', 'BJ''s Restaurants, Inc.', 'Quaxo', 'http://dummyimage.com/142x100.png/cc0000/ffffff', '2024-03-27 21:33:25', '2024-03-17 02:53:59', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (24, 1, null, 'Birds of America', 'mstarfordn', 'Wingstop Inc.', 'Voolia', 'http://dummyimage.com/222x100.png/cc0000/ffffff', '2024-03-05 05:13:12', '2024-03-03 21:03:16', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (25, 1, null, 'Sex Ed', 'boxenhamo', 'Oasmia Pharmaceutical AB', 'Tagpad', 'http://dummyimage.com/207x100.png/ff4444/ffffff', '2024-03-05 20:36:18', '2024-03-27 04:01:20', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (26, 1, null, 'An Amazing Couple', 'mbeekp', 'Kelly Services, Inc.', 'Roombo', 'http://dummyimage.com/244x100.png/5fa2dd/ffffff', '2024-03-16 23:05:46', '2024-03-26 06:17:03', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (27, 1, null, 'House of Cards', 'hbeevorsq', 'Valeant Pharmaceuticals International, Inc.', 'Latz', 'http://dummyimage.com/189x100.png/5fa2dd/ffffff', '2024-03-11 07:56:31', '2024-03-16 16:40:43', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (28, 1, null, 'Calling, The', 'obullentr', 'Amec Plc Ord', 'Buzzster', 'http://dummyimage.com/198x100.png/cc0000/ffffff', '2024-03-01 19:56:07', '2024-03-21 14:44:27', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (29, 1, null, 'Rugrats Go Wild!', 'cvains', 'Applied Industrial Technologies, Inc.', 'Tagfeed', 'http://dummyimage.com/110x100.png/cc0000/ffffff', '2024-03-26 05:43:02', '2024-03-09 14:44:01', null); +insert into book (book_id, member_id, book_item_id, book_title, book_author, book_publisher, book_category, book_cover, created_at, modified_at, deleted_at) values (30, 1, null, 'Postal', 'tmarchellot', 'SMTC Corporation', 'Skalith', 'http://dummyimage.com/118x100.png/ff4444/ffffff', '2024-03-01 09:03:05', '2024-03-17 02:16:17', null); + +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (1, 1, 1, 'Reduced responsive capability', '2024-03-23 12:38:32', '2024-03-11 05:27:00', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (2, 1, 2, 'Progressive discrete function', '2024-03-17 21:47:54', '2024-03-24 03:19:14', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (3, 1, 3, 'Polarised executive alliance', '2024-03-24 05:58:57', '2024-03-09 16:40:30', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (4, 1, 4, 'Visionary demand-driven task-force', '2024-03-01 16:45:31', '2024-03-22 15:01:29', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (5, 1, 5, 'Down-sized secondary extranet', '2024-03-01 00:36:25', '2024-03-13 10:05:25', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (6, 1, 6, 'Adaptive dynamic encryption', '2024-03-11 10:11:26', '2024-03-08 12:31:54', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (7, 1, 7, 'Centralized foreground infrastructure', '2024-03-19 17:21:49', '2024-03-27 22:25:53', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (8, 1, 8, 'Self-enabling attitude-oriented infrastructure', '2024-03-06 12:54:45', '2024-03-11 17:25:18', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (9, 1, 9, 'Synergistic composite collaboration', '2024-03-01 13:34:40', '2024-03-09 02:21:06', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (10, 1, 10, 'Reverse-engineered composite alliance', '2024-03-03 22:52:57', '2024-03-10 13:10:55', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (11, 1, 11, 'User-friendly directional framework', '2024-03-27 08:24:11', '2024-03-16 22:26:56', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (12, 1, 12, 'Vision-oriented web-enabled implementation', '2024-03-04 11:42:17', '2024-03-13 21:51:15', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (13, 1, 13, 'Cross-group responsive system engine', '2024-03-24 02:37:51', '2024-03-21 01:48:50', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (14, 1, 14, 'Programmable 3rd generation complexity', '2024-03-27 21:27:27', '2024-03-05 09:36:17', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (15, 1, 15, 'Reactive demand-driven flexibility', '2024-03-15 16:13:26', '2024-03-01 21:10:14', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (16, 1, 16, 'Expanded client-driven alliance', '2024-03-05 21:17:27', '2024-03-26 15:59:06', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (17, 1, 17, 'Distributed systematic strategy', '2024-03-18 08:15:56', '2024-03-06 13:41:46', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (18, 1, 18, 'Persevering national leverage', '2024-03-17 12:04:24', '2024-03-05 23:20:15', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (19, 1, 19, 'Multi-layered dedicated customer loyalty', '2024-03-19 16:56:50', '2024-03-21 21:40:20', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (20, 1, 20, 'Self-enabling full-range budgetary management', '2024-03-20 02:34:51', '2024-03-26 19:56:27', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (21, 1, 21, 'Intuitive systematic benchmark', '2024-03-14 00:30:15', '2024-03-20 22:50:15', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (22, 1, 22, 'Ergonomic mission-critical service-desk', '2024-03-16 11:53:23', '2024-03-19 10:39:26', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (23, 1, 23, 'Progressive heuristic leverage', '2024-03-04 09:16:11', '2024-03-14 22:24:36', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (24, 1, 24, 'Extended attitude-oriented support', '2024-03-02 13:26:35', '2024-03-22 19:57:15', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (25, 1, 25, 'Monitored dedicated toolset', '2024-03-18 05:57:08', '2024-03-26 22:23:44', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (26, 1, 26, 'Synergistic attitude-oriented firmware', '2024-03-04 02:24:47', '2024-03-01 14:56:16', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (27, 1, 27, 'Ameliorated impactful firmware', '2024-03-24 21:39:35', '2024-03-27 08:10:35', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (28, 1, 28, 'Streamlined mobile capacity', '2024-03-24 12:31:10', '2024-03-11 19:23:06', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (29, 1, 29, 'Cross-group intermediate superstructure', '2024-03-19 07:58:29', '2024-03-17 03:57:50', null); +insert into summary (summary_id, member_id, book_id, summary_content, created_at, modified_at, deleted_at) values (30, 1, 30, 'De-engineered demand-driven open architecture', '2024-03-13 14:06:05', '2024-03-02 14:13:32', null); diff --git a/src/main/resources/templates/authCode.html b/src/main/resources/templates/authCode.html new file mode 100644 index 0000000..da9c93e --- /dev/null +++ b/src/main/resources/templates/authCode.html @@ -0,0 +1,25 @@ + + + + 이메일 인증 코드 + + +
+
+ Logo +
+
+

인증 코드 발급

+

안녕하세요! 리딩 로그 서비스 이용을 위한 인증 코드입니다.

+

아래 코드를 입력하여 이메일 인증을 완료해주세요.

+
+ 인증 코드 +
+
+
+

감사합니다.

+

리딩 로그 팀

+
+
+ + diff --git a/src/main/resources/templates/tempPassword.html b/src/main/resources/templates/tempPassword.html new file mode 100644 index 0000000..1337990 --- /dev/null +++ b/src/main/resources/templates/tempPassword.html @@ -0,0 +1,26 @@ + + + + 임시 비밀번호 안내 + + +
+
+ Logo +
+
+

임시 비밀번호 발급

+

안녕하세요! 리딩 로그 서비스의 보안을 위해 임시 비밀번호를 발급해 드립니다.

+

아래의 임시 비밀번호로 로그인한 후, 반드시 비밀번호를 변경해 주세요.

+
+ 임시 비밀번호 +
+
+
+

비밀번호 변경 후에도 계속해서 문제가 발생한다면, 고객 지원 센터로 문의해 주세요.

+

감사합니다.

+

리딩 로그 팀

+
+
+ +