Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
e1d43dc
[feat]: 동아리 소속 목록 조회 api 구현
jiyun921 Feb 15, 2026
4c42e72
[test]: 동아리 소속 목록 조회 테스트 구현
jiyun921 Feb 15, 2026
7090b05
[fix]: 동아리 소속 목록 조회 응답 구조를 명세에 맞게 수정
jiyun921 Feb 16, 2026
d4d83cb
[feat]: 동아리 목록 조회 api 구현
jiyun921 Feb 18, 2026
a2be583
[fix]: cursor 타입 수정
jiyun921 Feb 19, 2026
9c44e7d
[test]: 동아리 목록 조회 테스트 구현
jiyun921 Feb 19, 2026
d818cac
[feat]: 동아리 상세 조회 api 구현
jiyun921 Feb 20, 2026
840644f
[feat]: 동아리 목록 조회 API에 로그인 사용자 기반 구독 정보 조회 기능 추가
jiyun921 Feb 20, 2026
26f1195
[test]: 동아리 상세 조회 테스트 구현
jiyun921 Feb 20, 2026
2b65645
[fix]: 동아리 목록/상세 조회 api - JWT 기반 email 인증 방식으로 수정
jiyun921 Feb 22, 2026
8cec711
[refactor]: club 상세 조회 api - 위치 정보가 없을 경우 location을 null로 반환하도록 수정
jiyun921 Feb 22, 2026
c67ff24
[fix]: name/recruitEndDate 정렬 시 복합 커서 적용
jiyun921 Feb 22, 2026
e4e7276
[refactor]: ClubDetail 조회 시 불필요한 userToken 제거
jiyun921 Feb 22, 2026
9d613f4
[refactor]: club 목록 조회 시 구독 정보 관련 N+1 쿼리 제거
jiyun921 Feb 22, 2026
e0a0241
[fix]: recruitEndDate 정렬을 위한 복합 커서(group|date|id) 구조 도입
jiyun921 Feb 23, 2026
2c6280e
[refactor]: Persistence에서 findSubscribedClubIds 가공 로직 Service로 책임 이동
jiyun921 Feb 25, 2026
3bbe835
[refactor] ClubDetailCommand 도입 및 email을 Command에 포함하도록 수정
jiyun921 Feb 25, 2026
018f9b9
[refactor] 미로그인 시 loginUserId 변수 생성 제거
jiyun921 Feb 25, 2026
0be4f6b
[refactor] hasLocation() 로직을 Service에서 ClubDetailDto로 이동
jiyun921 Feb 25, 2026
18dbfc3
[refactor] ClubDetailResponse Location 매핑 구조 정리 및 from() 추가
jiyun921 Feb 25, 2026
64f8efe
[refactor] 메서드명 구체화 및 메서드 순서 정리
jiyun921 Feb 25, 2026
ab20703
[comment] 머지 전 주석 저장
jiyun921 Feb 27, 2026
cd4eb4b
[merge] develop 브랜치 머지
jiyun921 Feb 27, 2026
34a716b
[refactor] 동아리 목록 조회 커서 페이징 제거
jiyun921 Feb 28, 2026
0403b77
[refactor] ClubDivisionListResponse에서 Result 리스트 직접 사용하도록 수정
jiyun921 Feb 28, 2026
59782a7
[refactor] totalCount 필드 관련 메서드 제거
jiyun921 Feb 28, 2026
31bc32e
[refactor] 구독 조회 로직을 RootUser 기준으로 통일
jiyun921 Feb 28, 2026
2df4cc3
[refactor] ClubQueryService 구독 로직 메서드 추출 및 DTO from 패턴 적용
jiyun921 Feb 28, 2026
6a7d5f8
[refactor] 모집 상태 계산 로직을 Domain으로 이동하고 Repository 책임 분리
jiyun921 Feb 28, 2026
28e7dd7
[refactor] Club 조회 시 Enum 변환 책임을 Service로 이동
jiyun921 Feb 28, 2026
0d1c6c2
[refactor] 구독자 수 Long 타입 통일 및 구독 관련 조회 로직 ClubSubscriptionPort로 이동
jiyun921 Feb 28, 2026
b550602
[test] ClubPersistenceAdapter 테스트 추가
jiyun921 Feb 28, 2026
ba06ea0
Merge branch 'develop' into feat/#336-club-read
jiyun921 Feb 28, 2026
8978c10
[fix] 존재하지 않는 동아리 구독 테스트 기댓값 NOT_FOUND로 수정
jiyun921 Mar 1, 2026
9cad760
[refactor] DTO from 제거 및 Service로 변환 책임 이동
jiyun921 Mar 1, 2026
4cfc7f0
[refactor] findSubscribedClubIds를 id 직접 조회 방식으로 수정
jiyun921 Mar 1, 2026
3289297
[chore] vectorstore.json 깃 추적 제거
jiyun921 Mar 2, 2026
ffced92
[refactor] countSubscribersByClubIds 테스트 map 검증을 containsEntry 방식으로 개선
jiyun921 Mar 2, 2026
cab52e4
[refactor] countSubscribersByClubIds_empty_input 테스트 변수명 수정
jiyun921 Mar 2, 2026
b2636b9
[refactor] ClubQueryServiceTest 불필요한 Mockito eq() 제거
jiyun921 Mar 2, 2026
8615b63
[test] Club 인수 테스트 구현
jiyun921 Mar 2, 2026
d9e0b9f
[refactor] ClubAcceptanceTest public 제거
jiyun921 Mar 2, 2026
aafe35b
[refactor] Club 인수 테스트 primitive 타입 null 비교 로직 수정
jiyun921 Mar 2, 2026
5e61ccd
[refactor] Club 인수 테스트 primitive 타입 null 비교 로직 수정
jiyun921 Mar 2, 2026
889e3c5
[refactor] 불필요한 개행 제거
jiyun921 Mar 3, 2026
255a70f
[refactor] ClubAcceptanceTest 문자열 직접 비교로 수정
jiyun921 Mar 3, 2026
780dedc
[refactor] ClubListCommand division을 생성 시점에 List로 변환하도록 수정
jiyun921 Mar 3, 2026
f31d6f4
[refactor] Rootuser 불필요한 Optional 형식 수정
jiyun921 Mar 3, 2026
c5f562e
[refactor] ClubDetailDto -> ClubDetailReadModel로 이름 수정
jiyun921 Mar 3, 2026
29b9708
[refactor] 구독자 수 조회 로직Object[] -> Projection으로 수정
jiyun921 Mar 3, 2026
35ce612
[fix] 동아리 조회 시 이미지 S3 Presigned URL 적용
jiyun921 Mar 3, 2026
a3256b1
[refactor] 동아리 목록 조회 카테고리 필터 검증 추가
jiyun921 Mar 3, 2026
161192e
[refactor] 빈 clubIds 입력 시 조기 반환 처리 추가
jiyun921 Mar 3, 2026
78d5250
[refactor] SQL 개행 원복
jiyun921 Mar 3, 2026
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
@@ -0,0 +1,119 @@
package com.kustacks.kuring.club.adapter.in.web;

import com.kustacks.kuring.auth.authentication.AuthorizationExtractor;
import com.kustacks.kuring.auth.authentication.AuthorizationType;
import com.kustacks.kuring.auth.token.JwtTokenProvider;
import com.kustacks.kuring.club.adapter.in.web.dto.ClubDetailResponse;
import com.kustacks.kuring.club.adapter.in.web.dto.ClubDivisionListResponse;
import com.kustacks.kuring.club.adapter.in.web.dto.ClubDivisionResponse;
import com.kustacks.kuring.club.adapter.in.web.dto.ClubListResponse;
import com.kustacks.kuring.club.application.port.in.ClubQueryUseCase;
import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult;
import com.kustacks.kuring.club.application.port.in.dto.ClubListCommand;
import com.kustacks.kuring.club.application.port.in.dto.ClubListResult;
import com.kustacks.kuring.common.annotation.RestWebAdapter;
import com.kustacks.kuring.common.data.Cursor;
import com.kustacks.kuring.common.dto.BaseResponse;
import com.kustacks.kuring.common.exception.InvalidStateException;
import com.kustacks.kuring.common.exception.code.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;
import java.util.Optional;

import static com.kustacks.kuring.auth.authentication.AuthorizationExtractor.extractAuthorizationValue;
import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_DETAIL_SEARCH_SUCCESS;
import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_DIVISION_SEARCH_SUCCESS;
import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CLUB_LIST_SEARCH_SUCCESS;

@Tag(name = "Club-Query", description = "동아리 정보 조회")
@Validated
@RequiredArgsConstructor
@RestWebAdapter(path = "/api/v2/clubs")
public class ClubQueryApiV2 {

private static final String FCM_TOKEN_HEADER_KEY = "User-Token";
private static final String JWT_TOKEN_HEADER_KEY = "JWT";

private final JwtTokenProvider jwtTokenProvider;
private final ClubQueryUseCase clubQueryUseCase;

@Operation(summary = "동아리 소속 목록 조회", description = "서버가 지원하는 동아리 소속 목록을 조회합니다")
@GetMapping("/divisions")
public ResponseEntity<BaseResponse<ClubDivisionListResponse>> getSupportedClubDivisions() {

List<ClubDivisionResponse> divisions = clubQueryUseCase.getClubDivisions()
.stream()
.map(ClubDivisionResponse::from)
.toList();

ClubDivisionListResponse response = new ClubDivisionListResponse(divisions);

return ResponseEntity.ok().body(new BaseResponse<>(CLUB_DIVISION_SEARCH_SUCCESS, response));
}

@Operation(summary = "동아리 목록 조회", description = "필터 조건에 맞는 동아리 목록을 커서 페이징으로 조회합니다")
@SecurityRequirement(name = JWT_TOKEN_HEADER_KEY)
@GetMapping
public ResponseEntity<BaseResponse<ClubListResponse>> getClubs(
@RequestParam(required = false) String category,
@RequestParam(required = false) String division,
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Min(1) @Max(30) int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestHeader(value = AuthorizationExtractor.AUTHORIZATION, required = false) String bearerToken
) {
String email = resolveLoginEmail(bearerToken);

ClubListCommand command = new ClubListCommand(category, division, Cursor.from(cursor), size, sortBy);

ClubListResult result = clubQueryUseCase.getClubs(command, email);
Comment thread
jiyun921 marked this conversation as resolved.
Outdated

ClubListResponse response = ClubListResponse.from(result);

return ResponseEntity.ok().body(new BaseResponse<>(CLUB_LIST_SEARCH_SUCCESS, response));
}

@Operation(summary = "동아리 상세 조회", description = "특정 동아리의 상세 정보를 조회합니다.")
@SecurityRequirement(name = FCM_TOKEN_HEADER_KEY)
@SecurityRequirement(name = JWT_TOKEN_HEADER_KEY)
@GetMapping("/{id}")
public ResponseEntity<BaseResponse<ClubDetailResponse>> getClubDetail(
@PathVariable Long id,
@RequestHeader(value = FCM_TOKEN_HEADER_KEY, required = false) String userToken,
@RequestHeader(value = AuthorizationExtractor.AUTHORIZATION, required = false) String bearerToken
) {
String email = resolveLoginEmail(bearerToken);

ClubDetailResult result = clubQueryUseCase.getClubDetail(id, email);
Comment thread
jiyun921 marked this conversation as resolved.
Outdated

ClubDetailResponse response = ClubDetailResponse.from(result);

return ResponseEntity.ok().body(new BaseResponse<>(CLUB_DETAIL_SEARCH_SUCCESS, response));
}

private String resolveLoginEmail(String bearerToken) {
return Optional.ofNullable(bearerToken)
.map(token -> extractAuthorizationValue(token, AuthorizationType.BEARER))
.map(this::validateJwtAndGetEmail)
.orElse(null);
}

private String validateJwtAndGetEmail(String jwtToken) {
if (!jwtTokenProvider.validateToken(jwtToken)) {
throw new InvalidStateException(ErrorCode.JWT_INVALID_TOKEN);
}
return jwtTokenProvider.getPrincipal(jwtToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import com.kustacks.kuring.club.application.port.in.dto.ClubDetailResult;

import java.time.LocalDateTime;

public record ClubDetailResponse(
Long id,
String name,
String summary,
String category,
String division,
int subscriberCount,
boolean isSubscribed,
String instagramUrl,
String youtubeUrl,
String etcUrl,
String description,
String qualifications,
String recruitmentStatus,
LocalDateTime recruitStartAt,
LocalDateTime recruitEndAt,
String applyUrl,
String posterImageUrl,
Location location
) {

public static ClubDetailResponse from(ClubDetailResult result) {

Location location = result.location() == null ?
null
: new Location(
result.location().building(),
result.location().room(),
result.location().lon(),
result.location().lat()
);

Comment thread
jiyun921 marked this conversation as resolved.
Outdated
return new ClubDetailResponse(
result.id(),
result.name(),
result.summary(),
result.category().getName(),
result.division().getName(),
result.subscriberCount(),
result.isSubscribed(),
result.instagramUrl(),
result.youtubeUrl(),
result.etcUrl(),
result.description(),
result.qualifications(),
result.recruitmentStatus().getValue(),
result.recruitStartAt(),
result.recruitEndAt(),
result.applyUrl(),
result.posterImageUrl(),
location
);
Comment thread
jiyun921 marked this conversation as resolved.
}

public record Location(
String building,
String room,
Double lon,
Double lat
) {
}
}
Comment thread
rlagkswn00 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import java.util.List;

public record ClubDivisionListResponse(
List<ClubDivisionResponse> divisions
Comment thread
jiyun921 marked this conversation as resolved.
Outdated
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import com.kustacks.kuring.club.application.port.in.dto.ClubDivisionResult;

public record ClubDivisionResponse(
String code,
String koreanName
) {
public static ClubDivisionResponse from(ClubDivisionResult result) {
return new ClubDivisionResponse(
result.code(),
result.koreanName()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import com.kustacks.kuring.club.application.port.in.dto.ClubItemResult;

import java.time.LocalDateTime;

public record ClubItemResponse(
Long id,
String name,
String summary,
String iconImageUrl,
String category,
String division,
boolean isSubscribed,
int subscriberCount,
LocalDateTime recruitStartDate,
LocalDateTime recruitEndDate
) {
public static ClubItemResponse from(ClubItemResult result) {
return new ClubItemResponse(
result.id(),
result.name(),
result.summary(),
result.iconImageUrl(),
result.category(),
result.division(),
result.isSubscribed(),
result.subscriberCount(),
result.recruitStartDate(),
result.recruitEndDate()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.kustacks.kuring.club.adapter.in.web.dto;

import com.kustacks.kuring.club.application.port.in.dto.ClubListResult;

import java.util.List;

public record ClubListResponse(
List<ClubItemResponse> clubs,
String cursor,
boolean hasNext,
int totalCount
) {

public static ClubListResponse from(ClubListResult result) {
List<ClubItemResponse> clubs = result.clubs().stream()
.map(ClubItemResponse::from)
.toList();

return new ClubListResponse(
clubs,
result.cursor(),
result.hasNext(),
result.totalCount()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.kustacks.kuring.club.adapter.out.persistence;

import com.kustacks.kuring.club.application.port.out.ClubQueryPort;
import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto;
import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel;
import com.kustacks.kuring.club.domain.ClubSubscribe;
import com.kustacks.kuring.common.annotation.PersistenceAdapter;
import com.kustacks.kuring.common.data.Cursor;
import lombok.RequiredArgsConstructor;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

@PersistenceAdapter
@RequiredArgsConstructor
public class ClubPersistenceAdapter implements ClubQueryPort {

private final ClubRepository clubRepository;
private final ClubSubscribeRepository clubSubscribeRepository;

@Override
public List<ClubReadModel> searchClubs(
String category,
List<String> divisions,
Cursor cursor,
int size,
String sortBy,
LocalDateTime now
) {
return clubRepository.searchClubs(
category,
divisions,
cursor == null ? null : cursor.getStringCursor(),
size,
sortBy,
now
);
}

@Override
public int countClubs(String category, List<String> divisions) {
return clubRepository.countClubs(category, divisions);
}

@Override
public Optional<ClubDetailDto> findClubDetailById(Long id) {
return clubRepository.findClubDetailById(id);
}

@Override
public int countSubscribers(Long clubId) {
return clubSubscribeRepository.countByClubId(clubId);
}
Comment thread
jiyun921 marked this conversation as resolved.
Comment thread
jiyun921 marked this conversation as resolved.

@Override
public boolean existsSubscription(Long clubId, Long loginUserId) {
return clubSubscribeRepository.existsByClubIdAndUser_LoginUserId(clubId, loginUserId);
}

@Override
public Map<Long, Integer> countSubscribersByClubIds(List<Long> clubIds) {

if (clubIds == null || clubIds.isEmpty()) {
return Map.of();
}

List<ClubSubscribe> subscriptions = clubSubscribeRepository.findByClubIdIn(clubIds);

return subscriptions.stream()
.collect(Collectors.groupingBy(
sub -> sub.getClub().getId(),
Collectors.collectingAndThen(
Collectors.counting(),
Long::intValue
Comment thread
jiyun921 marked this conversation as resolved.
Outdated
)
));
}
Comment thread
jiyun921 marked this conversation as resolved.

@Override
public Map<Long, Boolean> findSubscribedClubIds(
List<Long> clubIds,
Long loginUserId
) {

if (clubIds == null || clubIds.isEmpty() || loginUserId == null) {
return Map.of();
}

List<ClubSubscribe> subscriptions = clubSubscribeRepository.findByClubIdInAndUser_LoginUserId(clubIds, loginUserId);

return subscriptions.stream()
.collect(Collectors.toMap(
sub -> sub.getClub().getId(),
sub -> true
));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
jiyun921 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.kustacks.kuring.club.adapter.out.persistence;

import com.kustacks.kuring.club.application.port.out.dto.ClubDetailDto;
import com.kustacks.kuring.club.application.port.out.dto.ClubReadModel;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

public interface ClubQueryRepository {

List<ClubReadModel> searchClubs(String category, List<String> divisions, String cursor, int size, String sortBy, LocalDateTime now);

int countClubs(String category, List<String> divisions);
Comment thread
jiyun921 marked this conversation as resolved.
Outdated

Optional<ClubDetailDto> findClubDetailById(Long id);
}
Loading
Loading