diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index eaa40ea..d8594bb 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -38,6 +38,9 @@ jobs: touch ./src/main/resources/application-jwt.yml echo "${{ secrets.JWT_YML }}" >> src/main/resources/application-jwt.yml + + touch ./src/main/resources/application-infra.yml + echo "${{ secrets.INFRA_YML }}" >> src/main/resources/application-infra.yml # 빌드 권한 부여 - name: Grant execute permission for gradlew diff --git a/.gitignore b/.gitignore index 369c983..1048269 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ out/ ### Database /src/main/resources/application-db.yml /src/main/resources/application-jwt.yml +/src/main/resources/application-infra.yml diff --git a/build.gradle b/build.gradle index addeb0c..b169cb0 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,21 @@ dependencies { implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + // SMTP + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // commonmark + implementation 'org.commonmark:commonmark:0.22.0' + + // jsoup + implementation 'org.jsoup:jsoup:1.18.1' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/mvp/deplog/domain/auth/application/AuthService.java b/src/main/java/mvp/deplog/domain/auth/application/AuthService.java index 3a95cb3..6bbc0d6 100644 --- a/src/main/java/mvp/deplog/domain/auth/application/AuthService.java +++ b/src/main/java/mvp/deplog/domain/auth/application/AuthService.java @@ -1,8 +1,9 @@ package mvp.deplog.domain.auth.application; -import mvp.deplog.domain.auth.dto.LoginReq; -import mvp.deplog.domain.auth.dto.JoinReq; -import mvp.deplog.domain.auth.dto.LoginRes; +import mvp.deplog.domain.auth.dto.request.LoginReq; +import mvp.deplog.domain.auth.dto.request.JoinReq; +import mvp.deplog.domain.auth.dto.response.EmailDuplicateCheckRes; +import mvp.deplog.domain.auth.dto.response.LoginRes; import mvp.deplog.global.common.Message; import mvp.deplog.global.common.SuccessResponse; @@ -11,4 +12,6 @@ public interface AuthService { SuccessResponse join(JoinReq joinReq); SuccessResponse login(LoginReq loginReq); + + SuccessResponse checkEmailDuplicate(String email); } diff --git a/src/main/java/mvp/deplog/domain/auth/application/AuthServiceImpl.java b/src/main/java/mvp/deplog/domain/auth/application/AuthServiceImpl.java index dbd2fc9..b7f0b1a 100644 --- a/src/main/java/mvp/deplog/domain/auth/application/AuthServiceImpl.java +++ b/src/main/java/mvp/deplog/domain/auth/application/AuthServiceImpl.java @@ -3,14 +3,16 @@ import lombok.RequiredArgsConstructor; import mvp.deplog.domain.auth.domain.RefreshToken; import mvp.deplog.domain.auth.domain.respository.RefreshTokenRepository; -import mvp.deplog.domain.auth.dto.LoginReq; -import mvp.deplog.domain.auth.dto.LoginRes; -import mvp.deplog.domain.auth.dto.JoinReq; +import mvp.deplog.domain.auth.dto.request.LoginReq; +import mvp.deplog.domain.auth.dto.response.EmailDuplicateCheckRes; +import mvp.deplog.domain.auth.dto.response.LoginRes; +import mvp.deplog.domain.auth.dto.request.JoinReq; import mvp.deplog.global.common.Message; import mvp.deplog.global.common.SuccessResponse; import mvp.deplog.domain.member.domain.Member; import mvp.deplog.domain.member.domain.repository.MemberRepository; import mvp.deplog.global.security.jwt.JwtTokenProvider; +import mvp.deplog.infrastructure.redis.RedisUtil; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -25,6 +27,7 @@ @Service public class AuthServiceImpl implements AuthService{ + private final RedisUtil redisUtil; private final AuthenticationManager authenticationManager; private final MemberRepository memberRepository; @@ -35,8 +38,17 @@ public class AuthServiceImpl implements AuthService{ @Override @Transactional public SuccessResponse join(JoinReq joinReq) { + String email = joinReq.getEmail(); + if (memberRepository.existsByEmail(email)) + throw new IllegalArgumentException("이미 가입된 이메일입니다."); + + String data = redisUtil.getData(email + "_verify"); + if (data == null) + throw new IllegalArgumentException("인증이 필요한 이메일입니다."); + redisUtil.deleteData(email + "_verify"); + Member member = Member.builder() - .email(joinReq.getEmail()) + .email(email) .password(passwordEncoder.encode(joinReq.getPassword())) .name(joinReq.getName()) .part(joinReq.getPart()) @@ -93,9 +105,14 @@ public SuccessResponse login(LoginReq loginReq) { .build(); return SuccessResponse.of(loginRes); + } + + @Override + public SuccessResponse checkEmailDuplicate(String email) { + EmailDuplicateCheckRes emailDuplicateCheckRes = EmailDuplicateCheckRes.builder() + .availability(!memberRepository.existsByEmail(email)) + .build(); -// return ResponseEntity -// .status(HttpStatus.CREATED) -// .body(loginRes); + return SuccessResponse.of(emailDuplicateCheckRes); } } diff --git a/src/main/java/mvp/deplog/domain/auth/dto/JoinReq.java b/src/main/java/mvp/deplog/domain/auth/dto/request/JoinReq.java similarity index 96% rename from src/main/java/mvp/deplog/domain/auth/dto/JoinReq.java rename to src/main/java/mvp/deplog/domain/auth/dto/request/JoinReq.java index 785f878..0b94dea 100644 --- a/src/main/java/mvp/deplog/domain/auth/dto/JoinReq.java +++ b/src/main/java/mvp/deplog/domain/auth/dto/request/JoinReq.java @@ -1,4 +1,4 @@ -package mvp.deplog.domain.auth.dto; +package mvp.deplog.domain.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/mvp/deplog/domain/auth/dto/LoginReq.java b/src/main/java/mvp/deplog/domain/auth/dto/request/LoginReq.java similarity index 91% rename from src/main/java/mvp/deplog/domain/auth/dto/LoginReq.java rename to src/main/java/mvp/deplog/domain/auth/dto/request/LoginReq.java index e0ab55e..ccb7992 100644 --- a/src/main/java/mvp/deplog/domain/auth/dto/LoginReq.java +++ b/src/main/java/mvp/deplog/domain/auth/dto/request/LoginReq.java @@ -1,4 +1,4 @@ -package mvp.deplog.domain.auth.dto; +package mvp.deplog.domain.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/mvp/deplog/domain/auth/dto/response/EmailDuplicateCheckRes.java b/src/main/java/mvp/deplog/domain/auth/dto/response/EmailDuplicateCheckRes.java new file mode 100644 index 0000000..e416d83 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/auth/dto/response/EmailDuplicateCheckRes.java @@ -0,0 +1,13 @@ +package mvp.deplog.domain.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class EmailDuplicateCheckRes { + + @Schema(type = "boolean", example = "true", description= "닉네임 **사용 가능 여부**입니다. 값이 true면 중복 x, 사용 가능합니다.") + private boolean availability; +} diff --git a/src/main/java/mvp/deplog/domain/auth/dto/LoginRes.java b/src/main/java/mvp/deplog/domain/auth/dto/response/LoginRes.java similarity index 95% rename from src/main/java/mvp/deplog/domain/auth/dto/LoginRes.java rename to src/main/java/mvp/deplog/domain/auth/dto/response/LoginRes.java index 35089ab..849d71b 100644 --- a/src/main/java/mvp/deplog/domain/auth/dto/LoginRes.java +++ b/src/main/java/mvp/deplog/domain/auth/dto/response/LoginRes.java @@ -1,4 +1,4 @@ -package mvp.deplog.domain.auth.dto; +package mvp.deplog.domain.auth.dto.response; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; diff --git a/src/main/java/mvp/deplog/domain/auth/presentation/AuthApi.java b/src/main/java/mvp/deplog/domain/auth/presentation/AuthApi.java index 74282a6..0d837c3 100644 --- a/src/main/java/mvp/deplog/domain/auth/presentation/AuthApi.java +++ b/src/main/java/mvp/deplog/domain/auth/presentation/AuthApi.java @@ -8,35 +8,64 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import mvp.deplog.domain.auth.dto.LoginReq; -import mvp.deplog.domain.auth.dto.JoinReq; -import mvp.deplog.domain.auth.dto.LoginRes; +import mvp.deplog.domain.auth.dto.request.LoginReq; +import mvp.deplog.domain.auth.dto.request.JoinReq; +import mvp.deplog.domain.auth.dto.response.EmailDuplicateCheckRes; +import mvp.deplog.domain.auth.dto.response.LoginRes; import mvp.deplog.global.common.Message; +import mvp.deplog.global.common.SuccessResponse; +import mvp.deplog.global.exception.ErrorResponse; import org.springframework.http.ResponseEntity; -import org.springframework.web.ErrorResponse; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.*; @Tag(name = "Auth API", description = "인증 관련 API입니다.") public interface AuthApi { @Operation(summary = "회원 가입 API", description = "회원 가입을 진행합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "회원가입 성공", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Message.class))}), - @ApiResponse(responseCode = "400", description = "회원가입 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse( + responseCode = "201", description = "회원가입 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Message.class))} + ), + @ApiResponse( + responseCode = "400", description = "회원가입 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) }) @PostMapping(value = "/join") - ResponseEntity join( + ResponseEntity> join( @Parameter(description = "Schemas의 SignUpRequest를 참고해주세요.", required = true) @Valid @RequestBody JoinReq joinReq ); @Operation(summary = "로그인 API", description = "로그인을 진행합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그인 성공", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = LoginRes.class))}), - @ApiResponse(responseCode = "400", description = "로그인 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), + @ApiResponse( + responseCode = "200", description = "로그인 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = LoginRes.class))} + ), + @ApiResponse( + responseCode = "400", description = "로그인 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) }) @PostMapping(value = "/login") - ResponseEntity login( + ResponseEntity> login( @Parameter(description = "Schemas의 SignUpRequest를 참고해주세요.", required = true) @Valid @RequestBody LoginReq loginReq ); + + @Operation(summary = "이메일 중복 체크 API", description = "이메일 중복 여부를 체크합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "이메일 중복 체크 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = EmailDuplicateCheckRes.class))} + ), + @ApiResponse( + responseCode = "400", description = "이메일 중복 체크 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + @GetMapping(value = "/emails") + ResponseEntity> checkEmailDuplicate ( + @Parameter(description = "검사할 이메일을 입력해주세요.", required = true) @RequestParam(value = "email") String email + ); } diff --git a/src/main/java/mvp/deplog/domain/auth/presentation/AuthController.java b/src/main/java/mvp/deplog/domain/auth/presentation/AuthController.java index ad05d8b..2f7cd9b 100644 --- a/src/main/java/mvp/deplog/domain/auth/presentation/AuthController.java +++ b/src/main/java/mvp/deplog/domain/auth/presentation/AuthController.java @@ -1,23 +1,22 @@ package mvp.deplog.domain.auth.presentation; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import mvp.deplog.domain.auth.application.AuthServiceImpl; -import mvp.deplog.domain.auth.dto.LoginReq; -import mvp.deplog.domain.auth.dto.JoinReq; -import mvp.deplog.domain.auth.dto.LoginRes; +import mvp.deplog.domain.auth.dto.request.LoginReq; +import mvp.deplog.domain.auth.dto.request.JoinReq; +import mvp.deplog.domain.auth.dto.response.EmailDuplicateCheckRes; +import mvp.deplog.domain.auth.dto.response.LoginRes; import mvp.deplog.global.common.Message; import mvp.deplog.global.common.SuccessResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -@Tag(name = "Auth API", description = "Authorization 관련 API입니다.") @RequiredArgsConstructor @RestController @RequestMapping("/auth") -public class AuthController implements AuthApi{ +public class AuthController implements AuthApi { private final AuthServiceImpl authServiceImpl; @@ -34,4 +33,10 @@ public ResponseEntity> join(@Valid @RequestBody JoinReq public ResponseEntity> login(@Valid @RequestBody LoginReq loginReq) { return ResponseEntity.ok(authServiceImpl.login(loginReq)); } + + @Override + @GetMapping(value = "/emails") + public ResponseEntity> checkEmailDuplicate(@RequestParam(value = "email") String email) { + return ResponseEntity.ok(authServiceImpl.checkEmailDuplicate(email)); + } } diff --git a/src/main/java/mvp/deplog/domain/block/domain/Block.java b/src/main/java/mvp/deplog/domain/block/domain/Block.java deleted file mode 100644 index 520d839..0000000 --- a/src/main/java/mvp/deplog/domain/block/domain/Block.java +++ /dev/null @@ -1,43 +0,0 @@ -package mvp.deplog.domain.block.domain; - -import jakarta.persistence.*; -import jakarta.validation.constraints.Min; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import mvp.deplog.domain.common.BaseEntity; -import mvp.deplog.domain.post.domain.Post; - -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Getter -@Table(name = "block") -public class Block extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id", nullable = false) - private Post post; - - @Column(name = "content", columnDefinition = "TEXT") - private String content; // 내용 or 이미지 url - - @Column(name = "sequence", columnDefinition = "INT", nullable = false) - @Min(value = 1) - private int sequence; - - @Column(name = "is_image", nullable = false) - private boolean isImage; - - @Builder - public Block(Post post, String content, int sequence, boolean isImage) { - this.post = post; - this.content = content; - this.sequence = sequence; - this.isImage = isImage; - } -} diff --git a/src/main/java/mvp/deplog/domain/comment/application/CommentService.java b/src/main/java/mvp/deplog/domain/comment/application/CommentService.java new file mode 100644 index 0000000..cf848d8 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/comment/application/CommentService.java @@ -0,0 +1,58 @@ +package mvp.deplog.domain.comment.application; + +import lombok.RequiredArgsConstructor; +import mvp.deplog.domain.comment.domain.Comment; +import mvp.deplog.domain.comment.domain.repository.CommentRepository; +import mvp.deplog.domain.comment.dto.request.CommentReq; +import mvp.deplog.domain.comment.dto.response.CommentRes; +import mvp.deplog.domain.post.domain.Post; +import mvp.deplog.domain.post.domain.repository.PostRepository; +import mvp.deplog.global.common.Message; +import mvp.deplog.global.common.SuccessResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @Transactional + public SuccessResponse createComment(CommentReq commentReq) { + Long postId = commentReq.getPostId(); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 아이디의 게시글을 찾을 수 없습니다: " + postId)); + + Comment comment; + if(commentReq.getParentCommentId() == null){ + comment = Comment.commentBuilder() + .post(post) + .content(commentReq.getContent()) + .nickname(commentReq.getNickname()) + .build(); + } + else{ + Long parentCommentId = commentReq.getParentCommentId(); + Comment parentComment = commentRepository.findById(parentCommentId) + .orElseThrow(() -> new IllegalArgumentException("해당 아이디의 부모 댓글을 찾을 수 없습니다: " + parentCommentId)); + comment = Comment.replyBuilder() + .post(post) + .parentComment(parentComment) + .content(commentReq.getContent()) + .nickname(commentReq.getNickname()) + .build(); + } + + // 댓글 저장 + Comment saveComment = commentRepository.save(comment); + + Message message = Message.builder() + .message("댓글 작성이 완료되었습니다.") + .build(); + + return SuccessResponse.of(message); + } +} diff --git a/src/main/java/mvp/deplog/domain/comment/domain/Comment.java b/src/main/java/mvp/deplog/domain/comment/domain/Comment.java index 4baa99d..6f348d0 100644 --- a/src/main/java/mvp/deplog/domain/comment/domain/Comment.java +++ b/src/main/java/mvp/deplog/domain/comment/domain/Comment.java @@ -37,7 +37,7 @@ public class Comment extends BaseEntity { @Min(value = 1) private int depth; - @Builder(builderMethodName = "commentBuilder") + @Builder(builderMethodName = "commentBuilder", builderClassName = "commentBuilder") public Comment(Post post, String content, String nickname) { this.parentComment = null; this.post = post; @@ -46,7 +46,7 @@ public Comment(Post post, String content, String nickname) { this.depth = 1; } - @Builder(builderMethodName = "replyBuilder") + @Builder(builderMethodName = "replyBuilder", builderClassName = "replyBuilder") public Comment(Comment parentComment, Post post, String content, String nickname) { this.parentComment = parentComment; this.post = post; diff --git a/src/main/java/mvp/deplog/domain/comment/domain/repository/CommentRepository.java b/src/main/java/mvp/deplog/domain/comment/domain/repository/CommentRepository.java new file mode 100644 index 0000000..26a7269 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/comment/domain/repository/CommentRepository.java @@ -0,0 +1,7 @@ +package mvp.deplog.domain.comment.domain.repository; + +import mvp.deplog.domain.comment.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { +} diff --git a/src/main/java/mvp/deplog/domain/comment/dto/request/CommentReq.java b/src/main/java/mvp/deplog/domain/comment/dto/request/CommentReq.java new file mode 100644 index 0000000..579ec14 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/comment/dto/request/CommentReq.java @@ -0,0 +1,21 @@ +package mvp.deplog.domain.comment.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import mvp.deplog.domain.comment.domain.Comment; + +@Data +public class CommentReq { + + @Schema(type = "Long", example = "1", description = "부모 댓글 아이디입니다. 대댓글 작성 시 사용됩니다.") + private Long parentCommentId; + + @Schema(type = "Long", example = "1", description = "게시글 아이디입니다.") + private Long postId; + + @Schema(type = "String", example = "**댓글 내용**", description = "댓글 내용입니다.") + private String content; + + @Schema(type = "String", example = "닉네임", description = "닉네임입니다.") + private String nickname; +} diff --git a/src/main/java/mvp/deplog/domain/comment/dto/response/CommentRes.java b/src/main/java/mvp/deplog/domain/comment/dto/response/CommentRes.java new file mode 100644 index 0000000..0fe2525 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/comment/dto/response/CommentRes.java @@ -0,0 +1,14 @@ +package mvp.deplog.domain.comment.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CommentRes { + + // 게시글 상세 내용 불러올 때 사용하도록 추후에 클래스 명 수정 + @Schema(type = "Long", example = "1", description = "댓글 아이디를 반환합니다.") + private Long commentId; +} diff --git a/src/main/java/mvp/deplog/domain/comment/presentation/CommentApi.java b/src/main/java/mvp/deplog/domain/comment/presentation/CommentApi.java new file mode 100644 index 0000000..445558c --- /dev/null +++ b/src/main/java/mvp/deplog/domain/comment/presentation/CommentApi.java @@ -0,0 +1,36 @@ +package mvp.deplog.domain.comment.presentation; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 mvp.deplog.domain.comment.dto.request.CommentReq; +import mvp.deplog.domain.comment.dto.response.CommentRes; +import mvp.deplog.global.common.Message; +import mvp.deplog.global.common.SuccessResponse; +import mvp.deplog.global.exception.ErrorResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Comment API", description = "댓글 관련 API입니다.") +public interface CommentApi { + + @Operation(summary = "댓글 작성 API", description = "댓글 작성을 진행합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "댓글 작성 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = CommentRes.class))} + ), + @ApiResponse( + responseCode = "400", description = "댓글 작성 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + @PostMapping + ResponseEntity> createComment( + @Parameter(description = "Schemas의 CommentReq를 참고해주세요.", required = true) @RequestBody CommentReq commentReq); +} diff --git a/src/main/java/mvp/deplog/domain/comment/presentation/CommentController.java b/src/main/java/mvp/deplog/domain/comment/presentation/CommentController.java new file mode 100644 index 0000000..13aeba5 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/comment/presentation/CommentController.java @@ -0,0 +1,29 @@ +package mvp.deplog.domain.comment.presentation; + +import lombok.RequiredArgsConstructor; +import mvp.deplog.domain.comment.application.CommentService; +import mvp.deplog.domain.comment.dto.request.CommentReq; +import mvp.deplog.domain.comment.dto.response.CommentRes; +import mvp.deplog.global.common.Message; +import mvp.deplog.global.common.SuccessResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/comments") +public class CommentController implements CommentApi { + + private final CommentService commentService; + + @PostMapping + public ResponseEntity> createComment(@RequestBody CommentReq commentReq) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(commentService.createComment(commentReq)); + } +} diff --git a/src/main/java/mvp/deplog/domain/member/domain/repository/MemberRepository.java b/src/main/java/mvp/deplog/domain/member/domain/repository/MemberRepository.java index 8b16370..963dff3 100644 --- a/src/main/java/mvp/deplog/domain/member/domain/repository/MemberRepository.java +++ b/src/main/java/mvp/deplog/domain/member/domain/repository/MemberRepository.java @@ -8,4 +8,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); + + boolean existsByEmail(String email); } diff --git a/src/main/java/mvp/deplog/domain/post/application/PostService.java b/src/main/java/mvp/deplog/domain/post/application/PostService.java new file mode 100644 index 0000000..63037c2 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/post/application/PostService.java @@ -0,0 +1,65 @@ +package mvp.deplog.domain.post.application; + +import lombok.RequiredArgsConstructor; +import mvp.deplog.domain.member.domain.Member; +import mvp.deplog.domain.post.domain.Post; +import mvp.deplog.domain.post.domain.Stage; +import mvp.deplog.domain.post.domain.repository.PostRepository; +import mvp.deplog.domain.post.dto.response.CreatePostRes; +import mvp.deplog.domain.post.dto.request.PostReq; +import mvp.deplog.domain.tag.domain.Tag; +import mvp.deplog.domain.tag.domain.repository.TagRepository; +import mvp.deplog.domain.tagging.Tagging; +import mvp.deplog.domain.tagging.repository.TaggingRepository; +import mvp.deplog.global.common.SuccessResponse; +import mvp.deplog.infrastructure.markdown.MarkdownUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class PostService { + + private final PostRepository postRepository; + private final TagRepository tagRepository; + private final TaggingRepository taggingRepository; + + @Transactional + public SuccessResponse createPost(Member member, PostReq postReq) { + + String content = postReq.getContent(); + String previewContent = MarkdownUtil.extractPreviewContent(content); + String previewImage = MarkdownUtil.extractPreviewImage(content); + + Post post = Post.builder() + .member(member) + .title(postReq.getTitle()) + .content(content) + .previewContent(previewContent) + .previewImage(previewImage) + .stage(Stage.PUBLISHED) + .build(); + // 게시글 저장 + Post savePost = postRepository.save(post); + + // 태그 저장 & Tagging 엔티티 연결 + for(String tagName : postReq.getTagNameList()) { + Tag tag = tagRepository.findByName(tagName) + .orElseGet(() -> + tagRepository.save(Tag.builder().name(tagName).build())); + + Tagging tagging = Tagging.builder() + .post(post) + .tag(tag) + .build(); + taggingRepository.save(tagging); + } + + CreatePostRes createPostRes = CreatePostRes.builder() + .postId(savePost.getId()) + .build(); + + return SuccessResponse.of(createPostRes); + } +} diff --git a/src/main/java/mvp/deplog/domain/post/domain/Post.java b/src/main/java/mvp/deplog/domain/post/domain/Post.java index adef3e3..9f9a610 100644 --- a/src/main/java/mvp/deplog/domain/post/domain/Post.java +++ b/src/main/java/mvp/deplog/domain/post/domain/Post.java @@ -26,6 +26,15 @@ public class Post extends BaseEntity { @Column(name = "title") // null 가능 - 임시 저장 private String title; + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "preview_content") + private String previewContent; + + @Column(name = "preview_image", columnDefinition = "TEXT") + private String previewImage; + @Column(name = "like_count") @Min(value = 0) private Integer likeCount; @@ -43,9 +52,12 @@ public class Post extends BaseEntity { private Stage stage; @Builder - public Post(Member member, String title, Stage stage) { + public Post(Member member, String content, String previewContent, String previewImage, String title, Stage stage) { this.member = member; this.title = title; + this.content = content; + this.previewContent = previewContent; + this.previewImage = previewImage; this.likeCount = 0; this.scrapCount = 0; this.viewCount = 0; diff --git a/src/main/java/mvp/deplog/domain/post/domain/repository/PostRepository.java b/src/main/java/mvp/deplog/domain/post/domain/repository/PostRepository.java new file mode 100644 index 0000000..5258230 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/post/domain/repository/PostRepository.java @@ -0,0 +1,8 @@ +package mvp.deplog.domain.post.domain.repository; + +import mvp.deplog.domain.post.domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { + +} diff --git a/src/main/java/mvp/deplog/domain/post/dto/request/PostReq.java b/src/main/java/mvp/deplog/domain/post/dto/request/PostReq.java new file mode 100644 index 0000000..2a0677e --- /dev/null +++ b/src/main/java/mvp/deplog/domain/post/dto/request/PostReq.java @@ -0,0 +1,19 @@ +package mvp.deplog.domain.post.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class PostReq { + + @Schema(type = "String", example = "게시글 제목", description= "게시글 제목입니다.") + private String title; + + @Schema(type = "String", example = "**게시글 내용**", description= "게시글 내용입니다. 마크다운 형식으로 저장됩니다.") + private String content; + + @Schema(type = "List", example = "[Spring, React, JS]", description= "태그 이름 리스트입니다.") + private List tagNameList; +} diff --git a/src/main/java/mvp/deplog/domain/post/dto/response/CreatePostRes.java b/src/main/java/mvp/deplog/domain/post/dto/response/CreatePostRes.java new file mode 100644 index 0000000..9153f1b --- /dev/null +++ b/src/main/java/mvp/deplog/domain/post/dto/response/CreatePostRes.java @@ -0,0 +1,11 @@ +package mvp.deplog.domain.post.dto.response; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CreatePostRes { + + private Long postId; +} diff --git a/src/main/java/mvp/deplog/domain/post/presentation/PostApi.java b/src/main/java/mvp/deplog/domain/post/presentation/PostApi.java new file mode 100644 index 0000000..380366e --- /dev/null +++ b/src/main/java/mvp/deplog/domain/post/presentation/PostApi.java @@ -0,0 +1,40 @@ +package mvp.deplog.domain.post.presentation; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 mvp.deplog.domain.post.dto.response.CreatePostRes; +import mvp.deplog.domain.post.dto.request.PostReq; +import mvp.deplog.global.common.Message; +import mvp.deplog.global.common.SuccessResponse; +import mvp.deplog.global.exception.ErrorResponse; +import mvp.deplog.global.security.UserDetailsImpl; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Post API", description = "게시글 관련 API입니다.") +public interface PostApi { + + @Operation(summary = "게시글 작성 API", description = "게시글 작성을 진행합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "게시글 작성 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Message.class))} + ), + @ApiResponse( + responseCode = "400", description = "게시글 작성 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + @PostMapping + ResponseEntity> createPost( + @Parameter(description = "Access Token을 입력해주세요.", required = true) @AuthenticationPrincipal UserDetailsImpl userDetails, + @Parameter(description = "Schemas의 PostReq를 참고해주세요.", required = true) @RequestBody PostReq postReq + ); +} diff --git a/src/main/java/mvp/deplog/domain/post/presentation/PostController.java b/src/main/java/mvp/deplog/domain/post/presentation/PostController.java new file mode 100644 index 0000000..171901f --- /dev/null +++ b/src/main/java/mvp/deplog/domain/post/presentation/PostController.java @@ -0,0 +1,31 @@ +package mvp.deplog.domain.post.presentation; + +import lombok.RequiredArgsConstructor; +import mvp.deplog.domain.post.application.PostService; +import mvp.deplog.domain.post.dto.response.CreatePostRes; +import mvp.deplog.domain.post.dto.request.PostReq; +import mvp.deplog.global.common.SuccessResponse; +import mvp.deplog.global.security.UserDetailsImpl; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/posts") +public class PostController implements PostApi { + + private final PostService postService; + + @Override + @PostMapping + public ResponseEntity> createPost(@AuthenticationPrincipal UserDetailsImpl userDetails, @RequestBody PostReq postReq) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(postService.createPost(userDetails.getMember(), postReq)); + } +} diff --git a/src/main/java/mvp/deplog/domain/tag/domain/repository/TagRepository.java b/src/main/java/mvp/deplog/domain/tag/domain/repository/TagRepository.java new file mode 100644 index 0000000..ac54eb1 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/tag/domain/repository/TagRepository.java @@ -0,0 +1,10 @@ +package mvp.deplog.domain.tag.domain.repository; + +import mvp.deplog.domain.tag.domain.Tag; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TagRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/src/main/java/mvp/deplog/domain/tagging/repository/TaggingRepository.java b/src/main/java/mvp/deplog/domain/tagging/repository/TaggingRepository.java new file mode 100644 index 0000000..60a8d56 --- /dev/null +++ b/src/main/java/mvp/deplog/domain/tagging/repository/TaggingRepository.java @@ -0,0 +1,7 @@ +package mvp.deplog.domain.tagging.repository; + +import mvp.deplog.domain.tagging.Tagging; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TaggingRepository extends JpaRepository { +} diff --git a/src/main/java/mvp/deplog/global/config/SecurityConfig.java b/src/main/java/mvp/deplog/global/config/SecurityConfig.java index f3cf1fe..82025cf 100644 --- a/src/main/java/mvp/deplog/global/config/SecurityConfig.java +++ b/src/main/java/mvp/deplog/global/config/SecurityConfig.java @@ -35,7 +35,7 @@ public class SecurityConfig { private final RefreshTokenRepository refreshTokenRepository; private static final String[] WHITE_LIST = { - "/swagger", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/auth/**", "/test" + "/swagger", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/auth/**", "/test", "/mails/**", "/verification-email" }; @Bean diff --git a/src/main/java/mvp/deplog/global/exception/ErrorCode.java b/src/main/java/mvp/deplog/global/exception/ErrorCode.java index e490187..dac8571 100644 --- a/src/main/java/mvp/deplog/global/exception/ErrorCode.java +++ b/src/main/java/mvp/deplog/global/exception/ErrorCode.java @@ -79,7 +79,11 @@ public enum ErrorCode { // Business Error BUSINESS_EXCEPTION_ERROR(400, "B999", "Business Exception Error"), - NETWORK_AUTHENTICATION_REQUIRED(511, "B998", "Network Authentication Required"); + NETWORK_AUTHENTICATION_REQUIRED(511, "B998", "Network Authentication Required"), + + ILLEGAL_ARGUMENT_EXCEPTION_ERROR(400, "B997", "Illegal Argument Exception Error"), + + ARRAY_INDEX_OUT_OF_BOUNDS_ERROR(400, "B996", "Array Index Out of Bounds Error") ; diff --git a/src/main/java/mvp/deplog/global/exception/GlobalExceptionHandler.java b/src/main/java/mvp/deplog/global/exception/GlobalExceptionHandler.java index bc1fb87..d074372 100644 --- a/src/main/java/mvp/deplog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/mvp/deplog/global/exception/GlobalExceptionHandler.java @@ -18,7 +18,9 @@ import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.servlet.NoHandlerFoundException; +import java.io.FileNotFoundException; import java.io.IOException; +import java.net.MalformedURLException; @Slf4j @RestControllerAdvice @@ -75,7 +77,6 @@ protected ResponseEntity handleMissingRequestHeaderExceptionExcep return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } - // [Exception] 잘못된 서버 요청일 경우 발생한 경우 @ExceptionHandler(HttpClientErrorException.BadRequest.class) protected ResponseEntity handleBadRequestException(HttpClientErrorException e) { @@ -84,7 +85,6 @@ protected ResponseEntity handleBadRequestException(HttpClientErro return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } - // [Exception] 잘못된 주소로 요청 한 경우 @ExceptionHandler(NoHandlerFoundException.class) protected ResponseEntity handleNoHandlerFoundExceptionException(NoHandlerFoundException e) { @@ -93,7 +93,6 @@ protected ResponseEntity handleNoHandlerFoundExceptionException(N return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); } - // [Exception] NULL 값이 발생한 경우 @ExceptionHandler(NullPointerException.class) protected ResponseEntity handleNullPointerException(NullPointerException e) { @@ -122,11 +121,18 @@ public ResponseEntity handleDataIntegrityViolationException(DataI @ExceptionHandler(IOException.class) protected ResponseEntity handleIOException(IOException ex) { log.error("handleIOException", ex); + + // 요청한 파일을 찾을 수 없거나 잘못된 URL 형식이 사용되었을 경우 400 + HttpStatus status; + if (ex instanceof FileNotFoundException || ex instanceof MalformedURLException) { + status = HttpStatus.BAD_REQUEST; + } else { + status = HttpStatus.INTERNAL_SERVER_ERROR; + } final ErrorResponse response = ErrorResponse.of(ErrorCode.IO_ERROR, ex.getMessage()); - return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + return new ResponseEntity<>(response, status); } - // com.google.gson 내에 Exception 발생하는 경우 @ExceptionHandler(JsonParseException.class) protected ResponseEntity handleJsonParseExceptionException(JsonParseException ex) { @@ -143,6 +149,21 @@ protected ResponseEntity handleJsonProcessingException(JsonProces return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } + // [Exception] 메소드에 전달된 인수가 유효하지 않은 경우 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { + log.error("handleIllegalArgumentException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.ILLEGAL_ARGUMENT_EXCEPTION_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + // [Exception] 배열의 잘못된 인덱스에 접근할 경우 + @ExceptionHandler(ArrayIndexOutOfBoundsException.class) + protected ResponseEntity handleArrayIndexOutOfBoundsException(ArrayIndexOutOfBoundsException ex) { + log.error("handleArrayIndexOutOfBoundsException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.ARRAY_INDEX_OUT_OF_BOUNDS_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } // [Exception] 모든 Exception 경우 발생 @ExceptionHandler(Exception.class) diff --git a/src/main/java/mvp/deplog/infrastructure/mail/MailConfig.java b/src/main/java/mvp/deplog/infrastructure/mail/MailConfig.java new file mode 100644 index 0000000..4bd1592 --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/mail/MailConfig.java @@ -0,0 +1,64 @@ +package mvp.deplog.infrastructure.mail; + +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; + +import java.util.Properties; + +@Configuration +public class MailConfig { + + @Value("${spring.mail.host}") + private String mailServerHost; + + @Value("${spring.mail.port}") + private int mailServerPort; + + @Value("${spring.mail.username}") + private String mailServerUsername; + + @Value("${spring.mail.password}") + private String mailServerPassword; + + @Value("${spring.mail.properties.mail.debug}") + private boolean mailDebug; + + @Value("${spring.mail.properties.mail.smtp.auth}") + private boolean auth; + + @Value("${spring.mail.properties.mail.smtp.starttls.enable}") + private boolean starttlsEnable; + + @Value("${spring.mail.properties.mail.smtp.starttls.required}") + private boolean starttlsRequired; + + @Value("${spring.mail.properties.mail.smtp.ssl.enable}") + private boolean sslEnable; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(mailServerHost); + mailSender.setPort(mailServerPort); + mailSender.setUsername(mailServerUsername); + mailSender.setPassword(mailServerPassword); + mailSender.setJavaMailProperties(getMailProperties()); + + return mailSender; + } + + private Properties getMailProperties() { + Properties properties = new Properties(); + properties.put("mail.transport.protocol", "smtp"); + properties.put("mail.debug", mailDebug); + properties.put("mail.smtp.auth", auth); + properties.put("mail.smtp.starttls.enable", starttlsEnable); + properties.put("mail.smtp.starttls.required", starttlsRequired); + properties.put("mail.smtp.ssl.enable", sslEnable); + + return properties; + } +} diff --git a/src/main/java/mvp/deplog/infrastructure/mail/MailUtil.java b/src/main/java/mvp/deplog/infrastructure/mail/MailUtil.java new file mode 100644 index 0000000..407d880 --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/mail/MailUtil.java @@ -0,0 +1,63 @@ +package mvp.deplog.infrastructure.mail; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +import java.security.SecureRandom; + +@RequiredArgsConstructor +@Component +public class MailUtil { + + private final String SUBJECT = "Deplog 인증 링크입니다."; + private final String SENDER_NAME = "DEPlog"; + + @Value("${spring.mail.username}") + private String fromEmail; + + @Value("${example.verify.url}") + private String verificationUrl; + + @Value("${example.verify.verification.email}") + private String templateUrl; + + private final JavaMailSender mailSender; + private final SpringTemplateEngine templateEngine; + + public MimeMessage createMessage(String code, String email) throws MessagingException { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "utf-8"); + helper.setTo(email); + helper.setSubject(SUBJECT); + helper.setFrom(fromEmail); + helper.setText(setContext(code), true); // 'true' indicates HTML content + + return mimeMessage; + } + + // Description : 코드 생성 함수 (00000000 ~ zzzzzzzz) (8자리) + public String generateCode() { + SecureRandom random = new SecureRandom(); + // nextLong(long bound) : 0(포함)부터 입력된 bound(미포함) 사이의 랜덤 정수를 반환 + long randomNumber = random.nextLong(2821109907455L + 1); + String code = Long.toString(randomNumber, 36); + code = String.format("%8s", code).replace(' ', '0'); + + return code; + } + + // 타임리프를 이용한 context 설정 + public String setContext(String code) { + Context context = new Context(); + context.setVariable("code", code); + context.setVariable("verificationUrl", verificationUrl); + return templateEngine.process(templateUrl, context); + } +} diff --git a/src/main/java/mvp/deplog/infrastructure/mail/application/MailService.java b/src/main/java/mvp/deplog/infrastructure/mail/application/MailService.java new file mode 100644 index 0000000..e6c5877 --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/mail/application/MailService.java @@ -0,0 +1,58 @@ +package mvp.deplog.infrastructure.mail.application; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import mvp.deplog.domain.member.domain.repository.MemberRepository; +import mvp.deplog.global.common.SuccessResponse; +import mvp.deplog.infrastructure.mail.dto.MailCodeRes; +import mvp.deplog.infrastructure.mail.MailUtil; +import mvp.deplog.infrastructure.redis.RedisUtil; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.thymeleaf.spring6.SpringTemplateEngine; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class MailService { + + private final String VERIFY_SUFFIX = "_verify"; + + private final JavaMailSender mailSender; + private final SpringTemplateEngine templateEngine; + private final RedisUtil redisUtil; + private final MailUtil mailUtil; + + private final MemberRepository memberRepository; + + public SuccessResponse sendMail(String email) throws Exception { + if (memberRepository.existsByEmail(email)) + throw new IllegalArgumentException("이미 가입된 이메일입니다."); + String code = mailUtil.generateCode(); + redisUtil.setDataExpire(code, email, 60 * 3L); + + MimeMessage mimeMessage = mailUtil.createMessage(code, email); + mailSender.send(mimeMessage); + + MailCodeRes mailCodeRes = MailCodeRes.builder() + .code(code) + .build(); + + return SuccessResponse.of(mailCodeRes); + } + + public void verifyCode(String code) { + String email = redisUtil.getData(code); + if (email == null) + throw new IllegalArgumentException("유효하지 않은 코드입니다."); + redisUtil.deleteData(code); + redisUtil.setDataExpire(email + VERIFY_SUFFIX, String.valueOf(true), 60 * 60L); + +// Message message = Message.builder() +// .message("이메일 인증이 완료되었습니다.") +// .build(); +// +// return SuccessResponse.of(message); + } +} diff --git a/src/main/java/mvp/deplog/infrastructure/mail/dto/MailCodeRes.java b/src/main/java/mvp/deplog/infrastructure/mail/dto/MailCodeRes.java new file mode 100644 index 0000000..69c4db5 --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/mail/dto/MailCodeRes.java @@ -0,0 +1,13 @@ +package mvp.deplog.infrastructure.mail.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class MailCodeRes { + + @Schema(type = "string", example = "abcd1234", description = "인증 코드를 출력합니다. 이메일 인증 시 사용자에게 발급되는 임시 키 값입니다.") + private String code; +} diff --git a/src/main/java/mvp/deplog/infrastructure/mail/presentation/MailApi.java b/src/main/java/mvp/deplog/infrastructure/mail/presentation/MailApi.java new file mode 100644 index 0000000..9f530db --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/mail/presentation/MailApi.java @@ -0,0 +1,59 @@ +package mvp.deplog.infrastructure.mail.presentation; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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 jakarta.servlet.http.HttpServletResponse; +import mvp.deplog.global.common.Message; +import mvp.deplog.global.common.SuccessResponse; +import mvp.deplog.global.exception.ErrorResponse; +import mvp.deplog.infrastructure.mail.dto.MailCodeRes; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.view.RedirectView; + +import java.io.IOException; + +@Tag(name = "Mail API", description = "메일 관련 API입니다.") +public interface MailApi { + + @Operation(summary = "메일 발송 API", description = "메일을 발송합니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "메일 발송 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = MailCodeRes.class))} + ), + @ApiResponse( + responseCode = "400", description = "메일 발송 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + @GetMapping + ResponseEntity> sendMail( + @Parameter(description = "이메일을 입력해주세요.", required = true) @RequestParam String email + ) throws Exception; + + @Hidden + @Operation(summary = "메일 검증 API", description = "메일을 검증합니다. 사용자가 링크 클릭 시 호출됩니다.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "메일 검증 성공", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Message.class))} + ), + @ApiResponse( + responseCode = "400", description = "메일 검증 실패", + content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))} + ) + }) + @GetMapping(value = "/verify") + void verify( + @Parameter(description = "메일 발송 시 응답받은 코드를 사용해주세요.", required = true) @RequestParam String code, + HttpServletResponse response + ) throws IOException; +} diff --git a/src/main/java/mvp/deplog/infrastructure/mail/presentation/MailController.java b/src/main/java/mvp/deplog/infrastructure/mail/presentation/MailController.java new file mode 100644 index 0000000..9da88b9 --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/mail/presentation/MailController.java @@ -0,0 +1,33 @@ +package mvp.deplog.infrastructure.mail.presentation; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import mvp.deplog.global.common.SuccessResponse; +import mvp.deplog.infrastructure.mail.dto.MailCodeRes; +import mvp.deplog.infrastructure.mail.application.MailService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + +@RequiredArgsConstructor +@RestController +@RequestMapping(value = "/mails") +public class MailController implements MailApi { + + private final MailService mailService; + + @Override + @GetMapping + public ResponseEntity> sendMail(@RequestParam String email) throws Exception { + return ResponseEntity.ok(mailService.sendMail(email)); + } + + @Override + @GetMapping(value = "/verify") + public void verify(@RequestParam String code, HttpServletResponse response) throws IOException { + mailService.verifyCode(code); + String redirect_uri = "http://www.naver.com"; + response.sendRedirect(redirect_uri); + } +} diff --git a/src/main/java/mvp/deplog/infrastructure/markdown/MarkdownUtil.java b/src/main/java/mvp/deplog/infrastructure/markdown/MarkdownUtil.java new file mode 100644 index 0000000..3270454 --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/markdown/MarkdownUtil.java @@ -0,0 +1,68 @@ +package mvp.deplog.infrastructure.markdown; + +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.util.ArrayList; +import java.util.List; + +public class MarkdownUtil { + + private static final String EMPTY_STRING = ""; + + // Markdown -> (Html) -> Text + public static String extractPlainText(String markdown) { + String html = markdownToHtml(markdown); + String text = Jsoup.parse(html).text(); + + return text.isEmpty() ? EMPTY_STRING : text; + } + + // Markdown -> (Html) -> ImageUrl list + public static List extractImageLinks(String markdown) { + String html = markdownToHtml(markdown); + + Document document = Jsoup.parse(html); + Elements imgElements = document.select("img"); + List imageLinks = new ArrayList<>(); + for (Element imgElement : imgElements) { + imageLinks.add(imgElement.attr("src")); + } + return imageLinks; + } + + // Markdown -> (Html) -> Text -> Preview Content + public static String extractPreviewContent(String content) { + if (content == null || content.isEmpty()) + return EMPTY_STRING; + + String plainText = extractPlainText(content); + if (plainText.length() > 100) + return plainText.substring(0, 100); + else + return plainText; + } + + // Markdown -> (Html) -> First ImageUrl + public static String extractPreviewImage(String markdown) { + String html = markdownToHtml(markdown); + + Document document = Jsoup.parse(html); + Elements imgElements = document.select("img"); + + return imgElements.isEmpty() ? EMPTY_STRING : imgElements.get(0).attr("src"); + } + + // Markdown -> Html + public static String markdownToHtml(String markdown) { + Parser parser = Parser.builder().build(); + Node document = parser.parse(markdown); + HtmlRenderer renderer = HtmlRenderer.builder().build(); + return renderer.render(document); + } +} diff --git a/src/main/java/mvp/deplog/infrastructure/redis/RedisConfig.java b/src/main/java/mvp/deplog/infrastructure/redis/RedisConfig.java new file mode 100644 index 0000000..814beb8 --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/redis/RedisConfig.java @@ -0,0 +1,33 @@ +package mvp.deplog.infrastructure.redis; + +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; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password}") + private String redisPassword; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory((redisConnectionFactory())); + return redisTemplate; + } +} diff --git a/src/main/java/mvp/deplog/infrastructure/redis/RedisUtil.java b/src/main/java/mvp/deplog/infrastructure/redis/RedisUtil.java new file mode 100644 index 0000000..eb54ffe --- /dev/null +++ b/src/main/java/mvp/deplog/infrastructure/redis/RedisUtil.java @@ -0,0 +1,39 @@ +package mvp.deplog.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@RequiredArgsConstructor +@Component +public class RedisUtil { + + private final StringRedisTemplate stringRedisTemplate; + + public String getData(String key) { + ValueOperations valueOperations = stringRedisTemplate.opsForValue(); + return valueOperations.get(key); + } + + public boolean existData(String key) { + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key)); + } + + public void setData(String key, String value) { + ValueOperations valueOperations = stringRedisTemplate.opsForValue(); + valueOperations.set(key, value); + } + + public void setDataExpire(String key, String value, long duration) { + ValueOperations valueOperations = stringRedisTemplate.opsForValue(); + Duration expireDuration = Duration.ofSeconds(duration); + valueOperations.set(key, value, expireDuration); + } + + public void deleteData(String key) { + stringRedisTemplate.delete(key); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3215212..49e73d1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,4 @@ spring: profiles: - active: ["db", "jwt", "swagger"] + active: ["db", "jwt", "infra", "swagger"] - web: - resources: - add-mappings: false \ No newline at end of file diff --git a/src/main/resources/db/migration/V2__remove_block_modify_post.sql b/src/main/resources/db/migration/V2__remove_block_modify_post.sql new file mode 100644 index 0000000..b5a6a54 --- /dev/null +++ b/src/main/resources/db/migration/V2__remove_block_modify_post.sql @@ -0,0 +1,12 @@ +-- block 테이블에 대한 외래 키 제약 조건 삭제 +ALTER TABLE block + DROP FOREIGN KEY fk_block_to_post; + +-- block 테이블 삭제 +DROP TABLE IF EXISTS block; + +-- post 테이블에 새로운 컬럼 추가 +ALTER TABLE post + ADD COLUMN content TEXT NULL, + ADD COLUMN preview_content VARCHAR(255) NULL, + ADD COLUMN preview_image TEXT NULL; \ No newline at end of file diff --git a/src/main/resources/templates/verification-email.html b/src/main/resources/templates/verification-email.html new file mode 100644 index 0000000..86253dd --- /dev/null +++ b/src/main/resources/templates/verification-email.html @@ -0,0 +1,20 @@ + + + + Email Verification + + +
+

안녕하세요.

+

DEPlog입니다.

+
+

인증을 완료하려면 아래 버튼을 클릭해주세요:

+
+
+

인증을 완료하려면 아래 링크를 클릭해주세요:

+

이메일 인증하기

+
+
+
+ +