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 @@ -3,12 +3,13 @@
import com.hrr.backend.domain.user.dto.*;
import com.hrr.backend.domain.user.entity.User;
import com.hrr.backend.domain.user.service.UserService;
import com.hrr.backend.domain.verification.dto.VerificationResponseDto;
import com.hrr.backend.domain.verification.service.VerificationService;
import com.hrr.backend.global.config.CustomUserDetails;
import com.hrr.backend.global.response.ApiResponse;
import com.hrr.backend.global.response.SliceResponseDto;
import com.hrr.backend.global.response.SuccessCode;

import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -34,6 +35,7 @@
public class UserController {

private final UserService userService;
private final VerificationService verificationService;


// 닉네임 유효성 검사 API
Expand Down Expand Up @@ -140,37 +142,96 @@ public ApiResponse<UserResponseDto.MyInfoDto> getMyInfo(
return ApiResponse.onSuccess(SuccessCode.OK, myInfo);
}

@GetMapping("/search")
@Operation(summary = "챌린저 검색", description = "검색 키워드가 닉네임에 포함된 사용자를 조회합니다.")
public ApiResponse<SliceResponseDto<UserResponseDto.ProfileDto>> searchChallengers(
@RequestParam(name = "keyword")
@NotBlank(message = "검색어는 필수입니다.") String keyword,
@GetMapping("/search")
@Operation(summary = "챌린저 검색", description = "검색 키워드가 닉네임에 포함된 사용자를 조회합니다.")
public ApiResponse<SliceResponseDto<UserResponseDto.ProfileDto>> searchChallengers(
@RequestParam(name = "keyword")
@NotBlank(message = "검색어는 필수입니다.") String keyword,

// 페이징
@Min(1)
@RequestParam(name = "page", defaultValue = "1") int page, // 페이지 번호 (0부터 시작)
@Min(1)
@RequestParam(name = "size", defaultValue = "10") int size, // 페이지 크기)
// 페이징
@Min(1)
@RequestParam(name = "page", defaultValue = "1") int page, // 페이지 번호 (1부터 시작)
@Min(1)
@RequestParam(name = "size", defaultValue = "10") int size, // 페이지 크기

@Parameter(hidden = true)
@AuthenticationPrincipal CustomUserDetails customUserDetails
)
{
SliceResponseDto<UserResponseDto.ProfileDto> response = userService.searchChallengers(customUserDetails.getUser(), keyword, page-1, size);
@Parameter(hidden = true)
@AuthenticationPrincipal CustomUserDetails customUserDetails
)
{
SliceResponseDto<UserResponseDto.ProfileDto> response = userService.searchChallengers(customUserDetails.getUser(), keyword, page-1, size);

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

@PatchMapping("/me")
@GetMapping("/me/verifications/history")
@Operation(
summary = "내 기본 정보 수정",
description = "사용자가 자신의 기본 정보(닉네임, 프로필 이미지, 프로필 공개여부)를 수정합니다"
summary = "내 챌린지 인증 기록 조회",
description = "현재 로그인한 사용자가 참여한 모든 챌린지의 인증 기록을 최신순으로 조회합니다."
)
public ApiResponse<UpdateUserInfoResponseDto> updateUserInfo(
@AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @RequestBody UpdateUserInfoRequestDto requestDto
@ApiResponses({
Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분은 일종의 하드코딩이라 변경사항이 누락될 위험이 있을 뿐더러 실제 응답과 다를 경우 혼란을 줄 수 있습니다. 제네릭 타입으로 인해 스웨거 등에 응답이 정확하지 않을 경우에만 넣어주시고 그 외에는 API 명세에 response 예시로 넣는 방향으로 해주세요

@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "성공",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(
value = """
{
"resultType": "SUCCESS",
"error": null,
"success": {
"content": [
{
"verificationId": 1,
"challengeId": 101,
"challengeTitle": "미라클 모닝",
"type": "TEXT",
"title": "해피뉴이어! 올해 마지막 인증 올립니다",
"content": "여기엔 상세내용이 들어가유~",
"photoUrl": null,
"textUrl": "https://blog.example.com/post/123",
"verifiedAt": "2025-09-18T08:00:00Z"
},
{
"verificationId": 2,
"challengeId": 102,
"challengeTitle": "매일 책 10페이지 읽기",
"type": "CAMERA",
"title": "오늘의 독서 인증",
"content": null,
"photoUrl": "https://example.com/verification_image_2.jpg",
"textUrl": null,
"verifiedAt": "2025-09-13T22:30:00Z"
}
],
"currentPage": 0,
"size": 10,
"first": true,
"last": false,
"hasNext": true
}
}
"""
)
)
)
})
public ApiResponse<SliceResponseDto<VerificationResponseDto.HistoryDto>> getVerificationHistory(
@Parameter(hidden = true)
@AuthenticationPrincipal CustomUserDetails customUserDetails,

@RequestParam(name = "page", defaultValue = "1")
@Min(1)
@Parameter(description = "페이지 번호 (1부터 시작)", example = "1") int page,

@RequestParam(name = "size", defaultValue = "10")
@Min(1) @Max(100)
@Parameter(description = "페이지당 데이터 개수", example = "10") int size
) {
UpdateUserInfoResponseDto response = userService.updateUserInfo(userDetails.getUser().getId(), requestDto);
return ApiResponse.onSuccess(SuccessCode.USER_UPDATE_OK, response);
Long userId = customUserDetails.getUser().getId();
SliceResponseDto<VerificationResponseDto.HistoryDto> response =
verificationService.getVerificationHistory(userId, page-1, size);

return ApiResponse.onSuccess(SuccessCode.OK, response);
}
Comment on lines +166 to 236
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

API 엔드포인트 구현은 잘 되었으나, Swagger 문서의 일관성 이슈가 있습니다.

엔드포인트 구현 자체는 훌륭합니다:

  • RESTful 경로 설계 ✓
  • 인증 처리 ✓
  • 페이지네이션 파라미터 검증 ✓
  • 1-based → 0-based 변환 로직 ✓

하지만 Swagger 문서에 세 가지 개선이 필요합니다:

  1. currentPage 값 불일치 (Line 207)

    • 예제: "currentPage": 0
    • 실제: SliceResponseDtoslice.getNumber() + 1로 1-based 값을 반환
    • 해결: 예제를 "currentPage": 1로 수정
  2. verifiedAt 포맷 불일치 (Line 193)

    • 예제: "2025-09-18T08:00:00Z" (ISO 8601 with timezone)
    • 실제: @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")"2025-09-18 08:00:00" 형식
    • 해결: 예제 포맷을 "verifiedAt": "2025-09-18 08:00:00"로 변경
  3. 하드코딩된 예제의 유지보수 리스크 (Lines 171-218)

    • 이전 리뷰어 yc3697님이 지적하셨듯이, 하드코딩된 예제는 실제 응답과 달라질 위험이 있습니다.
    • 가능하면 제네릭 타입으로 자동 생성하거나, API 명세 문서에 예시를 별도 관리하는 것을 권장합니다.
    • 참고: Past review comment by yc3697
🔎 Swagger 예제 수정안
                                            "verifiedAt": "2025-09-18T08:00:00Z"
+                                            "verifiedAt": "2025-09-18 08:00:00"
                                          },
                                          {
                                            "verificationId": 2,
                                            "challengeId": 102,
                                            "challengeTitle": "매일 책 10페이지 읽기",
                                            "type": "CAMERA",
                                            "title": "오늘의 독서 인증",
                                            "content": null,
                                            "photoUrl": "https://example.com/verification_image_2.jpg",
                                            "textUrl": null,
-                                            "verifiedAt": "2025-09-13T22:30:00Z"
+                                            "verifiedAt": "2025-09-13 22:30:00"
                                          }
                                        ],
-                                        "currentPage": 0,
+                                        "currentPage": 1,
                                        "size": 10,
                                        "first": true,

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,21 @@ public VerificationDetailResponseDto toDetailDto(
}


}
/**
* Verification 엔티티를 HistoryDto로 변환
*/
public VerificationResponseDto.HistoryDto toHistoryDto(Verification verification) {
return VerificationResponseDto.HistoryDto.builder()
.verificationId(verification.getId())
.challengeId(verification.getRoundRecord().getUserChallenge().getChallenge().getId())
.challengeTitle(verification.getRoundRecord().getUserChallenge().getChallenge().getTitle())
.type(verification.getType().name())
.title(verification.getTitle())
.content(verification.getContent())
.photoUrl(verification.getPhotoUrl() != null ?
s3UrlUtil.toFullUrl(verification.getPhotoUrl()) : null)
.textUrl(verification.getTextUrl())
.verifiedAt(verification.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,40 @@ public static class CreateResponseDto {
/** 현재 라운드 인증 횟수 */
private Integer verificationCount;
}

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "사용자 전체 인증 기록 DTO")
public static class HistoryDto {

@Schema(description = "인증 ID", example = "1")
private Long verificationId;

@Schema(description = "챌린지 ID", example = "101")
private Long challengeId;

@Schema(description = "챌린지 제목", example = "미라클 모닝")
private String challengeTitle;

@Schema(description = "인증 타입", example = "TEXT", allowableValues = {"TEXT", "CAMERA"})
private String type;

@Schema(description = "인증 제목", example = "해피뉴이어! 올해 마지막 인증 올립니다")
private String title;

@Schema(description = "인증 내용", example = "여기엔 상세내용이 들어가유~")
private String content;

@Schema(description = "사진 URL (사진 인증)", example = "https://example.com/verification_image_1.jpg")
Copy link
Contributor

Choose a reason for hiding this comment

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

사진이 첨부된 인증글은 UI 상에서 클립 아이콘을 띄우는데 이를 적용하기 위해 프론트 측에 "사진이 첨부되지 않으면 null 이 반환된다" 등의 내용을 공유해주세요

private String photoUrl;

@Schema(description = "글 URL (글 인증)", example = "https://blog.example.com/post/123")
private String textUrl;

@Schema(description = "인증 일시", example = "2025-09-18T08:00:00")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime verifiedAt;
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.hrr.backend.domain.verification.repository;

import com.hrr.backend.domain.user.entity.User;
import com.hrr.backend.domain.verification.entity.Verification;
import com.hrr.backend.domain.verification.entity.enums.VerificationPostType;
import com.hrr.backend.domain.verification.entity.enums.VerificationStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
Expand Down Expand Up @@ -169,4 +171,20 @@ Page<Verification> findMyVerifications(
Pageable pageable
);

}
/**
* 사용자의 전체 챌린지 인증 기록 조회 (페이징)
*/
@Query("SELECT v FROM Verification v " +
"JOIN FETCH v.roundRecord rr " +
"JOIN FETCH rr.userChallenge uc " +
"JOIN FETCH uc.challenge c " +
"WHERE uc.user = :user " +
"AND v.status = :status " +
"ORDER BY v.createdAt DESC")
Slice<Verification> findVerificationHistoryByUser(
@Param("user") User user,
@Param("status") VerificationStatus status,
Pageable pageable
);

}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.hrr.backend.domain.verification.service;

import com.hrr.backend.domain.verification.dto.VerificationDetailResponseDto;
import com.hrr.backend.domain.user.entity.User;
import com.hrr.backend.domain.verification.dto.VerificationRequestDto;
import com.hrr.backend.domain.verification.dto.VerificationResponseDto;
import com.hrr.backend.domain.verification.dto.VerificationDetailResponseDto;
import com.hrr.backend.domain.verification.dto.VerificationUpdateRequestDto;
import com.hrr.backend.global.response.SliceResponseDto;

Expand Down Expand Up @@ -56,13 +56,21 @@ VerificationResponseDto.CreateResponseDto createPhotoVerification(
String title,
Boolean isQuestion
);

VerificationDetailResponseDto getVerificationDetail(Long verificationId, Long currentUserId, int page, int size);

void adoptComment(Long verificationId, Long commentId, Long currentUserId);

VerificationDetailResponseDto updateVerification(Long verificationId, Long currentUserId, VerificationUpdateRequestDto requestDto);

void deleteVerification(Long verificationId, Long currentUserId);


/**
* 사용자 전체 챌린지 인증 기록 조회
*/
SliceResponseDto<VerificationResponseDto.HistoryDto> getVerificationHistory(
Long userId,
int page,
int size
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public VerificationResponseDto.StatDto getVerificationStat(Long challengeId) {

Integer totalParticipantCount = challenge.getCurrentParticipants();
Round currentRound = challenge.getCurrentRound();

// 라운드 미시작 시 0명 반환
if (currentRound == null) {
return verificationConverter.toStatDto(0, totalParticipantCount, null);
Expand Down Expand Up @@ -278,16 +278,21 @@ private LocalDateTime determineTargetDateTime(Challenge challenge, Long roundId)
}

@Override
@Transactional(readOnly = true)
public VerificationDetailResponseDto getVerificationDetail(Long verificationId, Long currentUserId, int page, int size) {

public VerificationDetailResponseDto getVerificationDetail(
Long verificationId,
Long currentUserId,
int page,
int size
) {
// 인증글 조회
Verification verification = verificationRepository.findById(verificationId)
.orElseThrow(() -> new GlobalException(ErrorCode.VERIFICATION_NOT_FOUND));

// 차단된 게시글 접근 시 예외 발생
if (verification.getStatus() == VerificationStatus.BLOCKED) {
throw new GlobalException(ErrorCode.ACCESS_DENIED_REPORTED_POST);
}

// 차단된 게시글 접근 시 예외 발생
if (verification.getStatus() == VerificationStatus.BLOCKED) {
throw new GlobalException(ErrorCode.ACCESS_DENIED_REPORTED_POST);
}

RoundRecord roundRecord = verification.getRoundRecord();
UserChallenge userChallenge = roundRecord.getUserChallenge();
Expand Down Expand Up @@ -421,7 +426,6 @@ public VerificationDetailResponseDto updateVerification(Long verificationId, Lon
);
}


@Override
@Transactional
public void deleteVerification(Long verificationId, Long currentUserId) {
Expand All @@ -445,5 +449,34 @@ public void deleteVerification(Long verificationId, Long currentUserId) {
verificationRepository.delete(verification);
}

/**
* 사용자 전체 챌린지 인증 기록 조회
*/
@Override
public SliceResponseDto<VerificationResponseDto.HistoryDto> getVerificationHistory(
Long userId,
int page,
int size
) {
// 사용자 조회
User user = userRepository.findById(userId)
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND));

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

// Repository에서 인증 엔티티 조회
Slice<Verification> verificationSlice =
verificationRepository.findVerificationHistoryByUser(
user,
VerificationStatus.COMPLETED,
pageable);

// 엔티티를 DTO로 변환 (빌더 패턴 사용)
Slice<VerificationResponseDto.HistoryDto> dtoSlice =
verificationSlice.map(verificationConverter::toHistoryDto);

// SliceResponseDto로 변환하여 반환
return new SliceResponseDto<>(dtoSlice);
}
}