diff --git a/src/main/java/dev/woori/wooriLog/domain/DomainConstants.java b/src/main/java/dev/woori/wooriLog/domain/DomainConstants.java index bd8a8a3..30a1a1e 100644 --- a/src/main/java/dev/woori/wooriLog/domain/DomainConstants.java +++ b/src/main/java/dev/woori/wooriLog/domain/DomainConstants.java @@ -2,4 +2,5 @@ public abstract class DomainConstants { public static final String LEADER = "팀장"; + public static final String MEMBER = "팀원"; } diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/controller/BlogController.java b/src/main/java/dev/woori/wooriLog/domain/blog/controller/BlogController.java index 147629a..1d88960 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/controller/BlogController.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/controller/BlogController.java @@ -1,6 +1,6 @@ package dev.woori.wooriLog.domain.blog.controller; -import dev.woori.wooriLog.domain.blog.dto.request.BlogCreateReq; +import dev.woori.wooriLog.domain.blog.dto.request.BlogCreateOrUpdateReq; import dev.woori.wooriLog.domain.blog.dto.response.BlogCreateRes; import dev.woori.wooriLog.domain.blog.dto.response.BlogDetailInfoRes; import dev.woori.wooriLog.domain.blog.service.BlogService; @@ -10,10 +10,14 @@ import dev.woori.wooriLog.global.response.SuccessCode; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.Optional; + +@Slf4j @RestController @RequestMapping("/api") @RequiredArgsConstructor @@ -21,16 +25,18 @@ public class BlogController { private final BlogService blogService; + // 최신 5개의 블로그 조회 (홈화면) @GetMapping("/blog/home") public ResponseEntity> getHomeBlogInfos() { return ApiResponseUtil.success(SuccessCode.OK, blogService.getBlogBasicInfos()); } + // 블로그 작성 @PostMapping("/blog/{projectId}") public ResponseEntity> createBlog( @PathVariable("projectId") Long projectId, @UserId Long userId, - @Valid @RequestBody BlogCreateReq request + @Valid @RequestBody BlogCreateOrUpdateReq request ) { return ApiResponseUtil.success( SuccessCode.OK, @@ -38,11 +44,26 @@ public ResponseEntity> createBlog( ); } + // 블로그 조회 @GetMapping("/blog/{postId}") public ResponseEntity> getBlogInfo( + @UserId Optional userId, @PathVariable("postId") Long postId ) { - BlogDetailInfoRes res = blogService.getBlogInfo(postId); + BlogDetailInfoRes res = blogService.getBlogInfo(userId, postId); return ApiResponseUtil.success(SuccessCode.OK, res); } + + // 블로그 수정 + @PutMapping("/blog/{postId}") + public ResponseEntity> updateBlog(@UserId Long userId, @PathVariable("postId") Long postId, @Valid @RequestBody BlogCreateOrUpdateReq request){ + return ApiResponseUtil.success(SuccessCode.OK, blogService.updateBlog(userId, postId, request)); + } + + // 블로그 삭제 + @DeleteMapping("/blog/{postId}") + public ResponseEntity> deleteBlog(@UserId Long userId, @PathVariable("postId") Long postId) { + blogService.deleteBlog(userId, postId); + return ApiResponseUtil.success(SuccessCode.OK); + } } diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/dto/request/BlogCreateReq.java b/src/main/java/dev/woori/wooriLog/domain/blog/dto/request/BlogCreateOrUpdateReq.java similarity index 77% rename from src/main/java/dev/woori/wooriLog/domain/blog/dto/request/BlogCreateReq.java rename to src/main/java/dev/woori/wooriLog/domain/blog/dto/request/BlogCreateOrUpdateReq.java index cd96529..4b67ed6 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/dto/request/BlogCreateReq.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/dto/request/BlogCreateOrUpdateReq.java @@ -1,8 +1,6 @@ package dev.woori.wooriLog.domain.blog.dto.request; -import dev.woori.wooriLog.domain.blog.entity.Progress; import dev.woori.wooriLog.domain.blog.enums.Category; -import dev.woori.wooriLog.domain.blog.enums.ProgressValue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Builder; @@ -10,7 +8,7 @@ import java.util.List; @Builder -public record BlogCreateReq( +public record BlogCreateOrUpdateReq( @NotBlank String title, diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/dto/response/BlogBasicInfoRes.java b/src/main/java/dev/woori/wooriLog/domain/blog/dto/response/BlogBasicInfoRes.java index 36f13e7..8859869 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/dto/response/BlogBasicInfoRes.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/dto/response/BlogBasicInfoRes.java @@ -4,8 +4,10 @@ import dev.woori.wooriLog.domain.blog.entity.Blog; import dev.woori.wooriLog.domain.blog.entity.Progress; import dev.woori.wooriLog.domain.blog.enums.Category; +import dev.woori.wooriLog.domain.member.entity.Member; import lombok.Builder; +import java.time.LocalDateTime; import java.util.List; @Builder @@ -14,19 +16,27 @@ public record BlogBasicInfoRes( String title, String projectName, String authorName, + String authorProfileUrl, Category category, List tags, - List progresses + List progresses, + LocalDateTime createdAt, + LocalDateTime updatedAt ) { public static BlogBasicInfoRes create(Blog blog) { + Member author = blog.getMember(); + return BlogBasicInfoRes.builder() .blogId(blog.getId()) .title(blog.getTitle()) .projectName(blog.getProject().getProjectName()) - .authorName(blog.getMember().getName()) + .authorName(author.getName()) + .authorProfileUrl(author.getProfileUrl()) .category(blog.getCategory()) .tags(blog.getTags()) .progresses(transProgressDtos(blog.getProgresses())) + .createdAt(blog.getCreatedAt()) + .updatedAt(blog.getUpdatedAt()) .build(); } diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/dto/response/BlogDetailInfoRes.java b/src/main/java/dev/woori/wooriLog/domain/blog/dto/response/BlogDetailInfoRes.java index 19ce32c..ff4878f 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/dto/response/BlogDetailInfoRes.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/dto/response/BlogDetailInfoRes.java @@ -7,16 +7,19 @@ @Builder public record BlogDetailInfoRes( + boolean isAuthor, BlogDto post, BlogProjectDto project, MemberInfoDto author ) { public static BlogDetailInfoRes create( + boolean isAuthor, BlogDto post, BlogProjectDto project, MemberInfoDto author ) { return BlogDetailInfoRes.builder() + .isAuthor(isAuthor) .post(post) .project(project) .author(author) diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/entity/Blog.java b/src/main/java/dev/woori/wooriLog/domain/blog/entity/Blog.java index 3abccd8..89b51ac 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/entity/Blog.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/entity/Blog.java @@ -1,6 +1,6 @@ package dev.woori.wooriLog.domain.blog.entity; -import dev.woori.wooriLog.domain.blog.dto.request.BlogCreateReq; +import dev.woori.wooriLog.domain.blog.dto.request.BlogCreateOrUpdateReq; import dev.woori.wooriLog.domain.blog.enums.Category; import dev.woori.wooriLog.domain.member.entity.Member; import dev.woori.wooriLog.domain.project.entity.Project; @@ -55,7 +55,7 @@ public class Blog extends BaseEntity { @Builder.Default private List progresses = new ArrayList<>(); - public static Blog create(Project project, Member member, BlogCreateReq request, List progresses) { + public static Blog create(Project project, Member member, BlogCreateOrUpdateReq request, List progresses) { Blog blog = Blog.builder() .document(request.document()) .title(request.title()) @@ -68,6 +68,18 @@ public static Blog create(Project project, Member member, BlogCreateReq request, return blog; } + public void update(BlogCreateOrUpdateReq request, List progresses) { + this.title = request.title(); + this.category = request.category(); + this.document = request.document(); + // 태그 초기화 및 업데이트 + this.tags.clear(); + this.tags.addAll(request.tags()); + // Progress 초기화 및 업데이트 + this.progresses.clear(); + addProgresses(progresses); + } + private void addProgress(Progress p) { this.progresses.add(p); p.setBlog(this); diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/enums/Category.java b/src/main/java/dev/woori/wooriLog/domain/blog/enums/Category.java index df8c7e4..70315de 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/enums/Category.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/enums/Category.java @@ -4,10 +4,11 @@ import com.fasterxml.jackson.annotation.JsonValue; public enum Category { - BLOG("블로그"), CHECKPOINT("체크포인트"), - REVIEW("리뷰"), - TECH("기술/딥다이브"); + REVIEW("회고"), + TROUBLESHOOTING("트러블슈팅"), + DEEPDIVE("딥다이브"), + ETC("기타"); private final String value; diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/enums/ProgressValue.java b/src/main/java/dev/woori/wooriLog/domain/blog/enums/ProgressValue.java index bfa2e07..58157ff 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/enums/ProgressValue.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/enums/ProgressValue.java @@ -4,10 +4,23 @@ import com.fasterxml.jackson.annotation.JsonValue; public enum ProgressValue { + // 회고 + OUTLINE("개요"), + KEEP("잘했던 점"), + PROBLEM("아쉬웠던 점"), + TRY("배운 점 & 개선 방안"), + // 트러블 슈팅 SITUATION("상황"), CONCERN("고민"), ACTION("실행"), - REFLECTION("회고/성장"); + REFLECTION("회고/성장"), + // 딥다이브 + INTRODUCE("기술소개"), + EXAMPLE("예시"), + CORE("핵심 개념"), + CONCLUSION("결론"), + // 기타 + SUMMARY("요약"); private final String value; diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/repository/BlogRepository.java b/src/main/java/dev/woori/wooriLog/domain/blog/repository/BlogRepository.java index 34f7eaa..8c3d3cd 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/repository/BlogRepository.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/repository/BlogRepository.java @@ -15,6 +15,9 @@ public interface BlogRepository extends JpaRepository { Optional findById(Long blogId); + @Query("SELECT b FROM Blog b JOIN FETCH b.member WHERE b.id = :blogId") + Optional findByIdWithMember(@Param("blogId") Long blogId); + @Query("SELECT b FROM Blog b LEFT JOIN FETCH b.tags WHERE b.member.id = :memberId") List findByMemberId(Long memberId); diff --git a/src/main/java/dev/woori/wooriLog/domain/blog/service/BlogService.java b/src/main/java/dev/woori/wooriLog/domain/blog/service/BlogService.java index b47cd43..216a8ba 100644 --- a/src/main/java/dev/woori/wooriLog/domain/blog/service/BlogService.java +++ b/src/main/java/dev/woori/wooriLog/domain/blog/service/BlogService.java @@ -2,7 +2,7 @@ import dev.woori.wooriLog.domain.blog.dto.*; -import dev.woori.wooriLog.domain.blog.dto.request.BlogCreateReq; +import dev.woori.wooriLog.domain.blog.dto.request.BlogCreateOrUpdateReq; import dev.woori.wooriLog.domain.blog.dto.response.BlogBasicInfoRes; import dev.woori.wooriLog.domain.blog.dto.response.BlogDetailInfoRes; import dev.woori.wooriLog.domain.blog.entity.Blog; @@ -17,10 +17,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; import static dev.woori.wooriLog.global.response.error.ErrorMessage.*; @@ -41,17 +43,14 @@ public class BlogService { * @param request 새로운 글의 데이터가 담긴 request */ @Transactional - public Long createBlog(Long projectId, Long userId, BlogCreateReq request) { + public Long createBlog(Long projectId, Long userId, BlogCreateOrUpdateReq request) { log.info("[Blog Service] Create Blog : projectId={}, userId={}", projectId, userId); // 프로젝트-멤버 관계 조회 ProjectMember projectMember = projectMemberRepository.findWithMemberAndProjectByIds(projectId, userId) .orElseThrow(() -> new EntityNotFoundException(RELATION_NOT_FOUND)); // Progress Entity 생성 - List progresses = request.progresses().stream() - .filter(progressDto -> progressDto.message() != null && !progressDto.message().isBlank()) - .map(Progress::create) - .toList(); + List progresses = filterAndCreateProgress(request); // Blog Entity 생성 Blog createdBlog = Blog.create( @@ -72,16 +71,17 @@ public Long createBlog(Long projectId, Long userId, BlogCreateReq request) { * @return BlogDetailInfoRes: 열람할 글, 작성자, 작성 프로젝트의 정보 */ @Transactional - public BlogDetailInfoRes getBlogInfo(Long postId) { + public BlogDetailInfoRes getBlogInfo(Optional memberId, Long postId) { log.info("[Blog Service] Get Blog Info : blogId={}", postId); - Blog blog = blogRepository.findBlogByIdWithDetails(postId) .orElseThrow(() -> new EntityNotFoundException(BLOG_NOT_FOUND)); Project project = blog.getProject(); Member author = blog.getMember(); + boolean isAuthor = checkAuthor(memberId, author.getId()); return BlogDetailInfoRes.create( + isAuthor, BlogDto.create(blog), createBlogProjectDTO(project, author), MemberInfoDto.create(author) @@ -117,4 +117,65 @@ private BlogProjectDto createBlogProjectDTO(Project project, Member author) { return BlogProjectDto.create(project, memberList); } + + /** + * 블로그 id와 수정된 블로그 포스팅 정보를 통해 블로그 글을 수정 + * 블로그 글 작성자 id와 요청을 보낸 사용자 id가 일치하지 않으면 예외 발생 + * @param userId 사용자 id + * @param blogId 블로그 id + * @param request 블로그 수정 폼에 담긴 내용들 + * @return blogId 블로그 id + */ + @Transactional + public Long updateBlog(Long userId, Long blogId, BlogCreateOrUpdateReq request) { + log.info("[Blog Service] Update Blog : blogId={}", blogId); + Blog blog = findBlogAndCheckOwnerShip(userId, blogId); + List progresses = filterAndCreateProgress(request); + blog.update(request, progresses); + return blogId; + } + + /** + * 블로그 id를 받아와 해당 블로그 글을 삭제 + * 블로그 글 작성자 id와 요청을 보낸 사용자 id가 일치하지 않으면 예외 발생 + * @param userId 사용자 id + * @param blogId 블로그 id + */ + @Transactional + public void deleteBlog(Long userId, Long blogId) { + log.info("[Blog Service] Delete Blog : blogId={}", blogId); + Blog blog = findBlogAndCheckOwnerShip(userId, blogId); + blogRepository.delete(blog); + } + + /** + * 블로그 글 id를 통해 블로그 글을 가져오고 글의 작성자인지 확인하는 메서드 + * @param userId 사용자 id + * @param blogId 블로그 id + * @return Blog 요청을 보낸 사람이 작성자인 게 확인된 블로그 entity + */ + private Blog findBlogAndCheckOwnerShip(Long userId, Long blogId) { + Blog blog = blogRepository.findByIdWithMember(blogId).orElseThrow(() -> new EntityNotFoundException(BLOG_NOT_FOUND)); + if (!blog.getMember().getId().equals(userId)) { + throw new AccessDeniedException(BLOG_ACCESS_DENIED); + } + return blog; + } + + /** + * 글의 작성자인지 확인하는 메서드 + * @param memberId 조회한 유저의 memberId + * @param authorId 작성자의 memberId + * @return boolean 조회한 클라이언트가 작성자인지 여부 + */ + private static boolean checkAuthor(Optional memberId, Long authorId) { + return memberId.filter(id -> id.equals(authorId)).isPresent(); + } + + private static List filterAndCreateProgress(BlogCreateOrUpdateReq request) { + return request.progresses().stream() + .filter(progressDto -> progressDto.message() != null && !progressDto.message().isBlank()) + .map(Progress::create) + .toList(); + } } diff --git a/src/main/java/dev/woori/wooriLog/domain/member/controller/MemberController.java b/src/main/java/dev/woori/wooriLog/domain/member/controller/MemberController.java index 8024083..a8c8308 100644 --- a/src/main/java/dev/woori/wooriLog/domain/member/controller/MemberController.java +++ b/src/main/java/dev/woori/wooriLog/domain/member/controller/MemberController.java @@ -17,21 +17,25 @@ public class MemberController { private final MemberService memberService; + // 이메일을 이용한 유저 검색 @GetMapping("/members/search") public ResponseEntity getMembersInfoByEmail(@RequestParam String email, @UserId Long memberId) { return ApiResponseUtil.success(SuccessCode.OK, memberService.findMembersByEmail(email, memberId)); } + // 유저 조회 @GetMapping("/members") public ResponseEntity getMemberInfoById(@UserId Long userId) { return ApiResponseUtil.success(SuccessCode.OK, memberService.getMemberInfo(userId)); } + // 유저 프로필 조회 @GetMapping("/members/profile") public ResponseEntity getProfileInfoById(@UserId Long userId) { return ApiResponseUtil.success(SuccessCode.OK, memberService.getProfileInfoById(userId)); } + // 유저 프로필 수정 @PutMapping("/members/profile") public ResponseEntity updateMemberInfo(@UserId Long userId, @Valid @RequestBody MemberUpdateReq request) { return ApiResponseUtil.success(SuccessCode.OK, memberService.updateMemberInfo(userId, request)); diff --git a/src/main/java/dev/woori/wooriLog/domain/member/dto/MemberInfoDto.java b/src/main/java/dev/woori/wooriLog/domain/member/dto/MemberInfoDto.java index e0ed371..7dd14c3 100644 --- a/src/main/java/dev/woori/wooriLog/domain/member/dto/MemberInfoDto.java +++ b/src/main/java/dev/woori/wooriLog/domain/member/dto/MemberInfoDto.java @@ -6,6 +6,7 @@ @Builder public record MemberInfoDto( Long userId, + String profileUrl, String email, String name, String introduce @@ -13,6 +14,7 @@ public record MemberInfoDto( public static MemberInfoDto create(Member member) { return MemberInfoDto.builder() .userId(member.getId()) + .profileUrl(member.getProfileUrl()) .email(member.getEmail()) .name(member.getName()) .introduce(member.getIntroduce()) diff --git a/src/main/java/dev/woori/wooriLog/domain/member/dto/ProfileDto.java b/src/main/java/dev/woori/wooriLog/domain/member/dto/ProfileDto.java index 1d3cf8d..0b06189 100644 --- a/src/main/java/dev/woori/wooriLog/domain/member/dto/ProfileDto.java +++ b/src/main/java/dev/woori/wooriLog/domain/member/dto/ProfileDto.java @@ -11,25 +11,23 @@ @Builder public record ProfileDto( - Long userId, - String email, - String name, - String introduce, + MemberInfoDto member, List blogList, List projectList ) { public static ProfileDto create(Member member, List blogs, List projects) { + MemberInfoDto memberInfoDto = MemberInfoDto.create(member); + List blogSummaryDtos = blogs.stream() .map(BlogSummaryDto::create) .toList(); + List projectBasicInfoDtos = projects.stream() .map(ProjectBasicInfoDto::create) .toList(); + return ProfileDto.builder() - .userId(member.getId()) - .email(member.getEmail()) - .name(member.getName()) - .introduce(member.getIntroduce()) + .member(memberInfoDto) .blogList(blogSummaryDtos) .projectList(projectBasicInfoDtos) .build(); diff --git a/src/main/java/dev/woori/wooriLog/domain/member/entity/Member.java b/src/main/java/dev/woori/wooriLog/domain/member/entity/Member.java index 863c1be..c759982 100644 --- a/src/main/java/dev/woori/wooriLog/domain/member/entity/Member.java +++ b/src/main/java/dev/woori/wooriLog/domain/member/entity/Member.java @@ -9,6 +9,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import lombok.*; +import java.util.Objects; @Entity @Getter @@ -34,14 +35,8 @@ public class Member extends BaseEntity { private String introduce; - public static Member create(String email, String name, String provider, String socialId) { - return Member.builder() - .email(email) - .name(name) - .provider(provider) - .socialId(socialId) - .build(); - } + @Column(length = 2048) + private String profileUrl; public static Member create(GoogleUserInfoRes userInfo, GoogleEnrollReq req, String provider) { return Member.builder() @@ -50,6 +45,7 @@ public static Member create(GoogleUserInfoRes userInfo, GoogleEnrollReq req, Str .provider(provider) .socialId(userInfo.sub()) .introduce(req.introduce()) + .profileUrl(userInfo.picture()) .build(); } @@ -61,4 +57,10 @@ public void update(MemberUpdateReq request) { public void updateSocialId (String socialId) { this.socialId = socialId; } + + public void checkProfile(String picture) { + if (!Objects.equals(this.profileUrl, picture)) { + this.profileUrl = picture; + } + } } diff --git a/src/main/java/dev/woori/wooriLog/domain/project/controller/ProjectController.java b/src/main/java/dev/woori/wooriLog/domain/project/controller/ProjectController.java index f7c8523..ccd6a98 100644 --- a/src/main/java/dev/woori/wooriLog/domain/project/controller/ProjectController.java +++ b/src/main/java/dev/woori/wooriLog/domain/project/controller/ProjectController.java @@ -14,17 +14,19 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api") +@RequestMapping("/api/projects") public class ProjectController { private final ProjectService projectService; - @GetMapping("/projects/home") + // 최신 5개의 프로젝트 조회 (홈화면) + @GetMapping("/home") public ResponseEntity> getHomeProjectInfos() { return ApiResponseUtil.success(SuccessCode.OK, projectService.getProjectBasicInfos()); } - @PostMapping("/projects") + // 프로젝트 생성 + @PostMapping public ResponseEntity> createProject( @UserId Long leaderId, @Valid @RequestBody ProjectCreateReq request @@ -35,13 +37,44 @@ public ResponseEntity> createProject( ); } - @GetMapping("/projects/{projectId}") + // 프로젝트 조회 + @GetMapping("/{projectId}") public ResponseEntity> getProjectInfo(@PathVariable Long projectId) { return ApiResponseUtil.success(SuccessCode.OK, projectService.getProjectInfo(projectId)); } - @GetMapping("/projects/list") + // 프로젝트 가입 목록 조회 (유저) + @GetMapping("/list") public ResponseEntity> getProjectList(@UserId Long memberId) { return ApiResponseUtil.success(SuccessCode.OK, projectService.getProjectListByMemberId(memberId)); } + + // 프로젝트 삭제 + @DeleteMapping("/{projectId}") + public ResponseEntity> deleteProject(@PathVariable Long projectId, @UserId Long leaderId) { + projectService.deleteProject(projectId, leaderId); + return ApiResponseUtil.success(SuccessCode.OK); + } + + // 프로젝트 멤버 추가 + @PostMapping("/{projectId}/members/{memberId}") + public ResponseEntity> addProjectMember( + @PathVariable Long projectId, + @UserId Long leaderId, + @PathVariable Long memberId + ) { + projectService.addProjectMember(projectId, leaderId, memberId); + return ApiResponseUtil.success(SuccessCode.OK); + } + + // 프로젝트 멤버 삭제 + @DeleteMapping("/{projectId}/members/{memberId}") + public ResponseEntity> deleteProjectMember( + @PathVariable Long projectId, + @UserId Long leaderId, + @PathVariable Long memberId + ) { + projectService.deleteProjectMember(projectId, leaderId, memberId); + return ApiResponseUtil.success(SuccessCode.OK); + } } diff --git a/src/main/java/dev/woori/wooriLog/domain/project/dto/ProjectBasicInfoDto.java b/src/main/java/dev/woori/wooriLog/domain/project/dto/ProjectBasicInfoDto.java index 3bd2956..b859280 100644 --- a/src/main/java/dev/woori/wooriLog/domain/project/dto/ProjectBasicInfoDto.java +++ b/src/main/java/dev/woori/wooriLog/domain/project/dto/ProjectBasicInfoDto.java @@ -3,19 +3,24 @@ import dev.woori.wooriLog.domain.project.entity.Project; import lombok.Builder; +import java.time.LocalDateTime; import java.util.List; @Builder public record ProjectBasicInfoDto( Long projectId, String projectName, - List techStack + List techStack, + LocalDateTime createdAt, + LocalDateTime updatedAt ) { public static ProjectBasicInfoDto create(Project project) { return ProjectBasicInfoDto.builder() .projectId(project.getId()) .projectName(project.getProjectName()) .techStack(project.getTechStack()) + .createdAt(project.getCreatedAt()) + .updatedAt(project.getUpdatedAt()) .build(); } } diff --git a/src/main/java/dev/woori/wooriLog/domain/project/repository/ProjectMemberRepository.java b/src/main/java/dev/woori/wooriLog/domain/project/repository/ProjectMemberRepository.java index 5b89359..1186b00 100644 --- a/src/main/java/dev/woori/wooriLog/domain/project/repository/ProjectMemberRepository.java +++ b/src/main/java/dev/woori/wooriLog/domain/project/repository/ProjectMemberRepository.java @@ -13,7 +13,6 @@ @Repository public interface ProjectMemberRepository extends JpaRepository { - List findByProject(Project project); @Query("SELECT pm.member FROM ProjectMember pm WHERE pm.project = :project") List findMembersByProject(@Param("project") Project project); @@ -29,4 +28,12 @@ public interface ProjectMemberRepository extends JpaRepository findByMemberId(@Param("memberId") Long memberId); + + List findAllByProject(Project project); + + boolean existsByProjectIdAndMemberIdAndRole(Long projectId, Long memberId, String role); + + boolean existsByMemberIdAndProjectId(Long memberId, Long projectId); + + Optional findByProject_IdAndMember_Id(Long projectId, Long memberId); } diff --git a/src/main/java/dev/woori/wooriLog/domain/project/service/ProjectService.java b/src/main/java/dev/woori/wooriLog/domain/project/service/ProjectService.java index 7d72c52..1dbbae0 100644 --- a/src/main/java/dev/woori/wooriLog/domain/project/service/ProjectService.java +++ b/src/main/java/dev/woori/wooriLog/domain/project/service/ProjectService.java @@ -19,6 +19,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +30,7 @@ import java.util.stream.Collectors; import static dev.woori.wooriLog.domain.DomainConstants.LEADER; +import static dev.woori.wooriLog.domain.DomainConstants.MEMBER; import static dev.woori.wooriLog.global.response.error.ErrorMessage.*; @Slf4j @@ -123,6 +125,56 @@ public List getProjectBasicInfos() { .toList(); } + /** + * 프로젝트 삭제 메서드 + * @param projectId 삭제할 프로젝트 ID + * @param leaderId 요청 유저 ID + */ + @Transactional + public void deleteProject(Long projectId, Long leaderId) { + Project project = findProjectBy(projectId); + // TODO : Spring Interceptor를 적용하여 추후 분리 + checkAccessPermission(projectId, leaderId); + List projectMembers = projectMemberRepository.findAllByProject(project); + projectMemberRepository.deleteAll(projectMembers); + projectRepository.delete(project); + } + + /** + * 프로젝트 멤버 추가 + * @param projectId 프로젝트 ID + * @param leaderId 요청 유저 ID + * @param memberId 프로젝트에 추가할 유저 ID + */ + @Transactional + public void addProjectMember(Long projectId, Long leaderId, Long memberId) { + Project project = findProjectBy(projectId); + Member requestMember = findMemberBy(memberId); + // TODO : Spring Interceptor를 적용하여 추후 분리 + checkAccessPermission(projectId, leaderId); + if (projectMemberRepository.existsByMemberIdAndProjectId(memberId, projectId)) { + throw new IllegalArgumentException(DUPLICATED_REQUEST); + } + projectMemberRepository.save(ProjectMember.of(requestMember, project, MEMBER)); + } + + /** + * 프로젝트 멤버 삭제 + * @param projectId 프로젝트 ID + * @param leaderId 요청 유저 ID + * @param memberId 프로젝트에서 제거할 유저 ID + */ + @Transactional + public void deleteProjectMember(Long projectId, Long leaderId, Long memberId) { + checkAccessPermission(projectId, leaderId); + if (memberId.equals(leaderId)) { + throw new IllegalArgumentException(LEADER_CAN_NOT_DELETE); + } + ProjectMember projectMember = projectMemberRepository.findByProject_IdAndMember_Id(projectId, memberId) + .orElseThrow(() -> new EntityNotFoundException(RELATION_NOT_FOUND)); + projectMemberRepository.delete(projectMember); + } + private void addLeaderToProject(Long leaderId, Project project) { Member leader = findMemberBy(leaderId); projectMemberRepository.save(ProjectMember.of(leader, project, LEADER)); @@ -165,4 +217,10 @@ private Project findProjectBy(Long projectId) { private Member findMemberBy(Long userId) { return memberRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); } + + private void checkAccessPermission(Long projectId, Long memberId) { + if (!projectMemberRepository.existsByProjectIdAndMemberIdAndRole(projectId, memberId, LEADER)) { + throw new AccessDeniedException(ACCESS_DENIED); + } + } } diff --git a/src/main/java/dev/woori/wooriLog/global/auth/dto/LoginSuccessRes.java b/src/main/java/dev/woori/wooriLog/global/auth/dto/LoginSuccessRes.java index 7675466..b278a91 100644 --- a/src/main/java/dev/woori/wooriLog/global/auth/dto/LoginSuccessRes.java +++ b/src/main/java/dev/woori/wooriLog/global/auth/dto/LoginSuccessRes.java @@ -9,6 +9,7 @@ public record LoginSuccessRes( Long userId, String username, String email, + String profileUrl, String accessToken, String refreshToken ) { @@ -17,6 +18,7 @@ public static LoginSuccessRes create(Member member, Token token) { .userId(member.getId()) .username(member.getName()) .email(member.getEmail()) + .profileUrl(member.getProfileUrl()) .accessToken(token.getAccessToken()) .refreshToken(token.getRefreshToken()) .build(); diff --git a/src/main/java/dev/woori/wooriLog/global/auth/service/GoogleOAuthService.java b/src/main/java/dev/woori/wooriLog/global/auth/service/GoogleOAuthService.java index 1d73685..63ac167 100644 --- a/src/main/java/dev/woori/wooriLog/global/auth/service/GoogleOAuthService.java +++ b/src/main/java/dev/woori/wooriLog/global/auth/service/GoogleOAuthService.java @@ -42,6 +42,9 @@ public LoginSuccessRes login(GoogleLoginReq request) { Member member = memberRepository.findByProviderAndSocialId(Constants.GOOGLE, userInfo.sub()) // 소셜 ID를 통한 유저 조회 .orElseThrow(() -> new UnEnrolledException(googleToken.access_token())); + // 프로필 이미지 변경시 변경사항 적용 + member.checkProfile(userInfo.picture()); + // JWT Token 발급 Token token = jwtProvider.issueToken(member.getId()); diff --git a/src/main/java/dev/woori/wooriLog/global/exception/GlobalExceptionHandler.java b/src/main/java/dev/woori/wooriLog/global/exception/GlobalExceptionHandler.java index f96a41f..faa5865 100644 --- a/src/main/java/dev/woori/wooriLog/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/dev/woori/wooriLog/global/exception/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; import org.springframework.transaction.TransactionSystemException; import org.springframework.validation.FieldError; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -130,6 +131,16 @@ public ResponseEntity> handleExpiredTokenException(final JwtToke return ApiResponseUtil.failure(ErrorBaseCode.EXPIRED_TOKEN); } + /** + * 403 - AccessDeniedException + * 예외 내용: 사용자가 허가되지 않은 자원에 접근할 때 발생 + */ + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(final AccessDeniedException e) { + logWarn(e); + return ApiResponseUtil.failure(ErrorBaseCode.FORBIDDEN, e.getMessage()); + } + /** * 404 - EntityNotFoundException * 예외 내용 : 리소스에 대한 엔티티를 찾을 수 없는 오류 diff --git a/src/main/java/dev/woori/wooriLog/global/filter/JwtAuthenticationFilter.java b/src/main/java/dev/woori/wooriLog/global/filter/JwtAuthenticationFilter.java index 736ac4c..66cfd9c 100644 --- a/src/main/java/dev/woori/wooriLog/global/filter/JwtAuthenticationFilter.java +++ b/src/main/java/dev/woori/wooriLog/global/filter/JwtAuthenticationFilter.java @@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.AntPathMatcher; @@ -24,6 +25,7 @@ /** * SpringSecurity FilterChain에 추가할 JWT 관련 Filter */ +@Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -31,34 +33,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final List whiteList; private final AntPathMatcher pathMatcher = new AntPathMatcher(); - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - - if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { - return true; - } + private static final List DENY_URL = List.of( + "/api/projects/list" + ); - String uri = request.getRequestURI(); - - if ("GET".equalsIgnoreCase(request.getMethod())) { - if (pathMatcher.match("/api/projects/list", uri)) { - return false; - } - if (pathMatcher.match("/api/projects/*", uri) || pathMatcher.match("/api/blog/*", uri)) { - return true; - } - } + private static final List PERMIT_URL = List.of( + "/api/projects/*", + "/api/blog/*" + ); - return whiteList.stream().anyMatch(pattern -> pathMatcher.match(pattern, uri)); + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // CORS preflight 스킵 + return "OPTIONS".equalsIgnoreCase(request.getMethod()); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + final boolean isPermitAll = isPermit(request); final String accessToken = getAccessToken(request); - + // 화이트리스트 URI의 경우 통과 + if (isPermitAll) { + if (accessToken != null) { + try { + final long userId = jwtProvider.getUserIdFromClaims(accessToken); + doAuthentication(accessToken, userId); + } catch (JwtTokenException e) { + // 유효하지 않은 토큰의 경우 catch문에 잡혀 Authentication을 생성하지 않음 + log.warn("[JwtAuthenticationFilter] Invalid JwtToken from Anonymous User", e); + } + } + filterChain.doFilter(request, response); + return; + } + // 화이트리스트가 아닌 경우 token이 존재하지 않으면 UNAUTHORIZED 예외 처리 + if (accessToken == null) { + throw new JwtTokenException(ErrorBaseCode.UNAUTHORIZED); + } final long userId = jwtProvider.getUserIdFromClaims(accessToken); - - // Token을 이용한 인증 객체 생성 doAuthentication(accessToken, userId); filterChain.doFilter(request, response); } @@ -69,7 +81,7 @@ private String getAccessToken(final HttpServletRequest request) { // Bearer를 제외한 순수 aT return accessToken.substring(Constants.BEARER.length()); } - throw new JwtTokenException(ErrorBaseCode.UNAUTHORIZED); + return null; // 토큰이 없는 익명 사용자 } private void doAuthentication(final String token, final long userId) { @@ -77,4 +89,21 @@ private void doAuthentication(final String token, final long userId) { SecurityContext securityContext = SecurityContextHolder.getContext(); securityContext.setAuthentication(tokenAuthentication); } + + private boolean isPermit(final HttpServletRequest request) { + String uri = request.getRequestURI(); + + if ("GET".equalsIgnoreCase(request.getMethod())) { + // DENY_URL 리스트에 존재하는 URL 요청일 경우 거부 + if (DENY_URL.stream().anyMatch(deny -> pathMatcher.match(deny, uri))) { + return false; + } + // PERMIT_URL 리스트에 존재하는 URL 요청일 경우 허용 + if (PERMIT_URL.stream().anyMatch(permit -> pathMatcher.match(permit, uri))) { + return true; + } + } + // DENY_URL, PERMIT_URL 외의 WhiteList URL들의 경우엔 허용 + return whiteList.stream().anyMatch(pattern -> pathMatcher.match(pattern, uri)); + } } diff --git a/src/main/java/dev/woori/wooriLog/global/resolver/UserIdResolver.java b/src/main/java/dev/woori/wooriLog/global/resolver/UserIdResolver.java index d65fe67..904124f 100644 --- a/src/main/java/dev/woori/wooriLog/global/resolver/UserIdResolver.java +++ b/src/main/java/dev/woori/wooriLog/global/resolver/UserIdResolver.java @@ -1,6 +1,9 @@ package dev.woori.wooriLog.global.resolver; +import lombok.extern.slf4j.Slf4j; import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -8,12 +11,15 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; +import java.util.Optional; + +@Slf4j @Component public class UserIdResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { - return (parameter.getParameterType().equals(Long.class) || parameter.getParameterType().equals(long.class)) + return (parameter.getParameterType().equals(Long.class) || parameter.getParameterType().equals(Optional.class)) && parameter.hasParameterAnnotation(UserId.class); } @@ -22,6 +28,21 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - return SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + boolean authenticated = authentication != null + && authentication.isAuthenticated() + && !(authentication instanceof AnonymousAuthenticationToken); + + Long userId = authenticated + ? (Long) authentication.getPrincipal() + : null; + + // 익명 사용자 + if (parameter.getParameterType().equals(Optional.class)) { + return Optional.ofNullable(userId); + } + + return userId; } } diff --git a/src/main/java/dev/woori/wooriLog/global/response/error/ErrorMessage.java b/src/main/java/dev/woori/wooriLog/global/response/error/ErrorMessage.java index 179afd8..4b082e1 100644 --- a/src/main/java/dev/woori/wooriLog/global/response/error/ErrorMessage.java +++ b/src/main/java/dev/woori/wooriLog/global/response/error/ErrorMessage.java @@ -2,6 +2,11 @@ public abstract class ErrorMessage { + /** + * BAD REQUEST + */ + public static final String LEADER_CAN_NOT_DELETE = "팀장을 프로젝트에서 삭제할 수 없습니다."; + /** * NOT FOUND - 조회 실패 */ @@ -14,5 +19,11 @@ public abstract class ErrorMessage { * INVALID - 유효하지 않음 */ public static final String INVALID_MEMBER_EMAIL = "멤버의 이메일이 올바르지 않습니다 : "; + public static final String DUPLICATED_REQUEST = "이미 존재하는 리소스 입니다."; + /** + * DENIED - 접근 거부 + */ + public static final String BLOG_ACCESS_DENIED = "블로그 정보를 변경할 권한이 없습니다."; + public static final String ACCESS_DENIED = "권한이 없습니다."; }