-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement MVP2 companion sharing feature #8
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
117 changes: 117 additions & 0 deletions
117
src/main/java/com/cotato/cokerthon/domain/companion/controller/CompanionController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| package com.cotato.cokerthon.domain.companion.controller; | ||
|
|
||
| import com.cotato.cokerthon.domain.companion.dto.request.CompanionCreateRequest; | ||
| import com.cotato.cokerthon.domain.companion.dto.response.CompanionResponse; | ||
| import com.cotato.cokerthon.domain.companion.dto.response.CompanionSearchResponse; | ||
| import com.cotato.cokerthon.domain.companion.service.CompanionService; | ||
| import com.cotato.cokerthon.global.response.ApiResponse; | ||
| import com.cotato.cokerthon.global.security.LoginMember; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.responses.ApiResponses; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.validation.Valid; | ||
| import java.util.List; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.DeleteMapping; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @Tag(name = "Companion", description = "MVP2 - 친구와 수면 국가 공유 (동행자) API") | ||
| @RestController | ||
| @RequestMapping("/api/companions") | ||
| public class CompanionController { | ||
|
|
||
| private final CompanionService companionService; | ||
|
|
||
| public CompanionController(CompanionService companionService) { | ||
| this.companionService = companionService; | ||
| } | ||
|
|
||
| @Operation( | ||
| summary = "동행자 찾기 검색", | ||
| description = "로그인 아이디로 동행자가 될 상대를 검색합니다. 이미 동행자로 추가된 상대인지 여부도 함께 내려줍니다." | ||
| ) | ||
| @ApiResponses({ | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "401", description = "인증이 필요합니다 (AUTH_401)"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "404", description = "해당 아이디의 회원이 존재하지 않습니다 (COMMON_404)") | ||
| }) | ||
| @GetMapping("/search") | ||
| public ApiResponse<CompanionSearchResponse> search( | ||
| @AuthenticationPrincipal LoginMember loginMember, | ||
| @RequestParam String loginId | ||
| ) { | ||
| return ApiResponse.ok(companionService.search(loginMember.id(), loginId)); | ||
| } | ||
|
|
||
| @Operation( | ||
| summary = "동행자 추가", | ||
| description = """ | ||
| 입력한 아이디의 회원을 동행자로 추가합니다. | ||
|
|
||
| - 상호 동의 절차 없는 단방향 관계입니다. 추가 버튼을 누르는 즉시 등록됩니다 (상대方 수락 불필요). | ||
| - 상대방의 동행자 목록에는 나타나지 않으며, 나의 목록에만 표시됩니다. | ||
| - 자기 자신은 추가할 수 없고, 이미 추가한 상대를 다시 추가할 수 없습니다. | ||
| """ | ||
| ) | ||
| @ApiResponses({ | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "추가 성공"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "400", description = "자기 자신을 추가하려 함 (COMPANION_400_001)"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "401", description = "인증이 필요합니다 (AUTH_401)"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "404", description = "해당 아이디의 회원이 존재하지 않습니다 (COMMON_404)"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "409", description = "이미 추가한 동행자입니다 (COMPANION_409_001)") | ||
| }) | ||
| @PostMapping | ||
| public ApiResponse<CompanionResponse> add( | ||
| @AuthenticationPrincipal LoginMember loginMember, | ||
| @Valid @RequestBody CompanionCreateRequest request | ||
| ) { | ||
| return ApiResponse.ok(companionService.add(loginMember.id(), request)); | ||
| } | ||
|
|
||
| @Operation( | ||
| summary = "동행자 목록 조회", | ||
| description = "내가 추가한 동행자들의 닉네임, 현재 수면 도시, 서울과의 수면시차, 마지막 기록 시각을 조회합니다. " | ||
| + "아직 수면시차를 계산한 적 없는 동행자는 city/jetlag 관련 필드가 null로 내려갑니다." | ||
| ) | ||
| @ApiResponses({ | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "401", description = "인증이 필요합니다 (AUTH_401)") | ||
| }) | ||
| @GetMapping | ||
| public ApiResponse<List<CompanionResponse>> getCompanions(@AuthenticationPrincipal LoginMember loginMember) { | ||
| return ApiResponse.ok(companionService.getCompanions(loginMember.id())); | ||
| } | ||
|
|
||
| @Operation( | ||
| summary = "동행자 삭제", | ||
| description = "동행자 목록에서 특정 동행자와의 연결을 해제합니다. 단방향 연결만 삭제되며 상대방에게는 영향이 없습니다." | ||
| ) | ||
| @ApiResponses({ | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "401", description = "인증이 필요합니다 (AUTH_401)"), | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "404", description = "동행자로 추가되어 있지 않음 (COMMON_404)") | ||
| }) | ||
| @DeleteMapping("/{companionMemberId}") | ||
| public ApiResponse<Void> remove( | ||
| @AuthenticationPrincipal LoginMember loginMember, | ||
| @PathVariable Long companionMemberId | ||
| ) { | ||
| companionService.remove(loginMember.id(), companionMemberId); | ||
| return ApiResponse.ok(); | ||
| } | ||
| } |
12 changes: 12 additions & 0 deletions
12
src/main/java/com/cotato/cokerthon/domain/companion/dto/request/CompanionCreateRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.cotato.cokerthon.domain.companion.dto.request; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.NotBlank; | ||
|
|
||
| @Schema(description = "동행자 추가 요청") | ||
| public record CompanionCreateRequest( | ||
| @Schema(description = "추가할 동행자의 로그인 아이디", example = "meangg") | ||
| @NotBlank(message = "추가할 동행자의 아이디는 필수입니다.") | ||
| String loginId | ||
| ) { | ||
| } |
37 changes: 37 additions & 0 deletions
37
src/main/java/com/cotato/cokerthon/domain/companion/dto/response/CompanionCityResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package com.cotato.cokerthon.domain.companion.dto.response; | ||
|
|
||
| import com.cotato.cokerthon.domain.city.entity.City; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| @Schema(description = "동행자 카드에 표시되는 현재 수면 도시 정보") | ||
| public record CompanionCityResponse( | ||
| @Schema(description = "국가명", example = "태국") | ||
| String countryName, | ||
|
|
||
| @Schema(description = "도시명 (한글)", example = "방콕") | ||
| String cityNameKr, | ||
|
|
||
| @Schema(description = "영문 도시명", example = "BANGKOK") | ||
| String cityNameEn, | ||
|
|
||
| @Schema(description = "IATA 공항 코드", example = "BKK") | ||
| String airportCode, | ||
|
|
||
| @Schema(description = "위도 (프론트 지구본 매핑용)", example = "13.7563") | ||
| double latitude, | ||
|
|
||
| @Schema(description = "경도 (프론트 지구본 매핑용)", example = "100.5018") | ||
| double longitude | ||
| ) { | ||
|
|
||
| public static CompanionCityResponse from(City city) { | ||
| return new CompanionCityResponse( | ||
| city.getCountryName(), | ||
| city.getCityNameKr(), | ||
| city.getCityNameEn(), | ||
| city.getAirportCode(), | ||
| city.getLatitude(), | ||
| city.getLongitude() | ||
| ); | ||
| } | ||
| } |
70 changes: 70 additions & 0 deletions
70
src/main/java/com/cotato/cokerthon/domain/companion/dto/response/CompanionResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package com.cotato.cokerthon.domain.companion.dto.response; | ||
|
|
||
| import com.cotato.cokerthon.domain.member.entity.Member; | ||
| import com.cotato.cokerthon.domain.sleep.entity.JetlagDirection; | ||
| import com.cotato.cokerthon.domain.sleep.entity.SleepJetlagResult; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import java.time.LocalDateTime; | ||
|
|
||
| @Schema(description = "동행자 카드 (같이 여행 중인 친구)") | ||
| public record CompanionResponse( | ||
| @Schema(description = "동행자의 회원 ID (삭제 시 사용)", example = "2") | ||
| Long companionMemberId, | ||
|
|
||
| @Schema(description = "동행자 닉네임", example = "민지") | ||
| String nickname, | ||
|
|
||
| @Schema(description = "동행자 프로필 이미지 URL", example = "https://cdn.sleepair.app/profile/2.png", nullable = true) | ||
| String profileImageUrl, | ||
|
|
||
| @Schema(description = "동행자의 현재 수면 도시. 아직 계산 기록이 없으면 null", nullable = true) | ||
| CompanionCityResponse city, | ||
|
|
||
| @Schema(description = "동행자의 수면시차(분). 기록이 없으면 null", example = "120", nullable = true) | ||
| Integer jetlagMinutes, | ||
|
|
||
| @Schema(description = "화면에 바로 표시할 수 있는 시차 라벨. 기록이 없으면 null", example = "2시간", nullable = true) | ||
| String jetlagLabel, | ||
|
|
||
| @Schema(description = "서울 대비 조정 방향. 기록이 없으면 null", example = "WEST", nullable = true) | ||
| JetlagDirection direction, | ||
|
|
||
| @Schema(description = "마지막 기록 시각. 기록이 없으면 null", nullable = true) | ||
| LocalDateTime lastRecordedAt | ||
| ) { | ||
|
|
||
| public static CompanionResponse of(Member companionMember, SleepJetlagResult latestResult) { | ||
| if (latestResult == null) { | ||
| return new CompanionResponse( | ||
| companionMember.getId(), | ||
| companionMember.getNickname(), | ||
| companionMember.getProfileImageUrl(), | ||
| null, null, null, null, null | ||
| ); | ||
| } | ||
|
|
||
| return new CompanionResponse( | ||
| companionMember.getId(), | ||
| companionMember.getNickname(), | ||
| companionMember.getProfileImageUrl(), | ||
| CompanionCityResponse.from(latestResult.getMatchedCity()), | ||
| latestResult.getJetlagMinutes(), | ||
| formatJetlagLabel(latestResult.getJetlagMinutes()), | ||
| latestResult.getDirection(), | ||
| latestResult.getCreatedAt() | ||
| ); | ||
| } | ||
|
|
||
| private static String formatJetlagLabel(int jetlagMinutes) { | ||
| int hours = jetlagMinutes / 60; | ||
| int minutes = jetlagMinutes % 60; | ||
|
|
||
| if (hours == 0) { | ||
| return minutes + "분"; | ||
| } | ||
| if (minutes == 0) { | ||
| return hours + "시간"; | ||
| } | ||
| return hours + "시간 " + minutes + "분"; | ||
| } | ||
| } |
33 changes: 33 additions & 0 deletions
33
...main/java/com/cotato/cokerthon/domain/companion/dto/response/CompanionSearchResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.cotato.cokerthon.domain.companion.dto.response; | ||
|
|
||
| import com.cotato.cokerthon.domain.member.entity.Member; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| @Schema(description = "동행자 찾기 검색 결과") | ||
| public record CompanionSearchResponse( | ||
| @Schema(description = "검색된 회원 ID", example = "3") | ||
| Long memberId, | ||
|
|
||
| @Schema(description = "검색된 회원의 로그인 아이디", example = "meangg") | ||
| String loginId, | ||
|
|
||
| @Schema(description = "닉네임", example = "민주") | ||
| String nickname, | ||
|
|
||
| @Schema(description = "프로필 이미지 URL", example = "https://cdn.sleepair.app/profile/3.png", nullable = true) | ||
| String profileImageUrl, | ||
|
|
||
| @Schema(description = "이미 내 동행자 목록에 추가된 상대인지 여부", example = "false") | ||
| boolean alreadyCompanion | ||
| ) { | ||
|
|
||
| public static CompanionSearchResponse of(Member member, boolean alreadyCompanion) { | ||
| return new CompanionSearchResponse( | ||
| member.getId(), | ||
| member.getLoginId(), | ||
| member.getNickname(), | ||
| member.getProfileImageUrl(), | ||
| alreadyCompanion | ||
| ); | ||
| } | ||
| } |
16 changes: 16 additions & 0 deletions
16
src/main/java/com/cotato/cokerthon/domain/companion/repository/CompanionRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.cotato.cokerthon.domain.companion.repository; | ||
|
|
||
| import com.cotato.cokerthon.domain.companion.entity.Companion; | ||
| import com.cotato.cokerthon.domain.member.entity.Member; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface CompanionRepository extends JpaRepository<Companion, Long> { | ||
|
|
||
| boolean existsByMemberAndCompanionMember(Member member, Member companionMember); | ||
|
|
||
| Optional<Companion> findByMemberAndCompanionMember(Member member, Member companionMember); | ||
|
|
||
| List<Companion> findAllByMember(Member member); | ||
| } |
96 changes: 96 additions & 0 deletions
96
src/main/java/com/cotato/cokerthon/domain/companion/service/CompanionService.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| package com.cotato.cokerthon.domain.companion.service; | ||
|
|
||
| import com.cotato.cokerthon.domain.companion.dto.request.CompanionCreateRequest; | ||
| import com.cotato.cokerthon.domain.companion.dto.response.CompanionResponse; | ||
| import com.cotato.cokerthon.domain.companion.dto.response.CompanionSearchResponse; | ||
| import com.cotato.cokerthon.domain.companion.entity.Companion; | ||
| import com.cotato.cokerthon.domain.companion.repository.CompanionRepository; | ||
| import com.cotato.cokerthon.domain.member.entity.Member; | ||
| import com.cotato.cokerthon.domain.member.repository.MemberRepository; | ||
| import com.cotato.cokerthon.domain.sleep.entity.SleepJetlagResult; | ||
| import com.cotato.cokerthon.domain.sleep.repository.SleepJetlagResultRepository; | ||
| import com.cotato.cokerthon.global.exception.BusinessException; | ||
| import com.cotato.cokerthon.global.exception.ErrorCode; | ||
| import java.util.List; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| @Service | ||
| @Transactional(readOnly = true) | ||
| public class CompanionService { | ||
|
|
||
| private final CompanionRepository companionRepository; | ||
| private final MemberRepository memberRepository; | ||
| private final SleepJetlagResultRepository sleepJetlagResultRepository; | ||
|
|
||
| public CompanionService( | ||
| CompanionRepository companionRepository, | ||
| MemberRepository memberRepository, | ||
| SleepJetlagResultRepository sleepJetlagResultRepository | ||
| ) { | ||
| this.companionRepository = companionRepository; | ||
| this.memberRepository = memberRepository; | ||
| this.sleepJetlagResultRepository = sleepJetlagResultRepository; | ||
| } | ||
|
|
||
| public CompanionSearchResponse search(Long memberId, String targetLoginId) { | ||
| Member me = getMember(memberId); | ||
| Member target = getMemberByLoginId(targetLoginId); | ||
|
|
||
| boolean alreadyCompanion = companionRepository.existsByMemberAndCompanionMember(me, target); | ||
| return CompanionSearchResponse.of(target, alreadyCompanion); | ||
| } | ||
|
|
||
| @Transactional | ||
| public CompanionResponse add(Long memberId, CompanionCreateRequest request) { | ||
| Member me = getMember(memberId); | ||
| Member target = getMemberByLoginId(request.loginId()); | ||
|
|
||
| if (me.getId().equals(target.getId())) { | ||
| throw new BusinessException(ErrorCode.SELF_COMPANION_NOT_ALLOWED); | ||
| } | ||
| if (companionRepository.existsByMemberAndCompanionMember(me, target)) { | ||
| throw new BusinessException(ErrorCode.ALREADY_COMPANION); | ||
| } | ||
|
|
||
| companionRepository.save(Companion.create(me, target)); | ||
| return toCompanionResponse(target); | ||
| } | ||
|
|
||
| public List<CompanionResponse> getCompanions(Long memberId) { | ||
| Member me = getMember(memberId); | ||
|
|
||
| return companionRepository.findAllByMember(me).stream() | ||
| .map(companion -> toCompanionResponse(companion.getCompanionMember())) | ||
| .toList(); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void remove(Long memberId, Long companionMemberId) { | ||
| Member me = getMember(memberId); | ||
| Member target = getMember(companionMemberId); | ||
|
|
||
| Companion companion = companionRepository.findByMemberAndCompanionMember(me, target) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); | ||
|
|
||
| companionRepository.delete(companion); | ||
| } | ||
|
|
||
| private CompanionResponse toCompanionResponse(Member companionMember) { | ||
| SleepJetlagResult latestResult = sleepJetlagResultRepository | ||
| .findFirstByMemberOrderByCreatedAtDesc(companionMember) | ||
| .orElse(null); | ||
|
|
||
| return CompanionResponse.of(companionMember, latestResult); | ||
| } | ||
|
|
||
| private Member getMember(Long memberId) { | ||
| return memberRepository.findById(memberId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); | ||
| } | ||
|
|
||
| private Member getMemberByLoginId(String loginId) { | ||
| return memberRepository.findByLoginId(loginId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: 8th-COKERTHON/server-team5
Length of output: 6605
🏁 Script executed:
Repository: 8th-COKERTHON/server-team5
Length of output: 5564
🏁 Script executed:
Repository: 8th-COKERTHON/server-team5
Length of output: 9079
🏁 Script executed:
Repository: 8th-COKERTHON/server-team5
Length of output: 5554
🏁 Script executed:
Repository: 8th-COKERTHON/server-team5
Length of output: 3385
🏁 Script executed:
Repository: 8th-COKERTHON/server-team5
Length of output: 862
🏁 Script executed:
Repository: 8th-COKERTHON/server-team5
Length of output: 9966
CompanionService.java:52-56 — Map the duplicate-insert failure to
ALREADY_COMPANION. The(member_id, companion_member_id)unique constraint is already onCompanion, but theexistsBy...check is still race-prone and a concurrent insert can bubble up as a generic 500. Catch the unique-violation here and translate it toALREADY_COMPANION.🤖 Prompt for AI Agents