-
Notifications
You must be signed in to change notification settings - Fork 6
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/#251 최신 지도 조회 API 구현 #258
Changes from 9 commits
e9d7017
1de1b01
62ba5f1
10df876
53ec9b4
8af79ae
b075d3a
b6e45e7
fd65611
fe0225a
e8ac42a
8167a2c
573fc31
4faba89
d4429bb
9905b19
14296c4
1ef8041
f8b18d3
e530d4d
a7f1392
88ff4e6
0c2d500
e84a463
8b479c3
4d87239
5e216db
7c45b11
9c98f8a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
---|---|---|
@@ -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; | ||
|
@@ -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; | ||
} | ||
|
||
|
@@ -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(); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 질문이 있습니다 ! Topic에 대해서 distinct를 통해 중복을 제거하려고 하신 것 같은데, 해당 동작이 올바르게 수행 되나요 ? 만약, 올바르게 동작하지 않는다면 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Topic에 별다른 equals&Hashcode가 정의 되어있지 않더라도 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 준팍 말씀대로, 영속성 컨텍스트에서 수행해주는 것이 맞는 것 같네요 ~ 한 가지 걱정이되는 부분은, 서비스 특징상(핀 복사 등) findAll로 데이터를 가져올 경우 너무 많은 데이터를 가져오지 않을까 ? 싶습니다. Topic에도 updatedAt이라는 컬럼이 있다보니 위의 문제를 개선할 수 있지 않을까요 ? 처음 리뷰 당시에 이 부분을 인지하지 못했었네요 ㅠㅠ 죄송합니다 ! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제가 알기로, Topic의 updatedAt은 그래서 지금과 같은 상황에서 Topic의 updatedAt으로 해결할 경우, 저희의 도메인 요구사항은 Topic의 updatedAt을 사용하는 건 어려울 것으로 판단되는데 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재로서는 topic 에 마지막으로 핀이 추가된 시각 등을 컬럼으로 두고, pin 을 추가할 때 해당 컬럼을 갱신한 뒤, 정렬하여 가져오는 것이 가장 부하가 적어보이긴하네용! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 비즈니스적인 updatedAt이 필요하다.. 정도로 결론을 내리면 될까요 허헛 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
package com.mapbefine.mapbefine.atlas; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.springframework.http.HttpHeaders.AUTHORIZATION; | ||
|
||
import com.mapbefine.mapbefine.atlas.domain.Atlas; | ||
import com.mapbefine.mapbefine.atlas.domain.AtlasRepository; | ||
import com.mapbefine.mapbefine.common.IntegrationTest; | ||
import com.mapbefine.mapbefine.member.MemberFixture; | ||
import com.mapbefine.mapbefine.member.domain.Member; | ||
import com.mapbefine.mapbefine.member.domain.MemberRepository; | ||
import com.mapbefine.mapbefine.member.domain.Role; | ||
import com.mapbefine.mapbefine.topic.TopicFixture; | ||
import com.mapbefine.mapbefine.topic.domain.Topic; | ||
import com.mapbefine.mapbefine.topic.domain.TopicRepository; | ||
import io.restassured.RestAssured; | ||
import io.restassured.response.ExtractableResponse; | ||
import io.restassured.response.Response; | ||
import org.apache.tomcat.util.codec.binary.Base64; | ||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.DisplayName; | ||
import org.junit.jupiter.api.Test; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.http.HttpStatus; | ||
|
||
public class AtlasIntegrationTest extends IntegrationTest { | ||
|
||
@Autowired | ||
TopicRepository topicRepository; | ||
|
||
@Autowired | ||
MemberRepository memberRepository; | ||
|
||
@Autowired | ||
AtlasRepository atlasRepository; | ||
|
||
private Member member; | ||
private Topic topic; | ||
private String authHeader; | ||
|
||
@BeforeEach | ||
void setMember() { | ||
member = memberRepository.save( | ||
MemberFixture.create("other", "[email protected]", Role.USER) | ||
); | ||
topic = topicRepository.save(TopicFixture.createPublicAndAllMembersTopic(member)); | ||
authHeader = Base64.encodeBase64String( | ||
("Basic " + member.getMemberInfo().getEmail()).getBytes() | ||
); | ||
} | ||
|
||
@Test | ||
@DisplayName("모아보기에 추가되어있는 지도 목록을 조회한다") | ||
void findTopicsFromAtlas_Success() { | ||
// when | ||
ExtractableResponse<Response> response = RestAssured.given() | ||
.log().all() | ||
.header(AUTHORIZATION, authHeader) | ||
.when().get("/atlas") | ||
.then().log().all() | ||
.extract(); | ||
|
||
// then | ||
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); | ||
} | ||
|
||
@Test | ||
@DisplayName("모아보기에 지도를 추가한다") | ||
void addTopicToAtlas_Success() { | ||
//given | ||
Long topicId = topic.getId(); | ||
|
||
// when | ||
ExtractableResponse<Response> response = RestAssured.given() | ||
.log().all() | ||
.header(AUTHORIZATION, authHeader) | ||
.when().post("/atlas/{topicId}", topicId) | ||
.then().log().all() | ||
.extract(); | ||
|
||
// then | ||
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); | ||
} | ||
|
||
|
||
@Test | ||
@DisplayName("모아보기에 추가되어있는 지도를 삭제한다") | ||
void removeTopicFromAtlas_Success() { | ||
//given | ||
Long topicId = topic.getId(); | ||
atlasRepository.save(Atlas.from(topic, member)); | ||
|
||
// when | ||
ExtractableResponse<Response> response = RestAssured.given() | ||
.log().all() | ||
.header(AUTHORIZATION, authHeader) | ||
.when().delete("/atlas/{topicId}", topicId) | ||
.then().log().all() | ||
.extract(); | ||
|
||
// then | ||
assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ㅋㅋㅋㅋㅋㅋ 먼저 발견하셨군요 !