Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] feature/#253 특정 핀에 대해 내 지도에 저장하기 API #270

Merged
merged 38 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8646b1a
[Docs] GitHub Issue 및 PR Template 설정 (#37)
junpakPark Jul 18, 2023
5b153cc
[Docs] GitHub Issue Template 파일명 오류 수정 (#39)
junpakPark Jul 18, 2023
c54a142
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Jul 25, 2023
1397964
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Jul 25, 2023
4efef6e
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Jul 26, 2023
c1fe514
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Aug 1, 2023
4d7b389
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Aug 9, 2023
e9d7017
feat: 모아보기 기능 구현
junpakPark Aug 9, 2023
1a596ca
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Aug 10, 2023
1de1b01
refactor: controller 필드 final 추가
junpakPark Aug 10, 2023
62ba5f1
refactor: URI 및 메서드 네이밍 변경
junpakPark Aug 10, 2023
10df876
test: atlas 테스트 추가
junpakPark Aug 10, 2023
53ec9b4
test: 테스트 코드 리팩터링
junpakPark Aug 10, 2023
8af79ae
feat: 최신 토픽 조회 기능 구현
junpakPark Aug 10, 2023
b075d3a
test: 테스트 추가 및 리팩터링
junpakPark Aug 10, 2023
b6e45e7
test: 테스트 코드 로직 오류 수정
junpakPark Aug 10, 2023
fd65611
Merge branch 'feature/atlas' into feature/#251-updatedTopic
junpakPark Aug 10, 2023
8167a2c
test: 개행 및 DisplayName 수정
junpakPark Aug 11, 2023
573fc31
docs: restDocs snippets 추가
junpakPark Aug 11, 2023
d49b632
feat: 핀을 나의 토픽들에 복사하는 기능 구현 완료
junpakPark Aug 11, 2023
dea2ee8
test: copyPin 테스트 케이스 및 restDocs 추가
junpakPark Aug 11, 2023
595bbb6
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Aug 13, 2023
fd4ab32
refactor: 메서드 분리 및 불필요한 개행 삭제
junpakPark Aug 13, 2023
a65cede
refactor: 메서드 네이밍 변경
junpakPark Aug 13, 2023
dadca50
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Aug 14, 2023
c5a8553
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Aug 14, 2023
eb86367
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-m…
kpeel5839 Aug 15, 2023
85e5e79
refactor: 권한 부여 API 명세 변경
yoondgu Aug 16, 2023
1347041
fix: 권한 부여 API 응답코드 201로 변경
yoondgu Aug 16, 2023
a887e37
chore: git conflict 해결
junpakPark Aug 16, 2023
c58e942
chore: git conflict 해결
junpakPark Aug 16, 2023
545dd37
Merge branch 'hotfix/#311' of https://github.com/woowacourse-teams/20…
kpeel5839 Aug 16, 2023
0f07543
chore : 개발환경에서는 ddl-auto = update 로 설정
kpeel5839 Aug 16, 2023
17648af
fix : 파라미터 형식 통일
kpeel5839 Aug 16, 2023
c1c0134
fix : RestDocs 문서 수정
kpeel5839 Aug 16, 2023
be05418
refactor : validateMember 수정
kpeel5839 Aug 16, 2023
1fa892a
chore: git conflict 해결 및 불필요 Import문 제거
junpakPark Aug 16, 2023
bc85d60
Merge branch 'develop' into feature/#253-copyPinToExistedTopic
cpot5620 Aug 16, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/docs/asciidoc/topic.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ operation::topic-controller-test/find-bests[snippets='http-request,http-response

operation::topic-controller-test/find-by-id[snippets='http-request,http-response']

=== 최신 토픽 목록 조회
operation::topic-controller-test/find-all-by-order-by-updated-at-desc[snippets='http-request,http-response']

=== 토픽 생성

operation::topic-controller-test/create[snippets='http-request,http-response']
Expand All @@ -20,6 +23,9 @@ operation::topic-controller-test/create[snippets='http-request,http-response']

operation::topic-controller-test/merge-and-create[snippets='http-request,http-response']

== 토픽에 핀 추가
operation::topic-controller-test/copy-pin[snippets='http-request,http-response']

=== 토픽 수정

operation::topic-controller-test/update[snippets='http-request,http-response']
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.mapbefine.mapbefine.atlas.application;

import com.mapbefine.mapbefine.atlas.domain.Atlas;
import com.mapbefine.mapbefine.atlas.domain.AtlasRepository;
import com.mapbefine.mapbefine.auth.domain.AuthMember;
import com.mapbefine.mapbefine.member.domain.Member;
import com.mapbefine.mapbefine.member.domain.MemberRepository;
import com.mapbefine.mapbefine.topic.domain.Topic;
import com.mapbefine.mapbefine.topic.domain.TopicRepository;
import java.util.NoSuchElementException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class AtlasCommandService {

private final TopicRepository topicRepository;
private final MemberRepository memberRepository;
private final AtlasRepository atlasRepository;

public AtlasCommandService(
TopicRepository topicRepository,
MemberRepository memberRepository,
AtlasRepository atlasRepository
) {
this.topicRepository = topicRepository;
this.memberRepository = memberRepository;
this.atlasRepository = atlasRepository;
}

public void addTopic(AuthMember authMember, Long topicId) {
Long memberId = authMember.getMemberId();

// TODO: 2023/08/10 memberId가 없는 경우 터짐 (Guest인 경우) (단, loginRequired로 일차적으로 막아놓긴 함)
if (isTopicAlreadyAdded(topicId, memberId)) {
return;
}

Topic topic = findTopicById(topicId);
validateReadPermission(authMember, topic);

Member member = findMemberById(memberId);

Atlas atlas = Atlas.from(topic, member);
atlasRepository.save(atlas);
}

private boolean isTopicAlreadyAdded(Long topicId, Long memberId) {
return atlasRepository.existsByMemberIdAndTopicId(memberId, topicId);
}

private Member findMemberById(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(NoSuchElementException::new);
}

private Topic findTopicById(Long topicId) {
return topicRepository.findById(topicId)
.orElseThrow(NoSuchElementException::new);
}

private void validateReadPermission(AuthMember authMember, Topic topic) {
if (authMember.canRead(topic)) {
return;
}
throw new IllegalArgumentException("해당 지도에 접근권한이 없습니다.");
}

public void removeTopic(AuthMember authMember, Long topicId) {
atlasRepository.deleteByMemberIdAndTopicId(authMember.getMemberId(), topicId);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.mapbefine.mapbefine.atlas.application;

import com.mapbefine.mapbefine.atlas.domain.Atlas;
import com.mapbefine.mapbefine.atlas.domain.AtlasRepository;
import com.mapbefine.mapbefine.auth.domain.AuthMember;
import com.mapbefine.mapbefine.topic.dto.response.TopicResponse;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class AtlasQueryService {

private final AtlasRepository atlasRepository;

public AtlasQueryService(AtlasRepository atlasRepository) {
this.atlasRepository = atlasRepository;
}

public List<TopicResponse> findTopicsByMember(AuthMember member) {
return atlasRepository.findAllByMemberId(member.getMemberId())
.stream()
.map(Atlas::getTopic)
.filter(member::canRead)
.map(TopicResponse::from)
.toList();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.mapbefine.mapbefine.atlas.domain;

import static lombok.AccessLevel.PROTECTED;

import com.mapbefine.mapbefine.member.domain.Member;
import com.mapbefine.mapbefine.topic.domain.Topic;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.util.Objects;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = PROTECTED)
@Getter
public class Atlas {

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

@ManyToOne
@JoinColumn(name = "topic_id", nullable = false)
private Topic topic;

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

private Atlas(Topic topic, Member member) {
this.topic = topic;
this.member = member;
}

public static Atlas from(Topic topic, Member member) {
validateNotNull(topic, member);
return new Atlas(topic, member);
}

private static void validateNotNull(Topic topic, Member member) {
if (Objects.isNull(topic) || Objects.isNull(member)) {
throw new IllegalArgumentException("토픽과 멤버는 Null이어선 안됩니다.");
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.mapbefine.mapbefine.atlas.domain;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AtlasRepository extends JpaRepository<Atlas, Long> {

List<Atlas> findAllByMemberId(Long memberId);

boolean existsByMemberIdAndTopicId(Long memberId, Long topicId);

void deleteByMemberIdAndTopicId(Long memberId, Long topicId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.mapbefine.mapbefine.atlas.presentation;

import com.mapbefine.mapbefine.atlas.application.AtlasCommandService;
import com.mapbefine.mapbefine.atlas.application.AtlasQueryService;
import com.mapbefine.mapbefine.auth.domain.AuthMember;
import com.mapbefine.mapbefine.common.interceptor.LoginRequired;
import com.mapbefine.mapbefine.topic.dto.response.TopicResponse;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/atlas")
public class AtlasController {

private final AtlasCommandService atlasCommandService;
private final AtlasQueryService atlasQueryService;

public AtlasController(AtlasCommandService atlasCommandService, AtlasQueryService atlasQueryService) {
this.atlasCommandService = atlasCommandService;
this.atlasQueryService = atlasQueryService;
}

@LoginRequired
@GetMapping
public ResponseEntity<List<TopicResponse>> findTopicsFromAtlas(AuthMember member) {
List<TopicResponse> topicResponses = atlasQueryService.findTopicsByMember(member);

return ResponseEntity.ok(topicResponses);
}

@LoginRequired
@PostMapping("/{topicId}")
public ResponseEntity<Void> addTopicToAtlas(AuthMember authMember, @PathVariable Long topicId) {
atlasCommandService.addTopic(authMember, topicId);

return ResponseEntity.status(HttpStatus.CREATED).build();
}

@LoginRequired
@DeleteMapping("/{topicId}")
public ResponseEntity<Void> removeTopicFromAtlas(AuthMember authMember, @PathVariable Long topicId) {
atlasCommandService.removeTopic(authMember, topicId);

return ResponseEntity.noContent().build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ public interface PinRepository extends JpaRepository<Pin, Long> {

List<Pin> findByCreatorId(Long creatorId);

List<Pin> findAllByOrderByUpdatedAtDesc();
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,29 @@ private List<Pin> getAllPinsFromTopics(List<Topic> topics) {
.toList();
}

public void copyPin(AuthMember member, Long topicId, List<Long> pinIds) {
Topic topic = findTopic(topicId);
validatePinCreateOrUpdateAuth(member, topic);

if (pinIds.isEmpty()) {
throw new IllegalArgumentException("복사할 핀을 선택해주세요");
}
junpakPark marked this conversation as resolved.
Show resolved Hide resolved

copyPinsToTopic(member, topic, pinIds);
Copy link
Collaborator

Choose a reason for hiding this comment

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

준팍 제가 JPA 를 정확히 모르는 것 같아서 여쭤봅니다.

copyPinsToTopic 메서드는

private void copyPinsToTopic(
        AuthMember member,
        Topic topic,
        List<Long> pinIds
) {
    List<Pin> originalPins = findAllPins(pinIds);
    validateCopyablePins(member, originalPins);

    Member creator = findCreatorByAuthMember(member);

    originalPins.forEach(pin -> pin.copyToTopic(creator, topic));
}

위와 같이 이루어져있어

사용자가 해당 핀들을 저장하고 싶은 토픽의 Pins 에는 저장이 되지만, 실질적으로 topic 을 save 하는 구문이 없어 cascade 로 인한 영속화가 진행되지 않을 것 같은데

맞을까요??

아니면 해당 토픽은 이미 영속화된 토픽이기 때문에 변경감지로 인해서 알아서 영속화가 진행이 되는 것일까용?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

네 변경감지와 CascadeType.PERSIST로 인해서
Pin들도 자동으로 영속 상태로 만들어 집니다.

  1. Pin을 복사할 때, 생성자에 포함된 연관관계 편의 메서드로 인해
    TopicList<Pin> pins에 변화가 생깁니다.

  2. 이러한 Topic의 변화를 JPA가 트랜잭션 커밋 시점에서 감지합니다. (변경 감지)

  3. Topic이 영속 상태로 만들어질 때, CascadeType.PERSIST로 인해
    연관된 Pin들도 함께 영속 상태로 만들어집니다.

  4. 이로 인해 연관관계에 있는 Pin들이 영속 상태로 만들어지면서, INSERT 쿼리가 실행시킵니다.

}

private Topic findTopic(Long topicId) {
return topicRepository.findById(topicId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 Topic입니다."));
}

private void validatePinCreateOrUpdateAuth(AuthMember member, Topic topic) {
if (member.canPinCreateOrUpdate(topic)) {
return;
}
throw new IllegalArgumentException("핀을 추가할 권한이 없습니다.");
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

메서드 명을 아래와 같이 변경하는 것은 어떠신가요 ?
validatePinCreateOrUpdateAuthInTopic

처음 해당 메서드 명을 보고, '복사하고자 하는 핀의 수정 가능여부 검증'으로 오해했어요 !

필수 요청 사항은 아니므로, 준팍이 고민해보고 결정해주세용 ~

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

반영했습니다

public void updateTopicInfo(
AuthMember member,
Long topicId,
Expand All @@ -171,16 +194,10 @@ public void updateTopicInfo(
topic.updateTopicStatus(request.publicity(), request.permission());
}

private Topic findTopic(Long topicId) {
return topicRepository.findById(topicId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 Topic입니다."));
}

private void validateUpdateAuth(AuthMember member, Topic topic) {
if (member.canTopicUpdate(topic)) {
return;
}

throw new IllegalArgumentException("업데이트 권한이 없습니다.");
}

Expand All @@ -197,7 +214,6 @@ private void validateDeleteAuth(AuthMember member, Topic topic) {
if (member.canDelete(topic)) {
return;
}

throw new IllegalArgumentException("삭제 권한이 없습니다.");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.mapbefine.mapbefine.topic.application;

import com.mapbefine.mapbefine.auth.domain.AuthMember;
import com.mapbefine.mapbefine.pin.domain.Pin;
import com.mapbefine.mapbefine.pin.domain.PinRepository;
import com.mapbefine.mapbefine.topic.domain.Topic;
import com.mapbefine.mapbefine.topic.domain.TopicRepository;
import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse;
Expand All @@ -13,9 +15,11 @@
@Transactional(readOnly = true)
public class TopicQueryService {

private final PinRepository pinRepository;
private final TopicRepository topicRepository;

public TopicQueryService(final TopicRepository topicRepository) {
public TopicQueryService(PinRepository pinRepository, TopicRepository topicRepository) {
this.pinRepository = pinRepository;
this.topicRepository = topicRepository;
}

Expand Down Expand Up @@ -75,4 +79,13 @@ private void validateReadableTopics(AuthMember member, List<Topic> topics) {
}
}

public List<TopicResponse> findAllByOrderByUpdatedAtDesc(AuthMember member) {
return pinRepository.findAllByOrderByUpdatedAtDesc()
.stream()
.map(Pin::getTopic)
.distinct()
.filter(member::canRead)
.map(TopicResponse::from)
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ public ResponseEntity<Void> mergeAndCreate(
.build();
}

@LoginRequired
@PostMapping("/{topicId}/copy")
public ResponseEntity<Void> copyPin(AuthMember member, @PathVariable Long topicId, @RequestParam List<Long> pinIds) {
topicCommandService.copyPin(member, topicId, pinIds);

return ResponseEntity.ok().build();
}

@GetMapping
public ResponseEntity<List<TopicResponse>> findAll(AuthMember member) {
List<TopicResponse> topics = topicQueryService.findAllReadable(member);
Expand Down Expand Up @@ -85,6 +93,13 @@ public ResponseEntity<List<TopicDetailResponse>> findByIds(
return ResponseEntity.ok(responses);
}

@GetMapping("/newest")
public ResponseEntity<List<TopicResponse>> findAllByOrderByUpdatedAtDesc(AuthMember member) {
List<TopicResponse> responses = topicQueryService.findAllByOrderByUpdatedAtDesc(member);

return ResponseEntity.ok(responses);
}

@LoginRequired
@PutMapping("/{topicId}")
public ResponseEntity<Void> update(
Expand Down
Loading
Loading