Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public MemberResponse signup(SignupRequest request) {
request.nickname()
);

return MemberResponse.from(memberRepository.save(member));
return MemberResponse.from(memberRepository.save(member), null);
}

public TokenResponse login(LoginRequest request) {
Expand Down
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();
}
}
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
) {
}
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()
);
}
}
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 + "분";
}
}
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
);
}
}
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);
}
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));
Comment on lines +52 to +56

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

# Map the relevant service, entity, repository, and migration files.
git ls-files | rg 'src/main/java/com/cotato/cokerthon/domain/companion/service/CompanionService\.java|src/main/java/com/cotato/cokerthon/domain/companion|src/main/resources|src/test' || true

# Show a compact outline of likely relevant Java files.
for f in \
  src/main/java/com/cotato/cokerthon/domain/companion/service/CompanionService.java \
  src/main/java/com/cotato/cokerthon/domain/companion/domain/Companion.java \
  src/main/java/com/cotato/cokerthon/domain/companion/repository/CompanionRepository.java
do
  if [ -f "$f" ]; then
    echo "### OUTLINE $f"
    ast-grep outline "$f" --view expanded || true
  fi
done

# Locate all references to the duplicate check and companion table constraints.
rg -n "existsByMemberAndCompanionMember|ALREADY_COMPANION|Companion.create|unique|constraint|companion_member|member_id" \
  src/main/java src/main/resources || true

Repository: 8th-COKERTHON/server-team5

Length of output: 6605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant source sections with line numbers.
for f in \
  src/main/java/com/cotato/cokerthon/domain/companion/service/CompanionService.java \
  src/main/java/com/cotato/cokerthon/domain/companion/domain/Companion.java \
  src/main/java/com/cotato/cokerthon/domain/companion/repository/CompanionRepository.java
do
  if [ -f "$f" ]; then
    echo "### FILE $f"
    wc -l "$f"
    sed -n '1,220p' "$f" | cat -n
    echo
  fi
done

# Inspect schema/migrations if present.
for f in $(git ls-files | rg 'src/main/resources/(db/migration|schema|data)/.*\.(sql|yaml|yml|json)$' || true); do
  echo "### FILE $f"
  wc -l "$f"
  sed -n '1,240p' "$f" | cat -n
  echo
done

Repository: 8th-COKERTHON/server-team5

Length of output: 5564


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Relevant files only.
for f in \
  src/main/java/com/cotato/cokerthon/domain/companion/service/CompanionService.java \
  src/main/java/com/cotato/cokerthon/domain/companion/domain/Companion.java \
  src/main/java/com/cotato/cokerthon/domain/companion/repository/CompanionRepository.java
do
  if [ -f "$f" ]; then
    echo "### $f"
    wc -l "$f"
    sed -n '1,220p' "$f" | cat -n
    echo
  fi
done

echo "### SEARCH"
rg -n "existsByMemberAndCompanionMember|ALREADY_COMPANION|Companion.create|`@Table`|uniqueConstraints|`@UniqueConstraint`|companion_member|member_id" \
  src/main/java src/main/resources || true

Repository: 8th-COKERTHON/server-team5

Length of output: 9079


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the companion service, entity, and repository with line numbers.
for f in \
  src/main/java/com/cotato/cokerthon/domain/companion/service/CompanionService.java \
  src/main/java/com/cotato/cokerthon/domain/companion/domain/Companion.java \
  src/main/java/com/cotato/cokerthon/domain/companion/repository/CompanionRepository.java
do
  if [ -f "$f" ]; then
    echo "### $f"
    wc -l "$f"
    sed -n '1,240p' "$f" | cat -n
    echo
  fi
done

Repository: 8th-COKERTHON/server-team5

Length of output: 5554


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for schema/migration-level uniqueness constraints and duplicate handling.
rg -n "`@UniqueConstraint`|uniqueConstraints|`@Table`|ALREADY_COMPANION|existsByMemberAndCompanionMember|companion_member|member_id" \
  src/main/java src/main/resources || true

Repository: 8th-COKERTHON/server-team5

Length of output: 3385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the companion package structure.
find src/main/java/com/cotato/cokerthon/domain/companion -maxdepth 4 -type f | sort

Repository: 8th-COKERTHON/server-team5

Length of output: 862


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the companion entity and any global exception translation around data integrity violations.
for f in \
  src/main/java/com/cotato/cokerthon/domain/companion/entity/Companion.java \
  src/main/java/com/cotato/cokerthon/global/exception/GlobalExceptionHandler.java \
  src/main/java/com/cotato/cokerthon/global/exception/BusinessException.java
do
  if [ -f "$f" ]; then
    echo "### FILE $f"
    wc -l "$f"
    sed -n '1,240p' "$f" | cat -n
    echo
  fi
done

echo "### SEARCH"
rg -n "DataIntegrityViolationException|ConstraintViolationException|ALREADY_COMPANION|BusinessException|ExceptionHandler|`@RestControllerAdvice`|`@ControllerAdvice`" \
  src/main/java || true

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 on Companion, but the existsBy... check is still race-prone and a concurrent insert can bubble up as a generic 500. Catch the unique-violation here and translate it to ALREADY_COMPANION.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/cotato/cokerthon/domain/companion/service/CompanionService.java`
around lines 52 - 56, Handle the race-prone duplicate insert in the companion
creation flow: update the method containing existsByMemberAndCompanionMember and
Companion.create/save to catch the repository’s unique-constraint violation
(typically DataIntegrityViolationException) and throw
BusinessException(ErrorCode.ALREADY_COMPANION) instead, while preserving other
persistence errors.

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));
}
}
Loading
Loading