Skip to content

Commit

Permalink
[FEAT] 선택한 구역에 대한 프로젝트, 프로필 카드 리스트 랜덤 조회 API, 프로젝트 매칭 요청 생성 API 개발 (#92)
Browse files Browse the repository at this point in the history
* [FEAT] 선택한 구역의 '대기중'인 프로젝트 카드 리스트 랜덤 조회 메서드 추가, 커서 기반 페이지네이션 공통 포맷 적용

* [FEAT] 프로젝트 상세 카드를 조회하는 동적 쿼리 조회 메서드에 커서 기반 페이지네이션 공통 포맷 적용

* [FEAT] Region에 대한 프로젝트 카드 리스트 조회 Controller 구현

* [FEAT] 프로젝트 카드 랜덤 리스트 조회 API에 대해 Swagger 추가

* [FEAT] 프로필 카드 리스트 랜덤 조회 API 구현

* [FEAT] 프로필 카드 리스트 랜덤 조회 비즈니스 로직 구현, 커서 기반 페이지네이션 공통 포맷 적용

* [FEAT] 프로필 카드 리스트 랜덤 조회 API를 Swagger에 추가

* [FEAT] 프로젝트 카드 상세 페이지 응답 DTO 구현

* [FEAT] 프로필 카드 동적 쿼리 조회 메서드 구현, 커서 기반 페이지네이션 및 공통 포맷 적용

* [REFACT] 선택한 구역에 대한 프로필 카드 리스트 조회 API 응답 변경

* [REFACT] 프로필 카드 리스트 조회 API 응답 타입 Swagger에서 변경

* [REFACT] 프로젝트 카드의 상세 정보를 위해 사용자 정보와 프로젝트 만남 정보에 대한 응답 수정

* [REFACT] 프로필 카드의 상세 정보를 위해 사용자 정보에 대한 응답 수정

* [REFACT] queryDsl에서 Projections.constructor 사용을 위해 public 생성자 생성, projectTag와의 조인 로직 추가

* [FEAT] 프로젝트 매칭 엔티티에 private 생성자, 정적 팩토리 메서드 추가 구현

* [FEAT] 프로젝트 매칭 생성 요청 DTO 구현

* [FEAT] 프로젝트 매칭 중복 생성 검증 메서드 추가 구현

* [FEAT] 프로젝트 매칭 요청 관련 에러코드 작성

* [FEAT] 프로젝트 매칭 요청 생성 메서드 구현, 프로젝트 매칭 요청 관련 유효성 검사 메서드 구현

* [FEAT] 프로젝트 매칭 요청 생성 API 작성, API 관련 Swagger 작성, 불필요한 에러코드 제거, 에러코드 관련 오류메시지 샘플 작성

* [FEAT] 프로젝트 매칭 생성 API 응답 DTO 생성

* [FIX] 패키지명 오타 수정

* [FIX] 에러코드, 오타 관련 수정

* [FIX] 에러코드, 오타 관련 수정

* [REFACT] 에러코드 수정 (403 -> 400)

* [FEAT] pageable 수정

* [REFACT] Profile Card 랜덤 조회 리스트 API 리팩토링

* [REFACT] 읽기전용 @transactional 추가

* [REFACT] 메서드명 변경, 중복 매칭 요청 로직 수정, 네이밍 수정

* [FEAT] 불필요한 import문 제거

* [REFACT] 어노테이션 수정

* [REFACT] ProjectJoinRequest 엔티티의 Controller 엔드포인트 수정

* [REFACT] 엔터 제거
  • Loading branch information
JIN-076 authored Feb 19, 2024
1 parent 4bf7789 commit 6911ccd
Show file tree
Hide file tree
Showing 26 changed files with 624 additions and 59 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.oeid.mogakgo.common.swagger.template;

import io.oeid.mogakgo.common.base.CursorPaginationInfoReq;
import io.oeid.mogakgo.common.base.CursorPaginationResult;
import io.oeid.mogakgo.core.properties.swagger.error.SwaggerGeoErrorExamples;
import io.oeid.mogakgo.core.properties.swagger.error.SwaggerUserErrorExamples;
import io.oeid.mogakgo.domain.geo.domain.enums.Region;
import io.oeid.mogakgo.domain.user.presentation.dto.res.UserPublicApiResponse;
import io.oeid.mogakgo.exception.dto.ErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;

@Tag(name = "Profile Card", description = "프로필 카드 관련 API")
public interface ProfileCardSwagger {

@Operation(summary = "선택한 서비스 지역에 대한 랜덤 순서의 프로필 리스트 조회", description = "사용자가 서비스 지역의 프로필 리스트를 조회할 때 사용하는 API")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "프로필 리스트 조회 성공"),
@ApiResponse(responseCode = "400", description = "요청한 데이터가 유효하지 않음",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "E080101", value = SwaggerGeoErrorExamples.INVALID_SERVICE_REGION)
)),
@ApiResponse(responseCode = "404", description = "요청한 데이터가 존재하지 않음",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND)
)),
})
@Parameters({
@Parameter(name = "cursorId", description = "기준이 되는 커서 ID", example = "1"),
@Parameter(name = "pageSize", description = "요청할 데이터 크기", example = "5", required = true),
@Parameter(name = "sortOrder", description = "정렬 방향", example = "ASC"),
})
ResponseEntity<CursorPaginationResult<UserPublicApiResponse>> getRandomOrderedProfileCardsByRegion(
@Parameter(hidden = true) Long userId,
@Parameter(description = "조회하려는 서비스 지역", required = true) Region region,
@Parameter(hidden = true) CursorPaginationInfoReq pageable
);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.oeid.mogakgo.common.swagger.template;

import io.oeid.mogakgo.core.properties.swagger.error.SwaggerProjectErrorExamples;
import io.oeid.mogakgo.core.properties.swagger.error.SwaggerProjectJoinRequestErrorExamples;
import io.oeid.mogakgo.core.properties.swagger.error.SwaggerUserErrorExamples;
import io.oeid.mogakgo.domain.project_join_req.application.dto.req.ProjectJoinCreateReq;
import io.oeid.mogakgo.domain.project_join_req.presentation.dto.res.ProjectJoinRequestAPIRes;
import io.oeid.mogakgo.exception.dto.ErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;

@Tag(name = "Project Join Request", description = "프로젝트 매칭 요청 관련 API")
public interface ProjectJoinRequestSwagger {

@Operation(summary = "프로젝트 매칭 요청 생성", description = "회원이 프로젝트 매칭 요청을 생성할 때 사용하는 API")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "프로젝트 매칭 요청 생성 성공",
content = @Content(schema = @Schema(implementation = ProjectJoinRequestAPIRes.class))),
@ApiResponse(responseCode = "400", description = "요청한 데이터가 유효하지 않음",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(name = "E090101",
value = SwaggerProjectJoinRequestErrorExamples.PROJECT_JOIN_REQUEST_ALREADY_EXIST),
@ExampleObject(name = "E090102",
value = SwaggerProjectJoinRequestErrorExamples.PROJECT_JOIN_REQUEST_INVALID_REGION)
})),
@ApiResponse(responseCode = "403", description = "프로젝트 매칭 요청 권한이 없음",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "E050201",
value = SwaggerProjectJoinRequestErrorExamples.PROJECT_JOIN_REQUEST_FORBIDDEN_OPERATION))),
@ApiResponse(responseCode = "404", description = "요청한 데이터가 존재하지 않음",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND),
@ExampleObject(name = "E030301", value = SwaggerProjectErrorExamples.PROJECT_NOT_FOUND)
})),
})
ResponseEntity<ProjectJoinRequestAPIRes> create(
@Parameter(hidden = true) Long userId,
ProjectJoinCreateReq request
);

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import io.oeid.mogakgo.common.base.CursorPaginationInfoReq;
import io.oeid.mogakgo.common.base.CursorPaginationResult;
import io.oeid.mogakgo.core.properties.swagger.error.SwaggerGeoErrorExamples;
import io.oeid.mogakgo.core.properties.swagger.error.SwaggerProjectErrorExamples;
import io.oeid.mogakgo.core.properties.swagger.error.SwaggerUserErrorExamples;
import io.oeid.mogakgo.domain.geo.domain.enums.Region;
import io.oeid.mogakgo.domain.project.presentation.dto.res.ProjectDetailAPIRes;
import io.oeid.mogakgo.domain.project.presentation.dto.req.ProjectCreateReq;
import io.oeid.mogakgo.domain.project.presentation.dto.res.ProjectIdRes;
import io.oeid.mogakgo.domain.project_join_req.presentation.projectJoinRequestRes;
Expand Down Expand Up @@ -112,6 +115,33 @@ ResponseEntity<ProjectIdRes> cancel(
@Parameter(description = "프로젝트 ID", required = true) Long id
);

@Operation(summary = "선택한 서비스 지역에 대한 랜덤 순서의 프로젝트 리스트 조회", description = "사용자가 서비스 지역의 프로젝트 리스트를 조회할 때 사용하는 API")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "프로젝트 리스트 조회 성공"),
@ApiResponse(responseCode = "400", description = "요청한 데이터가 유효하지 않음",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "E080101", value = SwaggerGeoErrorExamples.INVALID_SERVICE_REGION)
)),
@ApiResponse(responseCode = "404", description = "요청한 데이터가 존재하지 않음",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND)
)),
})
@Parameters({
@Parameter(name = "cursorId", description = "기준이 되는 커서 ID", example = "1"),
@Parameter(name = "pageSize", description = "요청할 데이터 크기", example = "5", required = true),
@Parameter(name = "sortOrder", description = "정렬 방향", example = "ASC"),
})
ResponseEntity<CursorPaginationResult<ProjectDetailAPIRes>> getRandomOrderedProjectsByRegion(
@Parameter(hidden = true) Long userId,
@Parameter(description = "조회하려는 서비스 지역", required = true) Region region,
@Parameter(hidden = true) CursorPaginationInfoReq pageable
);

@Operation(summary = "프로젝트 카드 참가 요청 조회", description = "회원이 프로젝트 카드의 참가 요청을 조회할 때 사용하는 API")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "프로젝트 카드 참가 요청 조회 성공"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.oeid.mogakgo.core.properties.swagger.error;

public class SwaggerProjectJoinRequestErrorExamples {

public static final String PROJECT_JOIN_REQUEST_ALREADY_EXIST = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":400,\"code\":\"E050201\",\"message\":\"이미 매칭 요청을 보낸 프로젝트에 매칭 요청을 보낼 수 없습니다.\"}";
public static final String PROJECT_JOIN_REQUEST_INVALID_REGION = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":400,\"code\":\"E090102\",\"message\":\"프로젝트 매칭 서비스는 동네 인증되지 않은 구역에서 진행할 수 없습니다.\"}";
public static final String PROJECT_JOIN_REQUEST_FORBIDDEN_OPERATION = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":403,\"code\":\"E090101\",\"message\":\"프로젝트 생성자는 해당 프로젝트에 매칭 요청을 보낼 수 없습니다.\"}";
private SwaggerProjectJoinRequestErrorExamples() {
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.oeid.mogakgo.domain.cert.application;

import static io.oeid.mogakgo.exception.code.ErrorCode400.INVALID_SERVICE_REGION;
import static io.oeid.mogakgo.exception.code.ErrorCode403.CERT_INVALID_INFORMATION;
import static io.oeid.mogakgo.exception.code.ErrorCode403.INVALID_CERT_INFORMATION;

import io.oeid.mogakgo.domain.cert.exception.CertException;
import io.oeid.mogakgo.domain.geo.domain.enums.Region;
Expand Down Expand Up @@ -41,7 +41,7 @@ private boolean isPossibleCertification(Long userId, Region region) {

private void validateCertificator(User tokenUser, Long userId) {
if (!tokenUser.getId().equals(userId)) {
throw new CertException(CERT_INVALID_INFORMATION);
throw new CertException(INVALID_CERT_INFORMATION);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.oeid.mogakgo.domain.profile.application;

import static io.oeid.mogakgo.exception.code.ErrorCode400.INVALID_SERVICE_REGION;

import io.oeid.mogakgo.common.base.CursorPaginationInfoReq;
import io.oeid.mogakgo.common.base.CursorPaginationResult;
import io.oeid.mogakgo.domain.geo.domain.enums.Region;
import io.oeid.mogakgo.domain.geo.exception.GeoException;
import io.oeid.mogakgo.domain.profile.infrastructure.ProfileCardJpaRepository;
import io.oeid.mogakgo.domain.user.application.UserCommonService;
import io.oeid.mogakgo.domain.user.domain.User;
import io.oeid.mogakgo.domain.user.presentation.dto.res.UserPublicApiResponse;
import java.util.Collections;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProfileCardService {

private final ProfileCardJpaRepository profileCardRepository;
private final UserCommonService userCommonService;

public CursorPaginationResult<UserPublicApiResponse> getRandomOrderedProfileCardsByRegion(
Long userId, Region region, CursorPaginationInfoReq pageable
) {
validateToken(userId);
validateRegionCoverage(region);

CursorPaginationResult<UserPublicApiResponse> projects = profileCardRepository
.findByConditionWithPagination(
null, region, pageable
);
Collections.shuffle(projects.getData());
return projects;
}

private User validateToken(Long userId) {
return userCommonService.getUserById(userId);
}

private void validateRegionCoverage(Region region) {
if (Region.getByAreaCode(region.getAreaCode()) == null) {
throw new GeoException(INVALID_SERVICE_REGION);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.stereotype.Repository;

@Repository
public interface ProfileCardJpaRepository extends JpaRepository<ProfileCard, Long>, ProfileCardRepositoryCustom {
public interface ProfileCardJpaRepository extends JpaRepository<ProfileCard, Long>,
ProfileCardRepositoryCustom {

}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package io.oeid.mogakgo.domain.profile.infrastructure;

import io.oeid.mogakgo.common.base.CursorPaginationInfoReq;
import io.oeid.mogakgo.common.base.CursorPaginationResult;
import io.oeid.mogakgo.domain.geo.domain.enums.Region;
import io.oeid.mogakgo.domain.profile.domain.entity.ProfileCard;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import io.oeid.mogakgo.domain.user.presentation.dto.res.UserPublicApiResponse;

public interface ProfileCardRepositoryCustom {

Slice<ProfileCard> findByCondition(
Long cursorId, Long userId, Region region, Pageable pageable
CursorPaginationResult<UserPublicApiResponse> findByConditionWithPagination(
Long userId, Region region, CursorPaginationInfoReq pageable
);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
package io.oeid.mogakgo.domain.profile.infrastructure;

import static io.oeid.mogakgo.domain.profile.domain.entity.QProfileCard.profileCard;
import static io.oeid.mogakgo.domain.project.domain.entity.QProject.project;

import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import io.oeid.mogakgo.common.base.CursorPaginationInfoReq;
import io.oeid.mogakgo.common.base.CursorPaginationResult;
import io.oeid.mogakgo.domain.geo.domain.enums.Region;
import io.oeid.mogakgo.domain.profile.domain.entity.ProfileCard;
import io.oeid.mogakgo.domain.user.presentation.dto.res.UserPublicApiResponse;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;
import org.springframework.stereotype.Repository;

@Repository
Expand All @@ -20,19 +21,35 @@ public class ProfileCardRepositoryCustomImpl implements ProfileCardRepositoryCus
private final JPAQueryFactory jpaQueryFactory;

@Override
public Slice<ProfileCard> findByCondition(
Long cursorId, Long userId, Region region, Pageable pageable
public CursorPaginationResult<UserPublicApiResponse> findByConditionWithPagination(
Long userId, Region region, CursorPaginationInfoReq pageable
) {
List<ProfileCard> result = jpaQueryFactory.selectFrom(profileCard)
List<UserPublicApiResponse> result = jpaQueryFactory.select(
Projections.constructor(
UserPublicApiResponse.class,
profileCard.user.id,
profileCard.user.username,
profileCard.user.githubId,
profileCard.user.avatarUrl,
profileCard.user.bio,
profileCard.user.jandiRate,
profileCard.user.achievement.title,
profileCard.user.userDevelopLanguageTags,
profileCard.user.userWantedJobTags
)
)
.from(profileCard)
.where(
cursorIdEq(cursorId),
cursorIdCondition(pageable.getCursorId()),
userIdEq(userId),
regionEq(region)
)
.limit(pageable.getPageSize() + 1)
.fetch();
boolean hasNext = checkLastPage(result, pageable);
return new SliceImpl<>(result, pageable, hasNext);

return CursorPaginationResult.fromDataWithExtraItemForNextCheck(
result, pageable.getPageSize()
);
}

private BooleanExpression regionEq(Region region) {
Expand All @@ -43,15 +60,7 @@ private BooleanExpression userIdEq(Long userId) {
return userId != null ? profileCard.user.id.eq(userId) : null;
}

private BooleanExpression cursorIdEq(Long cursorId) {
return cursorId != null ? profileCard.id.gt(cursorId) : null;
}

private boolean checkLastPage(List<ProfileCard> profileCards, Pageable pageable) {
if (profileCards.size() > pageable.getPageSize()) {
profileCards.remove(pageable.getPageSize());
return true;
}
return false;
private BooleanExpression cursorIdCondition(Long cursorId) {
return cursorId != null ? project.id.gt(cursorId) : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.oeid.mogakgo.domain.profile.presentation;

import io.oeid.mogakgo.common.annotation.UserId;
import io.oeid.mogakgo.common.base.CursorPaginationInfoReq;
import io.oeid.mogakgo.common.base.CursorPaginationResult;
import io.oeid.mogakgo.common.swagger.template.ProfileCardSwagger;
import io.oeid.mogakgo.domain.geo.domain.enums.Region;
import io.oeid.mogakgo.domain.profile.application.ProfileCardService;
import io.oeid.mogakgo.domain.user.presentation.dto.res.UserPublicApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/profiles")
@RequiredArgsConstructor
public class ProfileCardController implements ProfileCardSwagger {

private final ProfileCardService profileCardService;

@GetMapping("/{region}")
public ResponseEntity<CursorPaginationResult<UserPublicApiResponse>> getRandomOrderedProfileCardsByRegion(
@UserId Long userId, @PathVariable Region region,
@Valid @ModelAttribute CursorPaginationInfoReq pageable
) {
return ResponseEntity.ok().body(
profileCardService.getRandomOrderedProfileCardsByRegion(userId, region, pageable));
}
}
Loading

0 comments on commit 6911ccd

Please sign in to comment.