Skip to content

Commit d4d2790

Browse files
authored
feat: 봉사 모집글 조회 기능 (#72)
* refactor: 에러 메세지 변경 * refactor(recruit-board): 조회 기능 메서드 변경 * test(recruit-board): 조회 기능 메서드 변경에 따른 테스트 * reactor(recruit-board): RecruitBoard 레포지토리 기능 분리 - 테스트용 메서드는 RecruitBoardJpaRepository 따로 주입 받아서 사용 - ex) saveAndFlush, deleteAllInBatch 등 * test(fixture): 테스트용 엔티티 생성을 위한 Fixture 클래스 * feat(recruit-board): 조회를 위한 엔티티 매핑 클래스 * feat(recruit-board): 모집글 조회 응답 Dto 작성 * feat(recruit-board): 동적 쿼리를 위한 조건 Condition Dto * feat(utils): Haversine 유틸 클래스 * feat(recruit-board): 모집글 조회 기능 - 단건 조회 - 페이징 전체 조회 - 페이징 검색 조회 - 페이징 기관 조회 - 페이징 위치 기반 조회 * test(recruit-board): 모집글 조회 기능 테스트 - 단건 조회 - 페이징 전체 조회 - 페이징 검색 조회 - 페이징 기관 조회 - 페이징 위치 기반 조회 * feat(recruit-board): 모집글 조회 컨트롤러 - 단건 조회 - 페이징 전체 조회 - 페이징 검색 조회 - 페이징 기관 조회 - 페이징 위치 기반 조회 * test(recruit-board): 컨트롤러 테스트 작성 * chore: Fixture 클래스 커버리지 제외 * chore: Fixture 클래스 커버리지 제외 * chore: fixture 디렉토리 커버리지 제외 * fix: 기본 생성자 생성 * refactor: isEqualTo(0) -> isZero() 변경 * refactor: duplicated code 제거 * fix: 파라미터가 아닌 고정된 값이 들어간 오류 해결 * refactor: Center Dto 이름 좀 더 명확하게 변경 * refactor: GeoUtils 디렉토리 위치 변경 - global/utils -> location/utils * refactor: PageableDefault size, page 제거 - default 값 사용 * refactor: 메서드 추출 * feat: 기관 아이디로 모집글 조회 기능 및 검증 추가 - 검색 조건 추가 - 존재하지 않는 기관 아이디로 조회시 검증 * test: 기관 아이디로 모집글 조회 기능 및 검증 테스트 - 검색 조건 추가 - 존재하지 않는 기관 아이디로 조회시 검증 * test: TestControllerSupport 으로 변경 * chore: 불필요한 import 제거 * chore: mapper 클래스 디렉토리 변경 - 도메인이 아닌 레포지토리 하위 변경 * test(recruit-board): 테스트 코드 수정 * refactor(location): Location 조회 수정 기능 리팩토링 * test(location): Location 조회 수정 기능 리팩토링 테스트
1 parent 74ea66d commit d4d2790

33 files changed

+2045
-161
lines changed

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def jacocoExcludePatterns = [
115115
'**/auth/**',
116116
'**/domain/*',
117117
'**/domains/*',
118+
'**/fixture/*'
118119
]
119120

120121
def jacocoExcludePatternsForVerify = [
@@ -129,6 +130,7 @@ def jacocoExcludePatternsForVerify = [
129130
'*.auth.*',
130131
'*.domain.*',
131132
'*.domains.*',
133+
'*.fixture.*'
132134
]
133135

134136
jacocoTestReport {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.somemore.center.dto.response;
2+
3+
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
4+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
5+
import com.somemore.center.domain.Center;
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
import java.util.UUID;
8+
import lombok.Builder;
9+
10+
@Builder
11+
@JsonNaming(SnakeCaseStrategy.class)
12+
@Schema(description = "기관 정보 응답 DTO")
13+
public record CenterSimpleInfoResponseDto(
14+
@Schema(description = "기관 아이디", example = "123e4567-e89b-12d3-a456-426614174000")
15+
UUID id,
16+
@Schema(description = "기관 이름", example = "환경 봉사 센터")
17+
String name
18+
) {
19+
20+
public static CenterSimpleInfoResponseDto from(Center center) {
21+
return CenterSimpleInfoResponseDto.builder()
22+
.id(center.getId())
23+
.name(center.getName())
24+
.build();
25+
}
26+
27+
public static CenterSimpleInfoResponseDto of(UUID centerId, String name) {
28+
return CenterSimpleInfoResponseDto.builder()
29+
.id(centerId)
30+
.name(name)
31+
.build();
32+
}
33+
}

src/main/java/com/somemore/global/exception/ExceptionMessage.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ public enum ExceptionMessage {
1515
UNAUTHORIZED_COMMUNITY_COMMENT("해당 댓글에 권한이 없습니다."),
1616
NOT_EXISTS_LOCATION("존재하지 않는 위치 ID 입니다."),
1717
NOT_EXISTS_RECRUIT_BOARD("존재하지 않는 봉사 모집글 ID 입니다."),
18-
UNAUTHORIZED_RECRUIT_BOARD("자신이 작성한 봉사 모집글이 아닙니다."),
18+
UNAUTHORIZED_RECRUIT_BOARD("해당 봉사 모집글에 권한이 없습니다."),
1919
UPLOAD_FAILED("파일 업로드에 실패했습니다."),
2020
INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."),
2121
FILE_SIZE_EXCEEDED("파일 크기가 허용된 한도를 초과했습니다."),
2222
EMPTY_FILE("파일이 존재하지 않습니다."),
23-
INSTANTIATION_NOT_ALLOWED("인스턴스화 할 수 없는 클래스 입니다.")
23+
INSTANTIATION_NOT_ALLOWED("인스턴스화 할 수 없는 클래스 입니다."),
2424
;
2525

2626
private final String message;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.somemore.location.dto.response;
2+
3+
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
4+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
5+
import com.somemore.location.domain.Location;
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
import java.math.BigDecimal;
8+
import lombok.Builder;
9+
10+
@Builder
11+
@JsonNaming(SnakeCaseStrategy.class)
12+
@Schema(description = "위치 조회 응답 DTO")
13+
public record LocationResponseDto(
14+
@Schema(description = "주소", example = "서울특별시 강남구 테헤란로 123")
15+
String address,
16+
@Schema(description = "위도", example = "37.5665")
17+
BigDecimal latitude,
18+
@Schema(description = "경도", example = "126.9780")
19+
BigDecimal longitude
20+
) {
21+
22+
public static LocationResponseDto from(Location location) {
23+
return LocationResponseDto.builder()
24+
.address(location.getAddress())
25+
.latitude(location.getLatitude())
26+
.longitude(location.getLongitude())
27+
.build();
28+
}
29+
30+
public static LocationResponseDto of(String address, BigDecimal latitude,
31+
BigDecimal longitude) {
32+
return LocationResponseDto.builder()
33+
.address(address)
34+
.latitude(latitude)
35+
.longitude(longitude)
36+
.build();
37+
}
38+
}

src/main/java/com/somemore/location/service/command/UpdateLocationService.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import com.somemore.location.dto.request.LocationUpdateRequestDto;
88
import com.somemore.location.repository.LocationRepository;
99
import com.somemore.location.usecase.command.UpdateLocationUseCase;
10-
import com.somemore.location.usecase.query.LocationQueryUseCase;
1110
import lombok.RequiredArgsConstructor;
1211
import org.springframework.stereotype.Service;
1312
import org.springframework.transaction.annotation.Transactional;
@@ -17,14 +16,17 @@
1716
@Service
1817
public class UpdateLocationService implements UpdateLocationUseCase {
1918

20-
private final LocationQueryUseCase locationQueryUseCase;
2119
private final LocationRepository locationRepository;
2220

2321
@Override
2422
public void updateLocation(LocationUpdateRequestDto requestDto, Long locationId) {
25-
Location location = locationQueryUseCase.findById(locationId)
26-
.orElseThrow(() -> new BadRequestException(NOT_EXISTS_LOCATION.getMessage()));
23+
Location location = getLocation(locationId);
2724
location.updateWith(requestDto);
2825
locationRepository.save(location);
2926
}
27+
28+
private Location getLocation(Long locationId) {
29+
return locationRepository.findById(locationId)
30+
.orElseThrow(() -> new BadRequestException(NOT_EXISTS_LOCATION.getMessage()));
31+
}
3032
}

src/main/java/com/somemore/location/service/query/LocationQueryService.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.somemore.location.service.query;
22

3+
import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_LOCATION;
4+
5+
import com.somemore.global.exception.BadRequestException;
36
import com.somemore.location.domain.Location;
47
import com.somemore.location.repository.LocationRepository;
58
import com.somemore.location.usecase.query.LocationQueryUseCase;
6-
import java.util.Optional;
79
import lombok.RequiredArgsConstructor;
810
import org.springframework.stereotype.Service;
911
import org.springframework.transaction.annotation.Transactional;
@@ -16,8 +18,14 @@ public class LocationQueryService implements LocationQueryUseCase {
1618
private final LocationRepository locationRepository;
1719

1820
@Override
19-
public Optional<Location> findById(Long id) {
20-
return locationRepository.findById(id);
21+
public Location getById(Long id) {
22+
return getLocation(id);
23+
}
24+
25+
private Location getLocation(Long id) {
26+
return locationRepository.findById(id).orElseThrow(
27+
() -> new BadRequestException(NOT_EXISTS_LOCATION.getMessage())
28+
);
2129
}
2230

2331
}
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
package com.somemore.location.usecase.query;
22

33
import com.somemore.location.domain.Location;
4-
import java.util.Optional;
54

65
public interface LocationQueryUseCase {
76

8-
Optional<Location> findById(Long id);
7+
Location getById(Long id);
98

109
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.somemore.location.utils;
2+
3+
import static lombok.AccessLevel.PRIVATE;
4+
5+
import lombok.NoArgsConstructor;
6+
7+
@NoArgsConstructor(access = PRIVATE)
8+
public class GeoUtils {
9+
10+
private static final double EARTH_RADIUS = 6371.0;
11+
12+
public static double[] calculateMaxMinCoordinates(double latitude, double longitude,
13+
double radius) {
14+
double latRad = Math.toRadians(latitude);
15+
double latDiff = radius / EARTH_RADIUS;
16+
double maxLatRad = latRad + latDiff;
17+
double minLatRad = latRad - latDiff;
18+
19+
double maxLat = Math.toDegrees(maxLatRad);
20+
double minLat = Math.toDegrees(minLatRad);
21+
22+
double lonDiff = radius / (EARTH_RADIUS * Math.cos(latRad));
23+
double maxLon = longitude + Math.toDegrees(lonDiff);
24+
double minLon = longitude - Math.toDegrees(lonDiff);
25+
26+
return new double[]{minLat, minLon, maxLat, maxLon};
27+
}
28+
29+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.somemore.recruitboard.controller;
2+
3+
import static org.springframework.data.domain.Sort.Direction.DESC;
4+
5+
import com.somemore.global.common.response.ApiResponse;
6+
import com.somemore.recruitboard.domain.RecruitStatus;
7+
import com.somemore.recruitboard.domain.VolunteerType;
8+
import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition;
9+
import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition;
10+
import com.somemore.recruitboard.dto.response.RecruitBoardDetailResponseDto;
11+
import com.somemore.recruitboard.dto.response.RecruitBoardResponseDto;
12+
import com.somemore.recruitboard.dto.response.RecruitBoardWithCenterResponseDto;
13+
import com.somemore.recruitboard.dto.response.RecruitBoardWithLocationResponseDto;
14+
import com.somemore.recruitboard.usecase.query.RecruitBoardQueryUseCase;
15+
import io.swagger.v3.oas.annotations.Operation;
16+
import io.swagger.v3.oas.annotations.tags.Tag;
17+
import java.util.UUID;
18+
import lombok.RequiredArgsConstructor;
19+
import org.springframework.data.domain.Page;
20+
import org.springframework.data.domain.Pageable;
21+
import org.springframework.data.web.PageableDefault;
22+
import org.springframework.web.bind.annotation.GetMapping;
23+
import org.springframework.web.bind.annotation.PathVariable;
24+
import org.springframework.web.bind.annotation.RequestMapping;
25+
import org.springframework.web.bind.annotation.RequestParam;
26+
import org.springframework.web.bind.annotation.RestController;
27+
28+
@Tag(name = "Recruit Board Query API", description = "봉사 활동 모집 조회 관련 API")
29+
@RequiredArgsConstructor
30+
@RequestMapping("/api")
31+
@RestController
32+
public class RecruitBoardQueryController {
33+
34+
private final RecruitBoardQueryUseCase recruitBoardQueryUseCase;
35+
36+
@GetMapping("/recruit-board/{id}")
37+
@Operation(summary = "봉사 모집글 상세 조회", description = "특정 모집글의 상세 정보를 조회합니다.")
38+
public ApiResponse<RecruitBoardWithLocationResponseDto> getById(
39+
@PathVariable Long id
40+
) {
41+
return ApiResponse.ok(
42+
200,
43+
recruitBoardQueryUseCase.getWithLocationById(id),
44+
"봉사 활동 모집 상세 조회 성공"
45+
);
46+
}
47+
48+
@GetMapping("/recruit-boards")
49+
@Operation(summary = "전체 모집글 조회", description = "모든 봉사 모집글 목록을 조회합니다.")
50+
public ApiResponse<Page<RecruitBoardWithCenterResponseDto>> getAll(
51+
@PageableDefault(sort = "created_at", direction = DESC)
52+
Pageable pageable
53+
) {
54+
RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder()
55+
.pageable(pageable)
56+
.build();
57+
58+
return ApiResponse.ok(
59+
200,
60+
recruitBoardQueryUseCase.getAllWithCenter(condition),
61+
"봉사 활동 모집글 리스트 조회 성공"
62+
);
63+
}
64+
65+
@GetMapping("/recruit-boards/search")
66+
@Operation(summary = "모집글 검색 조회", description = "검색 조건을 기반으로 모집글을 조회합니다.")
67+
public ApiResponse<Page<RecruitBoardWithCenterResponseDto>> getAllBySearch(
68+
@PageableDefault(sort = "created_at", direction = DESC) Pageable pageable,
69+
@RequestParam(required = false) String keyword,
70+
@RequestParam(required = false) VolunteerType type,
71+
@RequestParam(required = false) String region,
72+
@RequestParam(required = false) Boolean admitted,
73+
@RequestParam(required = false) RecruitStatus status
74+
) {
75+
RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder()
76+
.keyword(keyword)
77+
.type(type)
78+
.region(region)
79+
.admitted(admitted)
80+
.status(status)
81+
.pageable(pageable)
82+
.build();
83+
84+
return ApiResponse.ok(
85+
200,
86+
recruitBoardQueryUseCase.getAllWithCenter(condition),
87+
"봉사 활동 모집글 검색 조회 성공"
88+
);
89+
}
90+
91+
@GetMapping("/recruit-boards/nearby")
92+
@Operation(summary = "근처 모집글 조회", description = "주변 반경 내의 봉사 모집글을 조회합니다.")
93+
public ApiResponse<Page<RecruitBoardDetailResponseDto>> getNearby(
94+
@RequestParam double latitude,
95+
@RequestParam double longitude,
96+
@RequestParam(required = false, defaultValue = "5") double radius,
97+
@RequestParam(required = false) String keyword,
98+
@PageableDefault(sort = "created_at", direction = DESC) Pageable pageable
99+
) {
100+
RecruitBoardNearByCondition condition = RecruitBoardNearByCondition.builder()
101+
.latitude(latitude)
102+
.longitude(longitude)
103+
.radius(radius)
104+
.keyword(keyword)
105+
.pageable(pageable)
106+
.build();
107+
108+
return ApiResponse.ok(
109+
200,
110+
recruitBoardQueryUseCase.getRecruitBoardsNearby(condition),
111+
"근처 봉사 활동 모집글 조회 성공"
112+
);
113+
}
114+
115+
@GetMapping("/recruit-boards/center/{centerId}")
116+
@Operation(summary = "특정 기관 모집글 조회", description = "특정 기관의 봉사 모집글을 조회합니다.")
117+
public ApiResponse<Page<RecruitBoardResponseDto>> getRecruitBoardsByCenterId(
118+
@PathVariable UUID centerId,
119+
@PageableDefault(sort = "created_at", direction = DESC) Pageable pageable,
120+
@RequestParam(required = false) String keyword,
121+
@RequestParam(required = false) VolunteerType type,
122+
@RequestParam(required = false) String region,
123+
@RequestParam(required = false) Boolean admitted,
124+
@RequestParam(required = false) RecruitStatus status
125+
) {
126+
RecruitBoardSearchCondition condition = RecruitBoardSearchCondition.builder()
127+
.keyword(keyword)
128+
.type(type)
129+
.region(region)
130+
.admitted(admitted)
131+
.status(status)
132+
.pageable(pageable)
133+
.build();
134+
135+
return ApiResponse.ok(
136+
200,
137+
recruitBoardQueryUseCase.getRecruitBoardsByCenterId(centerId, condition),
138+
"기관 봉사 활동 모집글 조회 성공"
139+
);
140+
}
141+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.somemore.recruitboard.dto.condition;
2+
3+
import lombok.Builder;
4+
import org.springframework.data.domain.Pageable;
5+
6+
@Builder
7+
public record RecruitBoardNearByCondition(
8+
Double latitude,
9+
Double longitude,
10+
Double radius,
11+
String keyword,
12+
Pageable pageable
13+
) {
14+
15+
}

0 commit comments

Comments
 (0)