Skip to content
Open
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 @@ -13,8 +13,11 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

Expand Down Expand Up @@ -67,13 +70,144 @@ public class MissionController {
)
})
@PostMapping
public ResponseEntity<ApiResponse<MissionResponseDto.ChallengeMissionDto>> challengeMission(
public ApiResponse<MissionResponseDto.ChallengeMissionDto> challengeMission(
@Parameter(hidden = true) @LoginMemberId Long memberId,
@Valid @RequestBody MissionRequestDto.ChallengeMissionDto request) {

MissionResponseDto.ChallengeMissionDto response =
missionService.challengeMission(memberId, request);

return ResponseEntity.ok(ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response));
return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response);
}

/**
* 내가 진행 중인 미션 목록 조회 API
* GET /api/v1/members/me/missions/in-progress
*
* @param memberId 현재 로그인한 회원 ID (헤더에서 자동 추출)
* @param page 페이지 번호 (0부터 시작, 기본값: 0)
* @param size 페이지 크기 (1~100, 기본값: 10)
* @return 진행 중인 미션 목록 (페이징 정보 포함)
*/
@Operation(
summary = "내가 진행 중인 미션 목록 조회",
description = "현재 로그인한 회원이 진행 중인 미션 목록을 조회합니다. 페이징을 지원하며, 최근 도전한 순서로 정렬됩니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "미션 목록 조회 성공",
content = @Content(schema = @Schema(implementation = MissionResponseDto.MissionListDto.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 페이징 파라미터 (페이지 번호 음수, 크기 범위 초과 등)"
)
})
@GetMapping("/in-progress")
public ApiResponse<org.springframework.data.domain.Page<MissionResponseDto.MissionListDto>> getInProgressMissions(
@Parameter(hidden = true) @LoginMemberId Long memberId,
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0")
@Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기 (1~100)", example = "10")
@Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.")
@Max(value = 100, message = "페이지 크기는 100 이하여야 합니다.")
@RequestParam(defaultValue = "10") int size) {

// Pageable 생성
Pageable pageable = PageRequest.of(page, size);

// 서비스 호출
org.springframework.data.domain.Page<MissionResponseDto.MissionListDto> response =
missionService.getInProgressMissions(memberId, pageable);

return ApiResponse.onSuccess(GeneralSuccessCode.OK, response);
}

/**
* 진행 중인 미션을 완료로 변경 API
* PATCH /api/v1/members/me/missions/{missionId}/complete
*
* @param memberId 현재 로그인한 회원 ID (헤더에서 자동 추출)
* @param missionId 완료할 미션 ID (Path Variable)
* @return 완료된 미션 정보
*/
@Operation(
summary = "진행 중인 미션 완료하기",
description = "현재 로그인한 회원이 진행 중인 미션을 완료 상태로 변경합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "미션 완료 성공",
content = @Content(schema = @Schema(implementation = MissionResponseDto.CompleteMissionDto.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "회원의 미션을 찾을 수 없음"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "이미 완료된 미션"
)
})
@PatchMapping("/{missionId}/complete")
public ApiResponse<MissionResponseDto.CompleteMissionDto> completeMission(
@Parameter(hidden = true) @LoginMemberId Long memberId,
@Parameter(description = "완료할 미션 ID", example = "1")
@PathVariable Long missionId) {

// 서비스 호출
MissionResponseDto.CompleteMissionDto response =
missionService.completeMission(memberId, missionId);

return ApiResponse.onSuccess(GeneralSuccessCode.OK, response);
}

/**
* 특정 가게의 미션 목록 조회 API
* GET /api/v1/restaurants/{restaurantId}/missions
*
* @param restaurantId 가게 ID (Path Variable)
* @param page 페이지 번호 (0부터 시작, 기본값: 0)
* @param size 페이지 크기 (1~100, 기본값: 10)
* @return 가게의 미션 목록 (페이징 정보 포함)
*/
@Operation(
summary = "특정 가게의 미션 목록 조회",
description = "특정 가게에 등록된 미션 목록을 조회합니다. 페이징을 지원하며, 마감일 임박순으로 정렬됩니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "미션 목록 조회 성공",
content = @Content(schema = @Schema(implementation = MissionResponseDto.RestaurantMissionListDto.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 페이징 파라미터 (페이지 번호 음수, 크기 범위 초과 등)"
)
})
@GetMapping("/restaurants/{restaurantId}/missions")
public ApiResponse<MissionResponseDto.RestaurantMissionListDto> getMissionsByRestaurant(
@Parameter(description = "가게 ID", example = "1")
@PathVariable Long restaurantId,
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0")
@Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기 (1~100)", example = "10")
@Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.")
@Max(value = 100, message = "페이지 크기는 100 이하여야 합니다.")
@RequestParam(defaultValue = "10") int size) {

// Pageable 생성
Pageable pageable = PageRequest.of(page, size);

// 서비스 호출
MissionResponseDto.RestaurantMissionListDto response =
missionService.getMissionsByRestaurant(restaurantId, pageable);

return ApiResponse.onSuccess(GeneralSuccessCode.OK, response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;

public class MissionResponseDto {

Expand Down Expand Up @@ -131,4 +133,91 @@ public static AvailableMissionDto from(Mission mission) {
.build();
}
}

/**
* 미션 완료 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public static class CompleteMissionDto {
private Long memberMissionId;
private Long missionId;
private String status; // "완료"
private String completedAt; // "2025.11.30" 형식

/**
* MemberMission Entity -> CompleteMissionDto 변환
*/
public static CompleteMissionDto from(MemberMission memberMission) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd");
return CompleteMissionDto.builder()
.memberMissionId(memberMission.getId())
.missionId(memberMission.getMission().getId())
.status("완료")
.completedAt(LocalDateTime.now().format(formatter))
.build();
}
}

/**
* 특정 가게의 미션 DTO
*/
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public static class RestaurantMissionDto {
private Long missionId;
private String restaurantName;
private String missionCondition;
private LocalDateTime deadline;
private Integer missionPoint;

/**
* Mission Entity -> RestaurantMissionDto 변환
*/
public static RestaurantMissionDto from(Mission mission) {
return RestaurantMissionDto.builder()
.missionId(mission.getId())
.restaurantName(mission.getRestaurant().getRestaurantName())
.missionCondition(mission.getMissionCondition())
.deadline(mission.getDeadline())
.missionPoint(mission.getMissionPoint())
.build();
}
}

/**
* 특정 가게의 미션 목록 응답 DTO (페이징 포함)
*/
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public static class RestaurantMissionListDto {
private List<RestaurantMissionDto> missions;
private int currentPage;
private int totalPages;
private long totalElements;
private boolean hasNext;

/**
* Page<Mission> -> RestaurantMissionListDto 변환
*/
public static RestaurantMissionListDto from(Page<Mission> missionPage) {
List<RestaurantMissionDto> missions = missionPage.getContent().stream()
.map(RestaurantMissionDto::from)
.collect(Collectors.toList());

return RestaurantMissionListDto.builder()
.missions(missions)
.currentPage(missionPage.getNumber())
.totalPages(missionPage.getTotalPages())
.totalElements(missionPage.getTotalElements())
.hasNext(missionPage.hasNext())
.build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,11 @@ public class MemberMission {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mission_id", nullable = false)
private Mission mission;

/**
* 미션을 완료 상태로 변경
*/
public void complete() {
this.isComplete = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,19 @@ Page<MemberMission> findMissionsByMemberAndStatus(
* @return 도전 중이면 true, 아니면 false
*/
boolean existsByMemberIdAndMissionId(Long memberId, Long missionId);

/**
* 회원 ID와 미션 ID로 MemberMission 조회
* @param memberId 회원 ID
* @param missionId 미션 ID
* @return MemberMission
*/
@Query("SELECT mm FROM MemberMission mm " +
"JOIN FETCH mm.mission m " +
"WHERE mm.member.id = :memberId " +
"AND mm.mission.id = :missionId")
java.util.Optional<MemberMission> findByMemberIdAndMissionId(
@Param("memberId") Long memberId,
@Param("missionId") Long missionId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,24 @@ Page<Mission> findAvailableMissionsByRegion(
@Param("baseAddressId") Long baseAddressId,
Pageable pageable
);

/**
* 특정 가게의 미션 목록 조회
* - JOIN FETCH로 N+1 문제 해결
* - deadline 오름차순 정렬 (마감일 임박순)
*
* @param restaurantId 가게 ID
* @param pageable 페이징 정보
* @return 가게의 미션 목록 (페이징)
*/
@Query(value = "SELECT m FROM Mission m " +
"JOIN FETCH m.restaurant r " +
"WHERE r.id = :restaurantId " +
"ORDER BY m.deadline ASC",
countQuery = "SELECT COUNT(m) FROM Mission m " +
"WHERE m.restaurant.id = :restaurantId")
Page<Mission> findByRestaurantId(
@Param("restaurantId") Long restaurantId,
Pageable pageable
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,42 @@ public HomeDto getHomeScreen(Long memberId, Pageable pageable) {
.availableMissions(availableMissions)
.build();
}

/**
* 특정 가게의 미션 목록 조회 (페이징)
* @param restaurantId 가게 ID
* @param pageable 페이징 정보 (페이지 번호, 크기, 정렬)
* @return 가게의 미션 목록 (페이징 정보 포함)
*/
public MissionResponseDto.RestaurantMissionListDto getMissionsByRestaurant(Long restaurantId, Pageable pageable) {
// 1. 가게의 미션 목록 조회 (페이징)
Page<Mission> missionPage = missionRepository.findByRestaurantId(restaurantId, pageable);

// 2. Entity -> DTO 변환
return MissionResponseDto.RestaurantMissionListDto.from(missionPage);
}

/**
* 진행 중인 미션을 완료로 변경
* @param memberId JWT 토큰에서 추출한 회원 ID
* @param missionId 완료할 미션 ID
* @return 완료된 미션 정보
*/
@Transactional
public MissionResponseDto.CompleteMissionDto completeMission(Long memberId, Long missionId) {
// 1. MemberMission 조회
MemberMission memberMission = memberMissionRepository.findByMemberIdAndMissionId(memberId, missionId)
.orElseThrow(() -> new CustomException(ErrorCode.MEMBER_MISSION_NOT_FOUND));

// 2. 이미 완료된 미션인지 확인
if (memberMission.getIsComplete()) {
throw new CustomException(ErrorCode.MISSION_ALREADY_COMPLETED);
}

// 3. 미션 완료 처리 (dirty checking으로 자동 업데이트)
memberMission.complete();

// 4. Entity -> DTO 변환하여 반환
return MissionResponseDto.CompleteMissionDto.from(memberMission);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

Expand Down Expand Up @@ -66,10 +65,10 @@ public class ReviewController {
)
})
@PostMapping
public ResponseEntity<ApiResponse<ReviewResponseDto.ReviewCreateDto>> createReview(
public ApiResponse<ReviewResponseDto.ReviewCreateDto> createReview(
@Valid @RequestBody ReviewRequestDto.CreateReviewDto request) {
ReviewResponseDto.ReviewCreateDto response = reviewService.createReview(request);
return ResponseEntity.ok(ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response));
return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response);
}

/**
Expand Down Expand Up @@ -98,7 +97,7 @@ public ResponseEntity<ApiResponse<ReviewResponseDto.ReviewCreateDto>> createRevi
)
})
@GetMapping("/my")
public ResponseEntity<ApiResponse<ReviewResponseDto.MyReviewListDto>> getMyReviews(
public ApiResponse<ReviewResponseDto.MyReviewListDto> getMyReviews(
@Parameter(hidden = true) @LoginMemberId Long memberId,
@Parameter(description = "가게 이름 (선택)", example = "맛있는 식당")
@RequestParam(required = false) String restaurantName,
Expand Down Expand Up @@ -130,6 +129,6 @@ public ResponseEntity<ApiResponse<ReviewResponseDto.MyReviewListDto>> getMyRevie
// 서비스 호출
ReviewResponseDto.MyReviewListDto response = reviewService.getMyReviews(memberId, filter, pageable);

return ResponseEntity.ok(ApiResponse.onSuccess(GeneralSuccessCode.OK, response));
return ApiResponse.onSuccess(GeneralSuccessCode.OK, response);
}
}
Loading