diff --git a/.github/workflows/cd_gradle.yml b/.github/workflows/cd_gradle.yml index 38a53c95..6167ce1f 100644 --- a/.github/workflows/cd_gradle.yml +++ b/.github/workflows/cd_gradle.yml @@ -49,6 +49,15 @@ jobs: run: echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application.yml shell: bash + ## create firebase_service_key.json + - name: make firebase_service_key.json + run: touch ./src/main/resources/firebase_service_key.json + shell: bash + + - name: save firebase_service_key.json from secrets + run: echo "${{ secrets.FIREBASE_SERVICE }}" > ./src/main/resources/firebase_service_key.json + shell: bash + ## grant permission for gradlew - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/ci_gradle.yml b/.github/workflows/ci_gradle.yml index 2ac26dae..cf1a40ce 100644 --- a/.github/workflows/ci_gradle.yml +++ b/.github/workflows/ci_gradle.yml @@ -54,7 +54,16 @@ jobs: - name: save test_application.yml for secrets run: echo "${{ secrets.APPLICATION_TEST }}" > ./src/test/resources/application.yml shell: bash - + + ## create firebase_service_key.json + - name: make firebase_service_key.json + run: touch ./src/main/resources/firebase_service_key.json + shell: bash + + - name: save firebase_service_key.json from secrets + run: echo "${{ secrets.FIREBASE_SERVICE }}" > ./src/main/resources/firebase_service_key.json + shell: bash + ## create test_ddl.sql for test ## execute Gradle test diff --git a/.gitignore b/.gitignore index 70859010..5e77ea8b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ .vscode/ ### application.yml ### -application*.yml \ No newline at end of file +application*.yml +firebase*.json \ No newline at end of file diff --git a/src/main/java/io/oeid/mogakgo/common/base/CursorPaginationInfoReq.java b/src/main/java/io/oeid/mogakgo/common/base/CursorPaginationInfoReq.java new file mode 100644 index 00000000..6881b2ea --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/common/base/CursorPaginationInfoReq.java @@ -0,0 +1,27 @@ +package io.oeid.mogakgo.common.base; + +import jakarta.validation.constraints.NotNull; +import java.util.Objects; +import lombok.Getter; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.lang.Nullable; + +@Getter +public class CursorPaginationInfoReq { + + @Nullable + private final Long cursorId; + + @NotNull + private final int pageSize; + + @Nullable + private final Sort.Direction sortOrder; + + public CursorPaginationInfoReq(Long cursorId, int pageSize, Direction sortOrder) { + this.cursorId = cursorId; + this.pageSize = pageSize; + this.sortOrder = Objects.requireNonNullElse(sortOrder, Direction.ASC); + } +} diff --git a/src/main/java/io/oeid/mogakgo/common/base/CursorPaginationResult.java b/src/main/java/io/oeid/mogakgo/common/base/CursorPaginationResult.java new file mode 100644 index 00000000..83d90fb9 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/common/base/CursorPaginationResult.java @@ -0,0 +1,50 @@ +package io.oeid.mogakgo.common.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "커서 기반 페이지네이션 결과에 대한 일괄 응답") +@Getter +public class CursorPaginationResult { + + @Schema(description = "데이터 목록") + private List data; + @Schema(description = "다음 페이지가 있는지 여부") + private boolean hasNext; + @Schema(description = "현재 페이지의 데이터 수") + private Integer numberOfElements; + @Schema(description = "요청한 데이터 수") + private Integer size; + + private CursorPaginationResult(List data, Integer size) { + this.data = data; + this.size = size; + if (data.size() > size) { + this.hasNext = true; + this.data.remove(data.size() - 1); + } else { + this.hasNext = false; + } + this.numberOfElements = data.size(); + } + + private CursorPaginationResult(List data, Integer size, boolean hasNext) { + this.data = data; + this.numberOfElements = data.size(); + this.hasNext = hasNext; + this.size = size; + } + + public static CursorPaginationResult fromDataWithExtraItemForNextCheck( + List data, Integer size + ) { + return new CursorPaginationResult<>(data, size); + } + + public static CursorPaginationResult fromDataWithHasNext( + List data, Integer size, boolean hasNext + ) { + return new CursorPaginationResult<>(data, size, hasNext); + } +} diff --git a/src/main/java/io/oeid/mogakgo/common/swagger/template/AuthSwagger.java b/src/main/java/io/oeid/mogakgo/common/swagger/template/AuthSwagger.java new file mode 100644 index 00000000..ee323cbb --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/common/swagger/template/AuthSwagger.java @@ -0,0 +1,36 @@ +package io.oeid.mogakgo.common.swagger.template; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import io.oeid.mogakgo.core.properties.swagger.error.SwaggerAuthErrorExamples; +import io.oeid.mogakgo.domain.auth.presentation.dto.res.AuthAccessTokenResponse; +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.enums.ParameterIn; +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 = "Auth", description = "인증 관련 API") +@SuppressWarnings("unused") +public interface AuthSwagger { + + @Operation(summary = "토큰 재발급", description = "Access Token을 재발급 받을 때 사용하는 API") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공", + content = @Content(schema = @Schema(implementation = AuthAccessTokenResponse.class))), + @ApiResponse(responseCode = "401", description = "재발급 토큰 인증정보가 유효하지 않음", + content = @Content( + mediaType = APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "E010201", value = SwaggerAuthErrorExamples.AUTH_MISSING_CREDENTIALS))) + }) + ResponseEntity reissue( + @Parameter(in = ParameterIn.HEADER, hidden = true) String accessToken, + @Parameter(in = ParameterIn.COOKIE) String refreshToken); +} diff --git a/src/main/java/io/oeid/mogakgo/common/swagger/template/CertSwagger.java b/src/main/java/io/oeid/mogakgo/common/swagger/template/CertSwagger.java new file mode 100644 index 00000000..9647a9cf --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/common/swagger/template/CertSwagger.java @@ -0,0 +1,48 @@ +package io.oeid.mogakgo.common.swagger.template; + +import io.oeid.mogakgo.core.properties.swagger.error.SwaggerCertErrorExamples; +import io.oeid.mogakgo.core.properties.swagger.error.SwaggerGeoErrorExamples; +import io.oeid.mogakgo.core.properties.swagger.error.SwaggerUserErrorExamples; +import io.oeid.mogakgo.domain.cert.presentation.dto.req.UserRegionCertAPIReq; +import io.oeid.mogakgo.domain.cert.presentation.dto.res.UserRegionCertAPIRes; +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 = "Cert", description = "동네 인증 관련 API") +@SuppressWarnings("unused") +public interface CertSwagger { + + @Operation(summary = "동네 인증 완료 응답", description = "동네 인증 완료를 요청할 때 사용하는 API" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "동네 인증 요청 성공", + content = @Content(schema = @Schema(implementation = UserRegionCertAPIRes.class))), + @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 = "403", description = "동네 인증 권한이 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "E070201", value = SwaggerCertErrorExamples.INVALID_CERT_INFO))), + @ApiResponse(responseCode = "404", description = "요청한 유저가 존재하지 않음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND))), + }) + ResponseEntity certificateNeighborhood( + @Parameter(hidden = true) Long userId, + UserRegionCertAPIReq request + ); +} diff --git a/src/main/java/io/oeid/mogakgo/common/swagger/template/GeoSwagger.java b/src/main/java/io/oeid/mogakgo/common/swagger/template/GeoSwagger.java new file mode 100644 index 00000000..3d3b9d8b --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/common/swagger/template/GeoSwagger.java @@ -0,0 +1,43 @@ +package io.oeid.mogakgo.common.swagger.template; + +import io.oeid.mogakgo.core.properties.swagger.error.SwaggerGeoErrorExamples; +import io.oeid.mogakgo.core.properties.swagger.error.SwaggerUserErrorExamples; +import io.oeid.mogakgo.domain.geo.presentation.dto.res.UserRegionInfoAPIRes; +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 = "Geo", description = "지역 관련 API") +@SuppressWarnings("unused") +public interface GeoSwagger { + + @Operation(summary = "GPS에 대한 법정구역코드 응답", description = "사용자의 GPS 좌표의 법정구역코드를 요청할 때 사용하는 API" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "법정구역코드 요청 성공", + content = @Content(schema = @Schema(implementation = UserRegionInfoAPIRes.class))), + @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))), + }) + ResponseEntity getUserRegionInfoByGPS( + @Parameter(hidden = true) Long userId, + @Parameter(description = "사용자의 GPS 경도", required = true) Double longitude, + @Parameter(description = "사용자의 GPS 위도", required = true) Double latitude + ); + +} diff --git a/src/main/java/io/oeid/mogakgo/common/swagger/template/NotificationSwagger.java b/src/main/java/io/oeid/mogakgo/common/swagger/template/NotificationSwagger.java new file mode 100644 index 00000000..c6a1defb --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/common/swagger/template/NotificationSwagger.java @@ -0,0 +1,32 @@ +package io.oeid.mogakgo.common.swagger.template; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import io.oeid.mogakgo.core.properties.swagger.error.SwaggerUserErrorExamples; +import io.oeid.mogakgo.domain.notification.presentation.dto.req.FCMTokenApiRequest; +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.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Notification", description = "알림 관련 API") +@SuppressWarnings("unused") +public interface NotificationSwagger { + + @Operation(summary = "FCM 토큰 저장", description = "회원의 FCM 토큰을 저장할 때 사용하는 API") + @ApiResponse(responseCode = "200", description = "FCM 토큰 저장 성공") + @ApiResponse(responseCode = "404", description = "요청한 유저가 존재하지 않음", content = @Content( + mediaType = APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND) + }) + ) + ResponseEntity manageFCMToken(@Parameter(hidden = true) Long userId, + FCMTokenApiRequest request); +} diff --git a/src/main/java/io/oeid/mogakgo/common/swagger/template/OAuth2Swagger.java b/src/main/java/io/oeid/mogakgo/common/swagger/template/OAuth2Swagger.java new file mode 100644 index 00000000..bd35fecf --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/common/swagger/template/OAuth2Swagger.java @@ -0,0 +1,29 @@ +package io.oeid.mogakgo.common.swagger.template; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import io.oeid.mogakgo.domain.auth.presentation.dto.res.AuthAccessTokenResponse; +import io.oeid.mogakgo.domain.auth.presentation.dto.res.AuthLoginUrlResponse; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Tag(name = "OAuth2", description = "OAuth2 관련 API") +@SuppressWarnings("unused") +public interface OAuth2Swagger { + @Operation(summary = "로그인 URL 반환", description = "로그인 URL을 반환하는 API") + @ApiResponse(responseCode = "200", description = "로그인 URL 반환 성공", + content = @Content( + mediaType = APPLICATION_JSON_VALUE, + schema = @Schema(implementation = AuthLoginUrlResponse.class))) + ResponseEntity login(); + @Hidden + ResponseEntity loginSuccess( + OAuth2User oAuth2User, HttpServletResponse response); +} diff --git a/src/main/java/io/oeid/mogakgo/common/swagger/template/ProjectSwagger.java b/src/main/java/io/oeid/mogakgo/common/swagger/template/ProjectSwagger.java index 1d80ddc9..47884af6 100644 --- a/src/main/java/io/oeid/mogakgo/common/swagger/template/ProjectSwagger.java +++ b/src/main/java/io/oeid/mogakgo/common/swagger/template/ProjectSwagger.java @@ -1,12 +1,16 @@ 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.SwaggerProjectErrorExamples; import io.oeid.mogakgo.core.properties.swagger.error.SwaggerUserErrorExamples; 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; 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; @@ -19,8 +23,7 @@ @SuppressWarnings("unused") public interface ProjectSwagger { - @Operation(summary = "프로젝트 카드 생성", description = "회원이 프로젝트 카드를 생성할 때 사용하는 API" - ) + @Operation(summary = "프로젝트 카드 생성", description = "회원이 프로젝트 카드를 생성할 때 사용하는 API") @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "프로젝트 카드 생성 성공", content = @Content(schema = @Schema(implementation = ProjectIdRes.class))), @@ -51,10 +54,16 @@ ResponseEntity create( ProjectCreateReq request ); - @Operation(summary = "프로젝트 카드 삭제", description = "회원이 프로젝트 카드를 삭제할 때 사용하는 API" - ) + @Operation(summary = "프로젝트 카드 삭제", description = "회원이 프로젝트 카드를 삭제할 때 사용하는 API") @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "프로젝트 카드 삭제 성공"), + @ApiResponse(responseCode = "400", description = "프로젝트를 삭제 할 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "E030106", value = SwaggerProjectErrorExamples.PROJECT_DELETION_NOT_ALLOWED) + })), @ApiResponse(responseCode = "404", description = "요청한 데이터가 존재하지 않음", content = @Content( mediaType = "application/json", @@ -73,4 +82,61 @@ ResponseEntity delete( @Parameter(hidden = true) Long userId, @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 = "E030107", value = SwaggerProjectErrorExamples.PROJECT_CANCEL_NOT_ALLOWED) + })), + @ApiResponse(responseCode = "404", description = "요청한 데이터가 존재하지 않음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "E030301", value = SwaggerProjectErrorExamples.PROJECT_NOT_FOUND), + @ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND) + })), + @ApiResponse(responseCode = "403", description = "프로젝트 카드 취소 권한이 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "E030201", value = SwaggerProjectErrorExamples.PROJECT_FORBIDDEN_OPERATION))) + }) + ResponseEntity cancel( + @Parameter(hidden = true) Long userId, + @Parameter(description = "프로젝트 ID", required = true) Long id + ); + + @Operation(summary = "프로젝트 카드 참가 요청 조회", description = "회원이 프로젝트 카드의 참가 요청을 조회할 때 사용하는 API") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "프로젝트 카드 참가 요청 조회 성공"), + @ApiResponse(responseCode = "404", description = "요청한 데이터가 존재하지 않음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "E030301", value = SwaggerProjectErrorExamples.PROJECT_NOT_FOUND), + @ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND) + })), + @ApiResponse(responseCode = "403", description = "본인의 프로젝트 카드만 조회 할 수 있음.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "E050201", value = SwaggerProjectErrorExamples.PROJECT_JOIN_REQUEST_FORBIDDEN_OPERATION))) + }) + @Parameters({ + @Parameter(name = "cursorId", description = "기준이 되는 커서 ID", example = "1"), + @Parameter(name = "pageSize", description = "요청할 데이터 크기", example = "5", required = true), + @Parameter(name = "sortOrder", description = "정렬 방향", example = "ASC"), + }) + ResponseEntity> getJoinRequest( + @Parameter(hidden = true) Long userId, + @Parameter(description = "프로젝트 ID", required = true) Long id, + @Parameter(hidden = true) CursorPaginationInfoReq pageable + ); } diff --git a/src/main/java/io/oeid/mogakgo/common/swagger/template/UserSwagger.java b/src/main/java/io/oeid/mogakgo/common/swagger/template/UserSwagger.java new file mode 100644 index 00000000..336690a3 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/common/swagger/template/UserSwagger.java @@ -0,0 +1,54 @@ +package io.oeid.mogakgo.common.swagger.template; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import io.oeid.mogakgo.core.properties.swagger.error.SwaggerUserErrorExamples; +import io.oeid.mogakgo.domain.user.presentation.dto.req.UserSignUpApiRequest; +import io.oeid.mogakgo.domain.user.presentation.dto.res.UserSignUpApiResponse; +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 = "User", description = "회원 관련 API") +@SuppressWarnings("unused") +public interface UserSwagger { + + @Operation(summary = "회원 가입", description = "회원 가입을 할 때 사용하는 API") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원 가입 성공", + content = @Content(schema = @Schema(implementation = UserSignUpApiResponse.class))), + @ApiResponse(responseCode = "400", description = "요청한 데이터가 유효하지 않음", + content = @Content( + mediaType = APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "E020103", value = SwaggerUserErrorExamples.INVALID_USER_NAME), + })), + @ApiResponse(responseCode = "404", description = "OAuth2 정보가 존재하지 않음", + content = @Content( + mediaType = APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND))), + }) + ResponseEntity userSignUpApi( + @Parameter(hidden = true) Long userId, + UserSignUpApiRequest apiRequest); + + @Operation(summary = "회원 삭제", description = "회원을 삭제할 때 사용하는 API") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원 삭제 성공"), + @ApiResponse(responseCode = "404", description = "해당 유저가 존재하지 않음", + content = @Content( + mediaType = APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "E020301", value = SwaggerUserErrorExamples.USER_NOT_FOUND))), + }) + ResponseEntity userDeleteApi(@Parameter(hidden = true) Long userId); +} diff --git a/src/main/java/io/oeid/mogakgo/core/configuration/FCMConfig.java b/src/main/java/io/oeid/mogakgo/core/configuration/FCMConfig.java new file mode 100644 index 00000000..916b1019 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/core/configuration/FCMConfig.java @@ -0,0 +1,43 @@ +package io.oeid.mogakgo.core.configuration; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Objects; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FCMConfig { + + private FirebaseApp firebaseApp; + + public FCMConfig(@Value("${firebase.key-path}") String keyPath) throws IOException { + try (InputStream credentials = getClass().getClassLoader().getResourceAsStream(keyPath)) { + List firebaseApps = FirebaseApp.getApps(); + if (firebaseApps != null && !firebaseApps.isEmpty()) { + for (FirebaseApp app : firebaseApps) { + if (app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) { + firebaseApp = app; + } + } + } else { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream( + Objects.requireNonNull(credentials))) + .build(); + firebaseApp = FirebaseApp.initializeApp(options); + } + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + return FirebaseMessaging.getInstance(firebaseApp); + } +} diff --git a/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerAuthErrorExamples.java b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerAuthErrorExamples.java new file mode 100644 index 00000000..b93b2966 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerAuthErrorExamples.java @@ -0,0 +1,9 @@ +package io.oeid.mogakgo.core.properties.swagger.error; + +public class SwaggerAuthErrorExamples { + + public static final String AUTH_MISSING_CREDENTIALS = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":401,\"code\":\"E010201\",\"message\":\"사용자의 인증 정보를 찾을 수 없습니다.\"}"; + + private SwaggerAuthErrorExamples() { + } +} diff --git a/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerCertErrorExamples.java b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerCertErrorExamples.java new file mode 100644 index 00000000..0458fc72 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerCertErrorExamples.java @@ -0,0 +1,9 @@ +package io.oeid.mogakgo.core.properties.swagger.error; + +public class SwaggerCertErrorExamples { + + public static final String INVALID_CERT_INFO = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":401,\"code\":\"E070201\",\"message\":\"동네 인증을 수행할 권한이 없습니다.\"}"; + private SwaggerCertErrorExamples() { + + } +} diff --git a/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerGeoErrorExamples.java b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerGeoErrorExamples.java new file mode 100644 index 00000000..8289e600 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerGeoErrorExamples.java @@ -0,0 +1,9 @@ +package io.oeid.mogakgo.core.properties.swagger.error; + +public class SwaggerGeoErrorExamples { + + public static final String INVALID_SERVICE_REGION = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":400,\"code\":\"E080101\",\"message\":\"해당 지역은 서비스 지역이 아닙니다.\"}"; + private SwaggerGeoErrorExamples() { + + } +} diff --git a/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerProjectErrorExamples.java b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerProjectErrorExamples.java index 93e6cf9e..efb45390 100644 --- a/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerProjectErrorExamples.java +++ b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerProjectErrorExamples.java @@ -9,6 +9,9 @@ public class SwaggerProjectErrorExamples { public static final String INVALID_PROJECT_MEET_LOCATION = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":400,\"code\":\"E030105\",\"message\":\"프로젝트 만남 장소가 유저가 동네인증 한 구역이 아닙니다.\"}"; public static final String PROJECT_NOT_FOUND = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":404,\"code\":\"E030301\",\"message\":\"해당 프로젝트가 존재하지 않습니다.\"}"; public static final String PROJECT_FORBIDDEN_OPERATION = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":403,\"code\":\"E030201\",\"message\":\"해당 프로젝트에 대한 권한이 없습니다.\"}"; + public static final String PROJECT_DELETION_NOT_ALLOWED = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":400,\"code\":\"E030106\",\"message\":\"매칭 중이거나 대기중인 프로젝트는 삭제할 수 없습니다.\"}"; + public static final String PROJECT_CANCEL_NOT_ALLOWED = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":400,\"code\":\"E030107\",\"message\":\"이미 취소 되었거나 종료된 프로젝트는 취소할 수 없습니다.\"}"; + public static final String PROJECT_JOIN_REQUEST_FORBIDDEN_OPERATION = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":403,\"code\":\"E050201\",\"message\":\"해당 프로젝트 요청에 대한 권한이 없습니다.\"}"; private SwaggerProjectErrorExamples() { } diff --git a/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerUserErrorExamples.java b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerUserErrorExamples.java index e7c49b0a..1cf7daa9 100644 --- a/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerUserErrorExamples.java +++ b/src/main/java/io/oeid/mogakgo/core/properties/swagger/error/SwaggerUserErrorExamples.java @@ -3,6 +3,7 @@ public class SwaggerUserErrorExamples { public static final String USER_NOT_FOUND = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":404,\"code\":\"E020301\",\"message\":\"해당 유저가 존재하지 않습니다.\"}"; + public static final String INVALID_USER_NAME = "{\"timestamp\":\"2024-02-17T10:07:31.404Z\",\"statusCode\":400,\"code\":\"E020103\",\"message\":\"유저 이름은 비어있을 수 없습니다.\"}"; private SwaggerUserErrorExamples() { } diff --git a/src/main/java/io/oeid/mogakgo/domain/achievement/domain/Achievement.java b/src/main/java/io/oeid/mogakgo/domain/achievement/domain/Achievement.java new file mode 100644 index 00000000..6052841c --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/achievement/domain/Achievement.java @@ -0,0 +1,48 @@ +package io.oeid.mogakgo.domain.achievement.domain; + +import io.oeid.mogakgo.domain.achievement.domain.enums.AchievementType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "achievement_tb") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class Achievement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "title") + private String title; + + @Column(name = "description") + private String description; + + @Column(name = "img_url") + private String imgUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "type") + private AchievementType achievementType; + + @Builder + private Achievement(String title, String description, String imgUrl, + AchievementType achievementType) { + this.title = title; + this.description = description; + this.imgUrl = imgUrl; + this.achievementType = achievementType; + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/achievement/domain/enums/AchievementType.java b/src/main/java/io/oeid/mogakgo/domain/achievement/domain/enums/AchievementType.java new file mode 100644 index 00000000..397a7f85 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/achievement/domain/enums/AchievementType.java @@ -0,0 +1,5 @@ +package io.oeid.mogakgo.domain.achievement.domain.enums; + +public enum AchievementType { + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/application/AuthService.java b/src/main/java/io/oeid/mogakgo/domain/auth/application/AuthService.java new file mode 100644 index 00000000..969f80f2 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/auth/application/AuthService.java @@ -0,0 +1,45 @@ +package io.oeid.mogakgo.domain.auth.application; + +import com.auth0.jwt.interfaces.Claim; +import io.oeid.mogakgo.domain.auth.application.dto.res.AuthReissueResponse; +import io.oeid.mogakgo.domain.auth.exception.AuthException; +import io.oeid.mogakgo.domain.auth.jwt.JwtHelper; +import io.oeid.mogakgo.domain.auth.jwt.JwtRedisDao; +import io.oeid.mogakgo.exception.code.ErrorCode401; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@RequiredArgsConstructor +@Service +public class AuthService { + + private final JwtRedisDao jwtRedisDao; + private final JwtHelper jwtHelper; + + public AuthReissueResponse reissue(String expiredAccessToken, String refreshToken) { + expiredAccessToken = expiredAccessToken.substring(7); // remove "Bearer " (7 characters) + String verifyRefreshToken = jwtRedisDao.getRefreshTokenByAccessToken(expiredAccessToken); + if (!refreshToken.equals(verifyRefreshToken)) { + throw new AuthException(ErrorCode401.AUTH_MISSING_CREDENTIALS); + } + String accessToken = generateAccessToken(expiredAccessToken); + int refreshTokenExpirySeconds = calculateRefreshTokenExpirySeconds(refreshToken); + jwtRedisDao.saveTokens(accessToken, refreshToken, refreshTokenExpirySeconds); + return AuthReissueResponse.from(accessToken); + } + + private String generateAccessToken(String expiredAccessToken) { + Map claims = jwtHelper.verifyWithoutExpiry(expiredAccessToken); + long userId = claims.get(JwtHelper.USER_ID_STR).asLong(); + String[] roles = claims.get(JwtHelper.ROLES_STR).asArray(String.class); + return jwtHelper.sign(userId, roles, expiredAccessToken).getAccessToken(); + } + + private int calculateRefreshTokenExpirySeconds(String refreshToken) { + Map claims = jwtHelper.verifyRefreshToken(refreshToken); + return (int) (claims.get("exp").asLong() - System.currentTimeMillis() / 1000); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/application/dto/res/AuthReissueResponse.java b/src/main/java/io/oeid/mogakgo/domain/auth/application/dto/res/AuthReissueResponse.java new file mode 100644 index 00000000..5fdbc774 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/auth/application/dto/res/AuthReissueResponse.java @@ -0,0 +1,16 @@ +package io.oeid.mogakgo.domain.auth.application.dto.res; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class AuthReissueResponse { + + private final String accessToken; + + public static AuthReissueResponse from(String accessToken) { + return new AuthReissueResponse(accessToken); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/jwt/JwtHelper.java b/src/main/java/io/oeid/mogakgo/domain/auth/jwt/JwtHelper.java index 7259bfa7..ab044eb6 100644 --- a/src/main/java/io/oeid/mogakgo/domain/auth/jwt/JwtHelper.java +++ b/src/main/java/io/oeid/mogakgo/domain/auth/jwt/JwtHelper.java @@ -3,11 +3,11 @@ import static com.auth0.jwt.JWT.create; import static com.auth0.jwt.JWT.require; -import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.jwt.interfaces.JWTVerifier; import io.oeid.mogakgo.core.properties.JwtProperties; import java.util.Date; import java.util.Map; @@ -61,6 +61,19 @@ public JwtToken sign(long userId, String[] roles) { (int) refreshTokenExpirySeconds / 1000); } + public JwtToken sign(long userId, String[] roles, String refreshToken) { + Date now = new Date(); + String accessToken = create() + .withIssuer(issuer) + .withIssuedAt(now) + .withExpiresAt(calculateExpirySeconds(now, accessTokenExpirySeconds)) + .withClaim(USER_ID_STR, userId) + .withArrayClaim(ROLES_STR, roles) + .sign(algorithm); + return JwtToken.of(userId, accessToken, refreshToken, + (int) refreshTokenExpirySeconds / 1000); + } + public Map verify(String token) throws JWTVerificationException { DecodedJWT decodedJWT = jwtVerifier.verify(token); @@ -72,4 +85,14 @@ public Map verify(String token) } return claims; } + + public Map verifyWithoutExpiry(String token) { + JWTVerifier verifier = require(algorithm).acceptExpiresAt(refreshTokenExpirySeconds) + .build(); + return verifier.verify(token).getClaims(); + } + + public Map verifyRefreshToken(String token){ + return jwtVerifier.verify(token).getClaims(); + } } diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/jwt/JwtRedisDao.java b/src/main/java/io/oeid/mogakgo/domain/auth/jwt/JwtRedisDao.java index 6d2ad4af..1d8b1567 100644 --- a/src/main/java/io/oeid/mogakgo/domain/auth/jwt/JwtRedisDao.java +++ b/src/main/java/io/oeid/mogakgo/domain/auth/jwt/JwtRedisDao.java @@ -1,6 +1,8 @@ package io.oeid.mogakgo.domain.auth.jwt; import io.oeid.mogakgo.core.properties.JwtProperties; +import io.oeid.mogakgo.domain.auth.exception.AuthException; +import io.oeid.mogakgo.exception.code.ErrorCode401; import java.util.Optional; import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.RedisTemplate; @@ -25,10 +27,15 @@ public void saveTokens(String accessToken, String refreshToken) { .set(accessToken, refreshToken, refreshExpireHour, TimeUnit.HOURS); } + @Transactional + public void saveTokens(String accessToken, String refreshToken, int expireHour) { + redisTemplate.opsForValue() + .set(accessToken, refreshToken, expireHour, TimeUnit.SECONDS); + } + @Transactional(readOnly = true) - // TODO: 2024-02-15 if null then what should I do? public String getRefreshTokenByAccessToken(String accessToken) { var result = Optional.ofNullable(redisTemplate.opsForValue().get(accessToken)); - return result.orElseThrow(); + return result.orElseThrow(() -> new AuthException(ErrorCode401.AUTH_MISSING_CREDENTIALS)); } } diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/oauth/GithubOAuth2UserService.java b/src/main/java/io/oeid/mogakgo/domain/auth/oauth/GithubOAuth2UserService.java index 88e4c5aa..2d0237e1 100644 --- a/src/main/java/io/oeid/mogakgo/domain/auth/oauth/GithubOAuth2UserService.java +++ b/src/main/java/io/oeid/mogakgo/domain/auth/oauth/GithubOAuth2UserService.java @@ -34,7 +34,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic private OAuth2User generateOAuth2User(String nameAttributeKey, long id, User user) { Map attributes = Map.of( nameAttributeKey, id, - "user", user + "signupComplete", user.getSignupYn() ); return new DefaultOAuth2User(user.getAuthorities(), attributes, nameAttributeKey); } diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/oauth/OAuth2AuthenticationSuccessHandler.java b/src/main/java/io/oeid/mogakgo/domain/auth/oauth/OAuth2AuthenticationSuccessHandler.java index 8556aaa2..fe14cc3f 100644 --- a/src/main/java/io/oeid/mogakgo/domain/auth/oauth/OAuth2AuthenticationSuccessHandler.java +++ b/src/main/java/io/oeid/mogakgo/domain/auth/oauth/OAuth2AuthenticationSuccessHandler.java @@ -23,8 +23,6 @@ @RequiredArgsConstructor public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler { - private static final long MILLIS_PER_SECOND = 1000; - private final JwtHelper jwtHelper; private final JwtRedisDao jwtRedisDao; @@ -37,6 +35,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo jwtRedisDao.saveTokens(jwtToken.getAccessToken(), jwtToken.getRefreshToken()); Map attributes = new HashMap<>(); attributes.put("id", Long.parseLong(oAuth2User.getName())); + attributes.put("signUpComplete", oAuth2User.getAttribute("signupComplete")); attributes.put("accessToken", jwtToken.getAccessToken()); attributes.put("refreshToken", jwtToken.getRefreshToken()); attributes.put("refreshTokenExpireTime", jwtToken.getRefreshTokenExpirySeconds()); diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/presentation/AuthController.java b/src/main/java/io/oeid/mogakgo/domain/auth/presentation/AuthController.java index 136026b6..071f8237 100644 --- a/src/main/java/io/oeid/mogakgo/domain/auth/presentation/AuthController.java +++ b/src/main/java/io/oeid/mogakgo/domain/auth/presentation/AuthController.java @@ -1,42 +1,28 @@ package io.oeid.mogakgo.domain.auth.presentation; +import io.oeid.mogakgo.common.swagger.template.AuthSwagger; +import io.oeid.mogakgo.domain.auth.application.AuthService; import io.oeid.mogakgo.domain.auth.presentation.dto.res.AuthAccessTokenResponse; -import io.oeid.mogakgo.domain.auth.presentation.dto.res.AuthLoginUrlResponse; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; +import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Slf4j @RestController -@RequestMapping("/oauth") -public class AuthController { +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController implements AuthSwagger { - @GetMapping("/login/success") - public ResponseEntity loginSuccess( - @AuthenticationPrincipal OAuth2User oAuth2User, HttpServletResponse response) { - String accessToken = oAuth2User.getAttributes().get("accessToken").toString(); - String refreshToken = oAuth2User.getAttributes().get("refreshToken").toString(); - int refreshTokenExpireTime = (int) oAuth2User.getAttributes().get("refreshTokenExpireTime"); - setCookie(refreshToken, refreshTokenExpireTime, response); - return ResponseEntity.ok(AuthAccessTokenResponse.from(accessToken)); - } - - @GetMapping("/login") - public ResponseEntity login() { - return ResponseEntity.ok( - AuthLoginUrlResponse.from("http://127.0.0.1:8080/oauth2/authorization/github")); - } + private final AuthService authService; - private void setCookie(String refreshToken, int refreshTokenExpireTime, HttpServletResponse response) { - Cookie cookie = new Cookie("refreshToken", refreshToken); - cookie.setMaxAge(refreshTokenExpireTime); - cookie.setPath("/"); - response.addCookie(cookie); + @PostMapping("/reissue") + public ResponseEntity reissue( + @RequestHeader("Authorization") String accessToken, + @CookieValue("refreshToken") String refreshToken) { + var accessTokenDto = authService.reissue(accessToken, refreshToken); + return ResponseEntity.ok(AuthAccessTokenResponse.of(accessTokenDto.getAccessToken(), null)); } } diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/presentation/OAuth2Controller.java b/src/main/java/io/oeid/mogakgo/domain/auth/presentation/OAuth2Controller.java new file mode 100644 index 00000000..b5f049f9 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/auth/presentation/OAuth2Controller.java @@ -0,0 +1,52 @@ +package io.oeid.mogakgo.domain.auth.presentation; + +import io.oeid.mogakgo.common.swagger.template.OAuth2Swagger; +import io.oeid.mogakgo.domain.auth.presentation.dto.res.AuthAccessTokenResponse; +import io.oeid.mogakgo.domain.auth.presentation.dto.res.AuthLoginUrlResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/oauth2") +public class OAuth2Controller implements OAuth2Swagger { + + private final String serverUrl; + + public OAuth2Controller(@Value("${auth.server-url}") String serverUrl) { + this.serverUrl = serverUrl; + } + + @GetMapping("/login/success") + public ResponseEntity loginSuccess( + @AuthenticationPrincipal OAuth2User oAuth2User, HttpServletResponse response) { + String accessToken = oAuth2User.getAttributes().get("accessToken").toString(); + String refreshToken = oAuth2User.getAttributes().get("refreshToken").toString(); + int refreshTokenExpireTime = (int) oAuth2User.getAttributes().get("refreshTokenExpireTime"); + boolean signUpComplete = (boolean) oAuth2User.getAttributes().get("signUpComplete"); + setCookie(refreshToken, refreshTokenExpireTime, response); + return ResponseEntity.ok(AuthAccessTokenResponse.of(accessToken, signUpComplete)); + } + + @GetMapping("/login") + public ResponseEntity login() { + return ResponseEntity.ok( + AuthLoginUrlResponse.from(serverUrl + "/oauth2/authorization/github")); + } + + private void setCookie(String refreshToken, int refreshTokenExpireTime, + HttpServletResponse response) { + Cookie cookie = new Cookie("refreshToken", refreshToken); + cookie.setMaxAge(refreshTokenExpireTime); + cookie.setPath("/"); + response.addCookie(cookie); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/presentation/dto/res/AuthAccessTokenResponse.java b/src/main/java/io/oeid/mogakgo/domain/auth/presentation/dto/res/AuthAccessTokenResponse.java index ca5e576d..3e0751f0 100644 --- a/src/main/java/io/oeid/mogakgo/domain/auth/presentation/dto/res/AuthAccessTokenResponse.java +++ b/src/main/java/io/oeid/mogakgo/domain/auth/presentation/dto/res/AuthAccessTokenResponse.java @@ -1,17 +1,23 @@ package io.oeid.mogakgo.domain.auth.presentation.dto.res; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +@Schema(description = "Access Token 재발급 응답") @Getter public class AuthAccessTokenResponse { + @Schema(description = "Access Token", example="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJNb0dha0dvIiwiaWF0IjoxNzA4MjE5NDA3LCJleHAiOjE3NDQyMTk0MDcsInVzZXJJZCI6Miwicm9sZXMiOlsiUk9MRV9VU0VSIl19.vu_Oq5dX3cMYAOwFIk_BvqkEGrkk0Reth2FBde7pcKw") private final String accessToken; + @Schema(description = "회원가입 완료 여부", example="true", nullable = true) + private final Boolean signUpComplete; - private AuthAccessTokenResponse(String accessToken) { + private AuthAccessTokenResponse(String accessToken, Boolean signUpComplete) { this.accessToken = accessToken; + this.signUpComplete = signUpComplete; } - public static AuthAccessTokenResponse from(String accessToken) { - return new AuthAccessTokenResponse(accessToken); + public static AuthAccessTokenResponse of(String accessToken, Boolean signUpComplete) { + return new AuthAccessTokenResponse(accessToken, signUpComplete); } } diff --git a/src/main/java/io/oeid/mogakgo/domain/auth/presentation/dto/res/AuthLoginUrlResponse.java b/src/main/java/io/oeid/mogakgo/domain/auth/presentation/dto/res/AuthLoginUrlResponse.java index 2f4d7327..9d9d8cb9 100644 --- a/src/main/java/io/oeid/mogakgo/domain/auth/presentation/dto/res/AuthLoginUrlResponse.java +++ b/src/main/java/io/oeid/mogakgo/domain/auth/presentation/dto/res/AuthLoginUrlResponse.java @@ -1,10 +1,13 @@ package io.oeid.mogakgo.domain.auth.presentation.dto.res; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +@Schema(description = "로그인 URL 반환 응답") @Getter public class AuthLoginUrlResponse { + @Schema(description = "로그인 URL", example = "http://3.38.76.76:8080/oauth2/authorization/github") private final String loginUrl; private AuthLoginUrlResponse(String loginUrl) { diff --git a/src/main/java/io/oeid/mogakgo/domain/cert/application/CertService.java b/src/main/java/io/oeid/mogakgo/domain/cert/application/CertService.java new file mode 100644 index 00000000..2f21f0ed --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/cert/application/CertService.java @@ -0,0 +1,56 @@ +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 io.oeid.mogakgo.domain.cert.exception.CertException; +import io.oeid.mogakgo.domain.geo.domain.enums.Region; +import io.oeid.mogakgo.domain.geo.exception.GeoException; +import io.oeid.mogakgo.domain.user.application.UserCommonService; +import io.oeid.mogakgo.domain.user.application.UserGeoService; +import io.oeid.mogakgo.domain.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CertService { + + private final UserGeoService userGeoService; + private final UserCommonService userCommonService; + + public Long certificate(Long tokenUserId, Long userId, int areaCode) { + User tokenUser = validateToken(tokenUserId); + validateCertificator(tokenUser, userId); + Region region = validateAreaCodeCoverage(areaCode); + if (isPossibleCertification(userId, region)) { + userGeoService.updateUserGeo(userId, region); + } + return userId; + } + + private User validateToken(Long userId) { + return userCommonService.getUserById(userId); + } + + // 사용자가 아직 동네 인증을 하지 않았거나, 새롭게 인증하려는 지역이 이미 인증된 지역과 다를 경우만 동네 인증 처리 + private boolean isPossibleCertification(Long userId, Region region) { + Region userRegionInfo = userGeoService.getUserGeo(userId).getRegion(); + return userRegionInfo == null || userRegionInfo != region; + } + + private void validateCertificator(User tokenUser, Long userId) { + if (!tokenUser.getId().equals(userId)) { + throw new CertException(CERT_INVALID_INFORMATION); + } + } + + private Region validateAreaCodeCoverage(int areaCode) { + Region region = Region.getByAreaCode(areaCode); + if (region == null) { + throw new GeoException(INVALID_SERVICE_REGION); + } + return region; + } + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/cert/exception/CertException.java b/src/main/java/io/oeid/mogakgo/domain/cert/exception/CertException.java new file mode 100644 index 00000000..bb437a9d --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/cert/exception/CertException.java @@ -0,0 +1,11 @@ +package io.oeid.mogakgo.domain.cert.exception; + +import io.oeid.mogakgo.exception.code.ErrorCode; +import io.oeid.mogakgo.exception.exception_class.CustomException; + +public class CertException extends CustomException { + + public CertException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/cert/presentation/CertController.java b/src/main/java/io/oeid/mogakgo/domain/cert/presentation/CertController.java new file mode 100644 index 00000000..768d008e --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/cert/presentation/CertController.java @@ -0,0 +1,30 @@ +package io.oeid.mogakgo.domain.cert.presentation; + +import io.oeid.mogakgo.common.annotation.UserId; +import io.oeid.mogakgo.common.swagger.template.CertSwagger; +import io.oeid.mogakgo.domain.cert.application.CertService; +import io.oeid.mogakgo.domain.cert.presentation.dto.req.UserRegionCertAPIReq; +import io.oeid.mogakgo.domain.cert.presentation.dto.res.UserRegionCertAPIRes; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/cert") +@RequiredArgsConstructor +public class CertController implements CertSwagger { + + private final CertService certService; + + @PatchMapping("/certificate") + public ResponseEntity certificateNeighborhood( + @UserId Long userId, @Valid @RequestBody UserRegionCertAPIReq request + ) { + Long id = certService.certificate(userId, request.getUserId(), request.getAreaCode()); + return ResponseEntity.ok(UserRegionCertAPIRes.from(id)); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/cert/presentation/dto/req/UserRegionCertAPIReq.java b/src/main/java/io/oeid/mogakgo/domain/cert/presentation/dto/req/UserRegionCertAPIReq.java new file mode 100644 index 00000000..4c7178d2 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/cert/presentation/dto/req/UserRegionCertAPIReq.java @@ -0,0 +1,27 @@ +package io.oeid.mogakgo.domain.cert.presentation.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Schema(description = "사용자가 해당 코드에 해당하는 서비스 지역의 동네 인증을 요청") +@Getter +public class UserRegionCertAPIReq { + + @Schema(description = "동네 인증을 요청한 사용자 ID", example = "2", implementation = Long.class) + @NotNull + private final Long userId; + + @Schema(description = "동네 인증을 요청하는 서비스 지역의 법정구역코드", example = "11110", implementation = Integer.class) + @NotNull + private final Integer areaCode; + + private UserRegionCertAPIReq(Long userId, Integer areaCode) { + this.userId = userId; + this.areaCode = areaCode; + } + + public static UserRegionCertAPIReq of(Long userId, Integer areaCode) { + return new UserRegionCertAPIReq(userId, areaCode); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/cert/presentation/dto/res/UserRegionCertAPIRes.java b/src/main/java/io/oeid/mogakgo/domain/cert/presentation/dto/res/UserRegionCertAPIRes.java new file mode 100644 index 00000000..06960a6f --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/cert/presentation/dto/res/UserRegionCertAPIRes.java @@ -0,0 +1,21 @@ +package io.oeid.mogakgo.domain.cert.presentation.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "동네 인증 완료 응답. 인증을 수행한 사용자의 ID를 반환한다.") +@Getter +public class UserRegionCertAPIRes { + + @Schema(description = "동네 인증을 수행한 사용자 ID", example = "2", implementation = Long.class) + private final Long userId; + + private UserRegionCertAPIRes(Long userId) { + this.userId = userId; + } + + public static UserRegionCertAPIRes from(Long userId) { + return new UserRegionCertAPIRes(userId); + } + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/geo/application/GeoService.java b/src/main/java/io/oeid/mogakgo/domain/geo/application/GeoService.java index 0f56bea9..f019ebab 100644 --- a/src/main/java/io/oeid/mogakgo/domain/geo/application/GeoService.java +++ b/src/main/java/io/oeid/mogakgo/domain/geo/application/GeoService.java @@ -1,9 +1,15 @@ package io.oeid.mogakgo.domain.geo.application; +import static io.oeid.mogakgo.exception.code.ErrorCode400.INVALID_SERVICE_REGION; + import io.oeid.mogakgo.core.properties.KakaoProperties; +import io.oeid.mogakgo.domain.geo.domain.enums.Region; +import io.oeid.mogakgo.domain.geo.exception.GeoException; import io.oeid.mogakgo.domain.geo.feign.KakaoFeignClient; import io.oeid.mogakgo.domain.geo.feign.dto.AddressDocument; import io.oeid.mogakgo.domain.geo.feign.dto.AddressInfoDto; +import io.oeid.mogakgo.domain.user.application.UserCommonService; +import io.oeid.mogakgo.domain.user.domain.User; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,6 +20,13 @@ public class GeoService { private static final String SEPERATOR = " "; private final KakaoFeignClient kakaoFeignClient; private final KakaoProperties kakaoProperties; + private final UserCommonService userCommonService; + + public int getUserRegionInfoAboutCoordinates(Long tokenUserId, Double x, Double y) { + validateToken(tokenUserId); + validateCoordinates(x, y); + return validateCoordinatesCoverage(x, y); + } public int getAreaCodeAboutCoordinates(Double x, Double y) { AddressDocument document = getAddressInfoAboutAreaCode(x, y); @@ -26,6 +39,18 @@ public AddressDocument getAddressInfoAboutAreaCode(Double x, Double y) { return response.getDocuments()[0]; } + private int validateCoordinatesCoverage(Double x, Double y) { + int areaCode = getAreaCodeAboutCoordinates(x, y); + validateCodeCoverage(areaCode); + return areaCode; + } + + private void validateCodeCoverage(int areaCode) { + if (Region.getByAreaCode(areaCode) == null) { + throw new GeoException(INVALID_SERVICE_REGION); + } + } + private String generateKey(KakaoProperties kakaoProperties) { return kakaoProperties.getPrefix() + SEPERATOR + kakaoProperties.getRestApiKey(); } @@ -33,4 +58,15 @@ private String generateKey(KakaoProperties kakaoProperties) { private int extractAreaCode(AddressDocument document) { return Integer.parseInt(document.getCode().substring(0, 5)); } + + private User validateToken(Long userId) { + return userCommonService.getUserById(userId); + } + + private void validateCoordinates(Double x, Double y) { + if (x < 123.0 || x > 132.0 || y < 32.0 || y > 39.0) { + throw new GeoException(INVALID_SERVICE_REGION); + } + } + } diff --git a/src/main/java/io/oeid/mogakgo/domain/geo/exception/GeoException.java b/src/main/java/io/oeid/mogakgo/domain/geo/exception/GeoException.java new file mode 100644 index 00000000..666e15ca --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/geo/exception/GeoException.java @@ -0,0 +1,12 @@ +package io.oeid.mogakgo.domain.geo.exception; + +import io.oeid.mogakgo.exception.code.ErrorCode; +import io.oeid.mogakgo.exception.exception_class.CustomException; + +public class GeoException extends CustomException { + + public GeoException(ErrorCode errorCode) { + super(errorCode); + } + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/geo/presentation/GeoController.java b/src/main/java/io/oeid/mogakgo/domain/geo/presentation/GeoController.java new file mode 100644 index 00000000..33d9bc32 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/geo/presentation/GeoController.java @@ -0,0 +1,29 @@ +package io.oeid.mogakgo.domain.geo.presentation; + +import io.oeid.mogakgo.common.annotation.UserId; +import io.oeid.mogakgo.common.swagger.template.GeoSwagger; +import io.oeid.mogakgo.domain.geo.presentation.dto.res.UserRegionInfoAPIRes; +import io.oeid.mogakgo.domain.geo.application.GeoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/geo") +@RequiredArgsConstructor +public class GeoController implements GeoSwagger { + + private final GeoService geoService; + + @GetMapping("/areacode") + public ResponseEntity getUserRegionInfoByGPS( + @UserId Long userId, @RequestParam Double longitude, @RequestParam Double latitude + ) { + int areaCode = geoService.getUserRegionInfoAboutCoordinates(userId, longitude, latitude); + return ResponseEntity.ok(UserRegionInfoAPIRes.from(areaCode)); + } + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/geo/presentation/dto/res/UserRegionInfoAPIRes.java b/src/main/java/io/oeid/mogakgo/domain/geo/presentation/dto/res/UserRegionInfoAPIRes.java new file mode 100644 index 00000000..98d30f39 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/geo/presentation/dto/res/UserRegionInfoAPIRes.java @@ -0,0 +1,20 @@ +package io.oeid.mogakgo.domain.geo.presentation.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "사용자 GPS 정보에 대한 법정구역코드 응답. 사용자가 현재 위치한 법정구역코드를 반환한다.") +@Getter +public class UserRegionInfoAPIRes { + + @Schema(description = "사용자의 법정구역코드", example = "11110", implementation = Integer.class) + private final int areaCode; + + private UserRegionInfoAPIRes(int areaCode) { + this.areaCode = areaCode; + } + + public static UserRegionInfoAPIRes from(int areaCode) { + return new UserRegionInfoAPIRes(areaCode); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/application/FCMNotificationService.java b/src/main/java/io/oeid/mogakgo/domain/notification/application/FCMNotificationService.java new file mode 100644 index 00000000..ee429bb4 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/notification/application/FCMNotificationService.java @@ -0,0 +1,62 @@ +package io.oeid.mogakgo.domain.notification.application; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import io.oeid.mogakgo.domain.notification.domain.vo.FCMToken; +import io.oeid.mogakgo.domain.notification.exception.NotificationException; +import io.oeid.mogakgo.domain.notification.infrastructure.FCMTokenJpaRepository; +import io.oeid.mogakgo.domain.user.application.UserCommonService; +import io.oeid.mogakgo.exception.code.ErrorCode404; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FCMNotificationService { + + private final FCMTokenJpaRepository fcmTokenRepository; + private final UserCommonService userCommonService; + private final FirebaseMessaging firebaseMessaging; + + @Transactional + public void manageToken(Long userId, String fcmToken) { + log.info("manageToken Start"); + FCMToken token = fcmTokenRepository.findById(userCommonService.getUserById(userId).getId()) + .orElseGet(() -> new FCMToken(userId, fcmToken)); + token.updateToken(fcmToken); + fcmTokenRepository.save(token); + log.info("manageToken End"); + } + + public void sendNotification(Long userId, String title, String body) { + log.info("sendNotification Start"); + String fcmToken = getFCMToken(userId); + // send notification + Message message = Message.builder() + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .setToken(fcmToken) + .build(); + try { + String response = firebaseMessaging.send(message); + log.info("Successfully sent message: " + response); + } catch (FirebaseMessagingException e) { + log.error("Error sending message: " + e.getMessage()); + } + log.info("sendNotification End"); + } + + private String getFCMToken(Long userId) { + return fcmTokenRepository.findById(userId) + .map(FCMToken::getToken) + .orElseThrow( + () -> new NotificationException(ErrorCode404.NOTIFICATION_FCM_TOKEN_NOT_FOUND)); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/application/NotificationService.java b/src/main/java/io/oeid/mogakgo/domain/notification/application/NotificationService.java new file mode 100644 index 00000000..7f3a7338 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/notification/application/NotificationService.java @@ -0,0 +1,30 @@ +package io.oeid.mogakgo.domain.notification.application; + +import io.oeid.mogakgo.domain.notification.application.dto.req.NotificationCreateRequest; +import io.oeid.mogakgo.domain.notification.application.dto.res.NotificationCreateResponse; +import io.oeid.mogakgo.domain.notification.domain.Notification; +import io.oeid.mogakgo.domain.notification.infrastructure.NotificationJpaRepository; +import io.oeid.mogakgo.domain.user.application.UserCommonService; +import io.oeid.mogakgo.domain.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final UserCommonService userCommonService; + private final NotificationJpaRepository notificationRepository; + + @Transactional + public NotificationCreateResponse createNotification(NotificationCreateRequest request) { + log.info("createNotification request: {}", request); + User user = userCommonService.getUserById(request.getUserId()); + Notification notification = notificationRepository.save(request.toEntity(user)); + return NotificationCreateResponse.from(notification); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/application/dto/req/NotificationCreateRequest.java b/src/main/java/io/oeid/mogakgo/domain/notification/application/dto/req/NotificationCreateRequest.java new file mode 100644 index 00000000..6992d4d2 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/notification/application/dto/req/NotificationCreateRequest.java @@ -0,0 +1,26 @@ +package io.oeid.mogakgo.domain.notification.application.dto.req; + +import io.oeid.mogakgo.domain.notification.domain.Notification; +import io.oeid.mogakgo.domain.notification.domain.enums.NotificationTag; +import io.oeid.mogakgo.domain.user.domain.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class NotificationCreateRequest { + + private final Long userId; + private final NotificationTag notificationTag; + private final String detailData; + + public static NotificationCreateRequest of(Long userId, NotificationTag notificationTag, + String detailData) { + return new NotificationCreateRequest(userId, notificationTag, detailData); + } + + public Notification toEntity(User user) { + return Notification.of(user, notificationTag, detailData); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/application/dto/res/NotificationCreateResponse.java b/src/main/java/io/oeid/mogakgo/domain/notification/application/dto/res/NotificationCreateResponse.java new file mode 100644 index 00000000..c5b597ae --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/notification/application/dto/res/NotificationCreateResponse.java @@ -0,0 +1,30 @@ +package io.oeid.mogakgo.domain.notification.application.dto.res; + +import io.oeid.mogakgo.domain.notification.domain.Notification; +import io.oeid.mogakgo.domain.notification.domain.enums.NotificationTag; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class NotificationCreateResponse { + + private final Long id; + private final Long userId; + private final NotificationTag notificationTag; + private final String detailData; + private final LocalDateTime createdAt; + private final Boolean checkedYn; + + public static NotificationCreateResponse from(Notification notification) { + return new NotificationCreateResponse( + notification.getId(), + notification.getUser().getId(), + notification.getNotificationTag(), + notification.getDetailData(), + notification.getCreatedAt(), + notification.getCheckedYn()); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/domain/Notification.java b/src/main/java/io/oeid/mogakgo/domain/notification/domain/Notification.java index 8df69683..5f39cd8c 100644 --- a/src/main/java/io/oeid/mogakgo/domain/notification/domain/Notification.java +++ b/src/main/java/io/oeid/mogakgo/domain/notification/domain/Notification.java @@ -43,6 +43,9 @@ public class Notification { @Column(name = "detail_data") private String detailData; + @Column(name = "checked_yn") + private Boolean checkedYn; + @CreationTimestamp @Column(name = "created_at") private LocalDateTime createdAt; @@ -51,6 +54,7 @@ private Notification(User user, NotificationTag notificationTag, String detailDa this.user = user; this.notificationTag = validateNotificationTag(notificationTag); this.detailData = validateDetailData(detailData); + this.checkedYn = false; } public static Notification of(User user, NotificationTag notificationTag, String detail) { diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/domain/vo/FCMToken.java b/src/main/java/io/oeid/mogakgo/domain/notification/domain/vo/FCMToken.java new file mode 100644 index 00000000..32b1dc54 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/notification/domain/vo/FCMToken.java @@ -0,0 +1,48 @@ +package io.oeid.mogakgo.domain.notification.domain.vo; + +import io.oeid.mogakgo.domain.notification.exception.NotificationException; +import io.oeid.mogakgo.exception.code.ErrorCode400; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Table(name = "fcm_token_tb") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class FCMToken { + + @Id + @Column(name = "id") + private Long userId; + + @Column(name = "token") + private String token; + + public FCMToken(Long userId, String token) { + this.userId = verifyUserId(userId); + this.token = verifyToken(token); + } + + public Long verifyUserId(Long userId) { + if (userId == null) { + throw new NotificationException(ErrorCode400.USER_ID_NOT_NULL); + } + return userId; + } + + public String verifyToken(String fcmToken) { + if (fcmToken == null || fcmToken.isBlank()) { + throw new NotificationException(ErrorCode400.NOTIFICATION_FCM_TOKEN_NOT_NULL); + } + return fcmToken; + } + + public void updateToken(String token) { + this.token = verifyToken(token); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/infrastructure/FCMTokenJpaRepository.java b/src/main/java/io/oeid/mogakgo/domain/notification/infrastructure/FCMTokenJpaRepository.java new file mode 100644 index 00000000..f9a9d995 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/notification/infrastructure/FCMTokenJpaRepository.java @@ -0,0 +1,10 @@ +package io.oeid.mogakgo.domain.notification.infrastructure; + +import io.oeid.mogakgo.domain.notification.domain.vo.FCMToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface FCMTokenJpaRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/infrastructure/NotificationJpaRepository.java b/src/main/java/io/oeid/mogakgo/domain/notification/infrastructure/NotificationJpaRepository.java index 7c3e3857..99d885e9 100644 --- a/src/main/java/io/oeid/mogakgo/domain/notification/infrastructure/NotificationJpaRepository.java +++ b/src/main/java/io/oeid/mogakgo/domain/notification/infrastructure/NotificationJpaRepository.java @@ -2,7 +2,9 @@ import io.oeid.mogakgo.domain.notification.domain.Notification; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository public interface NotificationJpaRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/presentation/NotificationController.java b/src/main/java/io/oeid/mogakgo/domain/notification/presentation/NotificationController.java new file mode 100644 index 00000000..b98098b6 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/notification/presentation/NotificationController.java @@ -0,0 +1,30 @@ +package io.oeid.mogakgo.domain.notification.presentation; + +import io.oeid.mogakgo.common.annotation.UserId; +import io.oeid.mogakgo.common.swagger.template.NotificationSwagger; +import io.oeid.mogakgo.domain.notification.application.FCMNotificationService; +import io.oeid.mogakgo.domain.notification.presentation.dto.req.FCMTokenApiRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/notification") +@RequiredArgsConstructor +public class NotificationController implements NotificationSwagger { + + private final FCMNotificationService fcmNotificationService; + + @PostMapping("/fcm") + public ResponseEntity manageFCMToken(@UserId Long userId, @RequestBody @Valid + FCMTokenApiRequest request) { + fcmNotificationService.manageToken(userId, request.getFcmToken()); + return ResponseEntity.ok().build(); + } + + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/notification/presentation/dto/req/FCMTokenApiRequest.java b/src/main/java/io/oeid/mogakgo/domain/notification/presentation/dto/req/FCMTokenApiRequest.java new file mode 100644 index 00000000..8c6983eb --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/notification/presentation/dto/req/FCMTokenApiRequest.java @@ -0,0 +1,17 @@ +package io.oeid.mogakgo.domain.notification.presentation.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Schema(description = "FCM 토큰 등록 요청") +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class FCMTokenApiRequest { + + @Schema(description = "FCM 토큰", example = "d4774fVVUuS3wdHHnGMYEj:APA91bFjQD6WXu8z5B0bliub661jwRshGvoCafMTYkm0cX9bZCbaUIa6ybycBT8WqkEN9j-qIYgFGB2zNnNosluquDhatUZmpbst87qo0oT8P2Id39xtWV0jhXpwSdIoLyZtdD0G9s5f", implementation = String.class, type = "string") + @NotBlank(message = "FCM 토큰은 필수입니다.") + private final String fcmToken; +} diff --git a/src/main/java/io/oeid/mogakgo/domain/profile/domain/entity/ProfileCard.java b/src/main/java/io/oeid/mogakgo/domain/profile/domain/entity/ProfileCard.java new file mode 100644 index 00000000..a987c3bd --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/profile/domain/entity/ProfileCard.java @@ -0,0 +1,49 @@ +package io.oeid.mogakgo.domain.profile.domain.entity; + +import io.oeid.mogakgo.domain.user.domain.User; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "profile_card_tb") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ProfileCard { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "profile_card_id") + private Long id; + + @OneToOne(cascade = CascadeType.ALL) + @JoinColumn(name = "user_id", updatable = false) + private User user; + + @Column(name = "total_like_amount", nullable = false) + private Long totalLikeAmount; + + @Builder + private ProfileCard(User user) { + this.user = user; + this.totalLikeAmount = 0L; + } + + public static ProfileCard from(User user) { + return ProfileCard.builder() + .user(user) + .build(); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/profile/domain/entity/ProfileCardLike.java b/src/main/java/io/oeid/mogakgo/domain/profile/domain/entity/ProfileCardLike.java new file mode 100644 index 00000000..9587096f --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/profile/domain/entity/ProfileCardLike.java @@ -0,0 +1,60 @@ +package io.oeid.mogakgo.domain.profile.domain.entity; + +import io.oeid.mogakgo.domain.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Entity +@Table(name = "profile_card_like_tb") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EntityListeners(AuditingEntityListener.class) +public class ProfileCardLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "profile_card_like_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", updatable = false) + private User sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", updatable = false) + private User receiver; + + @CreatedDate + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Builder + private ProfileCardLike(User sender, User receiver) { + this.sender = sender; + this.receiver = receiver; + } + + public static ProfileCardLike of(User sender, User receiver) { + return ProfileCardLike.builder() + .sender(sender) + .receiver(receiver) + .build(); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardJpaRepository.java b/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardJpaRepository.java new file mode 100644 index 00000000..bb45a235 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardJpaRepository.java @@ -0,0 +1,10 @@ +package io.oeid.mogakgo.domain.profile.infrastructure; + +import io.oeid.mogakgo.domain.profile.domain.entity.ProfileCard; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProfileCardJpaRepository extends JpaRepository, ProfileCardRepositoryCustom { + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardRepositoryCustom.java b/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardRepositoryCustom.java new file mode 100644 index 00000000..fdb6ba43 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardRepositoryCustom.java @@ -0,0 +1,13 @@ +package io.oeid.mogakgo.domain.profile.infrastructure; + +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; + +public interface ProfileCardRepositoryCustom { + + Slice findByCondition( + Long cursorId, Long userId, Region region, Pageable pageable + ); +} diff --git a/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardRepositoryCustomImpl.java b/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardRepositoryCustomImpl.java new file mode 100644 index 00000000..31bad7b1 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/profile/infrastructure/ProfileCardRepositoryCustomImpl.java @@ -0,0 +1,57 @@ +package io.oeid.mogakgo.domain.profile.infrastructure; + +import static io.oeid.mogakgo.domain.profile.domain.entity.QProfileCard.profileCard; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import io.oeid.mogakgo.domain.geo.domain.enums.Region; +import io.oeid.mogakgo.domain.profile.domain.entity.ProfileCard; +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 +@RequiredArgsConstructor +public class ProfileCardRepositoryCustomImpl implements ProfileCardRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findByCondition( + Long cursorId, Long userId, Region region, Pageable pageable + ) { + List result = jpaQueryFactory.selectFrom(profileCard) + .where( + cursorIdEq(cursorId), + userIdEq(userId), + regionEq(region) + ) + .limit(pageable.getPageSize() + 1) + .fetch(); + boolean hasNext = checkLastPage(result, pageable); + return new SliceImpl<>(result, pageable, hasNext); + } + + private BooleanExpression regionEq(Region region) { + return region != null ? profileCard.user.region.eq(region) : null; + } + + 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 profileCards, Pageable pageable) { + if (profileCards.size() > pageable.getPageSize()) { + profileCards.remove(pageable.getPageSize()); + return true; + } + return false; + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project/application/ProjectService.java b/src/main/java/io/oeid/mogakgo/domain/project/application/ProjectService.java index 15d400e5..34360ac7 100644 --- a/src/main/java/io/oeid/mogakgo/domain/project/application/ProjectService.java +++ b/src/main/java/io/oeid/mogakgo/domain/project/application/ProjectService.java @@ -2,15 +2,21 @@ import static io.oeid.mogakgo.exception.code.ErrorCode400.NOT_MATCH_MEET_LOCATION; import static io.oeid.mogakgo.exception.code.ErrorCode403.PROJECT_FORBIDDEN_OPERATION; +import static io.oeid.mogakgo.exception.code.ErrorCode403.PROJECT_JOIN_REQUEST_FORBIDDEN_OPERATION; import static io.oeid.mogakgo.exception.code.ErrorCode404.PROJECT_NOT_FOUND; import static io.oeid.mogakgo.exception.code.ErrorCode404.USER_NOT_FOUND; +import io.oeid.mogakgo.common.base.CursorPaginationInfoReq; +import io.oeid.mogakgo.common.base.CursorPaginationResult; import io.oeid.mogakgo.domain.geo.application.GeoService; import io.oeid.mogakgo.domain.geo.domain.enums.Region; import io.oeid.mogakgo.domain.project.domain.entity.Project; import io.oeid.mogakgo.domain.project.exception.ProjectException; import io.oeid.mogakgo.domain.project.infrastructure.ProjectJpaRepository; import io.oeid.mogakgo.domain.project.presentation.dto.req.ProjectCreateReq; +import io.oeid.mogakgo.domain.project_join_req.exception.ProjectJoinRequestException; +import io.oeid.mogakgo.domain.project_join_req.infrastruture.ProjectJoinRequestJpaRepository; +import io.oeid.mogakgo.domain.project_join_req.presentation.projectJoinRequestRes; import io.oeid.mogakgo.domain.user.domain.User; import io.oeid.mogakgo.domain.user.exception.UserException; import io.oeid.mogakgo.domain.user.infrastructure.UserJpaRepository; @@ -26,6 +32,7 @@ public class ProjectService { private final UserJpaRepository userJpaRepository; private final ProjectJpaRepository projectJpaRepository; private final GeoService geoService; + private final ProjectJoinRequestJpaRepository projectJoinRequestJpaRepository; @Transactional public Long create(Long userId, ProjectCreateReq request) { @@ -57,6 +64,42 @@ public void delete(Long userId, Long projectId) { project.delete(user); } + public void cancel(Long userId, Long projectId) { + // 유저 존재 여부 체크 + User user = getUser(userId); + + // 프로젝트 존재 여부 체크 + Project project = getProject(projectId); + + // 매칭이 되었거나, 매칭 준비중이지만 요청이 있을때는 잔디력 감소를 위한 변수 + boolean projectHasReq = projectJoinRequestJpaRepository.existsByProjectId(projectId); + + // 프로젝트 취소 + project.cancel(user, projectHasReq); + } + + public CursorPaginationResult getJoinRequest( + Long userId, Long projectId, CursorPaginationInfoReq pageable + ) { + // 유저 존재 여부 체크 + User user = getUser(userId); + + // 프로젝트 존재 여부 체크 + Project project = getProject(projectId); + + // 본인만 본인의 프로젝트 참가 요청을 조회할 수 있음 + // TODO : project exception 인지 project join request exception 인지 확인 + try { + project.validateCreator(user); + } catch (ProjectException e) { + throw new ProjectJoinRequestException(PROJECT_JOIN_REQUEST_FORBIDDEN_OPERATION); + } + + // 프로젝트 참가 요청 조회 + return projectJoinRequestJpaRepository.findByConditionWithPagination( + null, projectId, null, null); + } + private User getUser(Long userId) { return userJpaRepository.findById(userId) .orElseThrow(() -> new UserException(USER_NOT_FOUND)); diff --git a/src/main/java/io/oeid/mogakgo/domain/project/domain/entity/Project.java b/src/main/java/io/oeid/mogakgo/domain/project/domain/entity/Project.java index a3fa8bb2..69b626a3 100644 --- a/src/main/java/io/oeid/mogakgo/domain/project/domain/entity/Project.java +++ b/src/main/java/io/oeid/mogakgo/domain/project/domain/entity/Project.java @@ -80,16 +80,35 @@ private Project( } public void delete(User tokenUser) { - validateCreator(tokenUser); + validateAvailableDelete(tokenUser); super.delete(); } - private void validateCreator(User tokenUser) { + public void cancel(User tokenUser, boolean projectHasReq) { + validateAvailableCancel(tokenUser); + // 매칭이 되었거나, 매칭 준비중이지만 요청이 있을때는 잔디력 감소 + if (projectHasReq) { + this.creator.decreaseJandiRate(); + } + this.projectStatus = ProjectStatus.CANCELED; + } + + public void validateCreator(User tokenUser) { if (tokenUser == null || !this.creator.getId().equals(tokenUser.getId())) { throw new ProjectException(PROJECT_FORBIDDEN_OPERATION); } } + private void validateAvailableCancel(User tokenUser) { + validateCreator(tokenUser); + this.projectStatus.validateAvailableCancel(); + } + + private void validateAvailableDelete(User tokenUser) { + validateCreator(tokenUser); + this.projectStatus.validateAvailableDelete(); + } + private void addProjectTagsWithValidation(List projectTags) { projectTags.forEach(tag -> this.projectTags.add( ProjectTag.of(tag.getContent(), this)) diff --git a/src/main/java/io/oeid/mogakgo/domain/project/domain/entity/enums/ProjectStatus.java b/src/main/java/io/oeid/mogakgo/domain/project/domain/entity/enums/ProjectStatus.java index 14ee2c13..0269a14a 100644 --- a/src/main/java/io/oeid/mogakgo/domain/project/domain/entity/enums/ProjectStatus.java +++ b/src/main/java/io/oeid/mogakgo/domain/project/domain/entity/enums/ProjectStatus.java @@ -1,16 +1,33 @@ package io.oeid.mogakgo.domain.project.domain.entity.enums; +import static io.oeid.mogakgo.exception.code.ErrorCode400.PROJECT_CANCEL_NOT_ALLOWED; +import static io.oeid.mogakgo.exception.code.ErrorCode400.PROJECT_DELETION_NOT_ALLOWED; + +import io.oeid.mogakgo.domain.project.exception.ProjectException; import lombok.Getter; @Getter public enum ProjectStatus { PENDING("매칭 대기중"), MATCHED("매칭 완료됨"), - CANCELED("매칭 취소됨"); + CANCELED("매칭 취소됨"), + FINISHED("매칭 종료됨"); private final String description; ProjectStatus(String description) { this.description = description; } + + public void validateAvailableDelete() { + if (this == PENDING || this == MATCHED) { + throw new ProjectException(PROJECT_DELETION_NOT_ALLOWED); + } + } + + public void validateAvailableCancel() { + if (this == CANCELED || this == FINISHED) { + throw new ProjectException(PROJECT_CANCEL_NOT_ALLOWED); + } + } } diff --git a/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectJpaRepository.java b/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectJpaRepository.java index 283dad49..52b1d5b5 100644 --- a/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectJpaRepository.java +++ b/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectJpaRepository.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Repository; @Repository -public interface ProjectJpaRepository extends JpaRepository { +public interface ProjectJpaRepository extends JpaRepository, ProjectRepositoryCustom { @Query("select p from Project p where p.id = :id and p.deletedAt is null") @Override diff --git a/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectRepositoryCustom.java b/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectRepositoryCustom.java new file mode 100644 index 00000000..205cfc0e --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectRepositoryCustom.java @@ -0,0 +1,14 @@ +package io.oeid.mogakgo.domain.project.infrastructure; + +import io.oeid.mogakgo.domain.geo.domain.enums.Region; +import io.oeid.mogakgo.domain.project.domain.entity.Project; +import io.oeid.mogakgo.domain.project.domain.entity.enums.ProjectStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface ProjectRepositoryCustom { + + Slice findByCondition( + Long cursorId, Long userId, Region region, ProjectStatus projectStatus, Pageable pageable + ); +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectRepositoryCustomImpl.java b/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectRepositoryCustomImpl.java new file mode 100644 index 00000000..d33a7b93 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project/infrastructure/ProjectRepositoryCustomImpl.java @@ -0,0 +1,63 @@ +package io.oeid.mogakgo.domain.project.infrastructure; + +import static io.oeid.mogakgo.domain.project.domain.entity.QProject.project; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import io.oeid.mogakgo.domain.geo.domain.enums.Region; +import io.oeid.mogakgo.domain.project.domain.entity.Project; +import io.oeid.mogakgo.domain.project.domain.entity.enums.ProjectStatus; +import org.springframework.data.domain.Pageable; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProjectRepositoryCustomImpl implements ProjectRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Slice findByCondition( + Long cursorId, Long userId, Region region, ProjectStatus projectStatus, Pageable pageable + ) { + List result = jpaQueryFactory.selectFrom(project) + .where( + cursorIdEq(cursorId), + userIdEq(userId), + regionEq(region), + projectStatusEq(projectStatus) + ) + .limit(pageable.getPageSize() + 1) + .fetch(); + boolean hasNext = checkLastPage(result, pageable); + return new SliceImpl<>(result, pageable, hasNext); + } + + private BooleanExpression userIdEq(Long userId) { + return userId != null ? project.creator.id.eq(userId) : null; + } + + private BooleanExpression regionEq(Region region) { + return region != null ? project.creatorInfo.region.eq(region) : null; + } + + private BooleanExpression projectStatusEq(ProjectStatus projectStatus) { + return projectStatus != null ? project.projectStatus.eq(projectStatus) : null; + } + + private BooleanExpression cursorIdEq(Long cursorId) { + return cursorId != null ? project.id.gt(cursorId) : null; + } + + private boolean checkLastPage(List projects, Pageable pageable) { + if (projects.size() > pageable.getPageSize()) { + projects.remove(pageable.getPageSize()); + return true; + } + return false; + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project/presentation/ProjectController.java b/src/main/java/io/oeid/mogakgo/domain/project/presentation/ProjectController.java index e95ae1d6..e738ef22 100644 --- a/src/main/java/io/oeid/mogakgo/domain/project/presentation/ProjectController.java +++ b/src/main/java/io/oeid/mogakgo/domain/project/presentation/ProjectController.java @@ -1,14 +1,20 @@ package io.oeid.mogakgo.domain.project.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.ProjectSwagger; import io.oeid.mogakgo.domain.project.application.ProjectService; 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; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -22,7 +28,6 @@ public class ProjectController implements ProjectSwagger { private final ProjectService projectService; - @Override @PostMapping public ResponseEntity create( @UserId Long userId, @Valid @RequestBody ProjectCreateReq request @@ -39,4 +44,20 @@ public ResponseEntity delete( return ResponseEntity.noContent().build(); } + @PatchMapping("/{id}/cancel") + public ResponseEntity cancel( + @UserId Long userId, @PathVariable Long id + ) { + projectService.cancel(userId, id); + return ResponseEntity.status(200).body(ProjectIdRes.from(id)); + } + + @GetMapping("/{id}/requests") + public ResponseEntity> getJoinRequest( + @UserId Long userId, @PathVariable Long id, + @Valid @ModelAttribute CursorPaginationInfoReq pageable + ) { + return ResponseEntity.ok().body(projectService.getJoinRequest(userId, id, pageable)); + } + } diff --git a/src/main/java/io/oeid/mogakgo/domain/project/presentation/dto/req/ProjectCreateReq.java b/src/main/java/io/oeid/mogakgo/domain/project/presentation/dto/req/ProjectCreateReq.java index 82d3c46e..6c42f08e 100644 --- a/src/main/java/io/oeid/mogakgo/domain/project/presentation/dto/req/ProjectCreateReq.java +++ b/src/main/java/io/oeid/mogakgo/domain/project/presentation/dto/req/ProjectCreateReq.java @@ -10,7 +10,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.PastOrPresent; import java.time.LocalDateTime; import java.util.List; import lombok.Getter; @@ -53,7 +52,7 @@ public class ProjectCreateReq { @NotBlank private String meetDetail; - @Schema(description = "프로젝트 태그 목록", implementation = ProjectTagCreateReq.class, + @Schema(description = "프로젝트 태그 목록", example = "[{\"content\":\"인싸\"},{\"content\":\"말많은\"}]") @NotEmpty private List<@Valid ProjectTagCreateReq> tags; diff --git a/src/main/java/io/oeid/mogakgo/domain/project_join_req/domain/entity/ProjectJoinRequest.java b/src/main/java/io/oeid/mogakgo/domain/project_join_req/domain/entity/ProjectJoinRequest.java new file mode 100644 index 00000000..447f9ac2 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project_join_req/domain/entity/ProjectJoinRequest.java @@ -0,0 +1,52 @@ +package io.oeid.mogakgo.domain.project_join_req.domain.entity; + +import io.oeid.mogakgo.domain.project.domain.entity.Project; +import io.oeid.mogakgo.domain.project_join_req.domain.entity.enums.RequestStatus; +import io.oeid.mogakgo.domain.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Entity +@Table(name = "project_join_request_tb") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class ProjectJoinRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", updatable = false) + private User sender; + + @Enumerated(EnumType.STRING) + @Column(name = "join_request_status") + private RequestStatus requestStatus; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", updatable = false) + private Project project; + + @CreatedDate + @Column(name = "created_at") + private LocalDateTime createdAt; +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project_join_req/domain/entity/enums/RequestStatus.java b/src/main/java/io/oeid/mogakgo/domain/project_join_req/domain/entity/enums/RequestStatus.java new file mode 100644 index 00000000..7834a05e --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project_join_req/domain/entity/enums/RequestStatus.java @@ -0,0 +1,14 @@ +package io.oeid.mogakgo.domain.project_join_req.domain.entity.enums; + +public enum RequestStatus { + PENDING("요청 대기중"), + ACCEPTED("요청 수락됨"), + REJECTED("요청 거절됨"); + + private final String description; + + RequestStatus(String description) { + this.description = description; + } + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project_join_req/exception/ProjectJoinRequestException.java b/src/main/java/io/oeid/mogakgo/domain/project_join_req/exception/ProjectJoinRequestException.java new file mode 100644 index 00000000..1455d2ba --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project_join_req/exception/ProjectJoinRequestException.java @@ -0,0 +1,11 @@ +package io.oeid.mogakgo.domain.project_join_req.exception; + +import io.oeid.mogakgo.exception.code.ErrorCode; +import io.oeid.mogakgo.exception.exception_class.CustomException; + +public class ProjectJoinRequestException extends CustomException { + + public ProjectJoinRequestException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestJpaRepository.java b/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestJpaRepository.java new file mode 100644 index 00000000..0756fcaa --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestJpaRepository.java @@ -0,0 +1,11 @@ +package io.oeid.mogakgo.domain.project_join_req.infrastruture; + +import io.oeid.mogakgo.domain.project_join_req.domain.entity.ProjectJoinRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProjectJoinRequestJpaRepository extends JpaRepository, + ProjectJoinRequestRepositoryCustom { + + boolean existsByProjectId(Long projectId); + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestRepositoryCustom.java b/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestRepositoryCustom.java new file mode 100644 index 00000000..73e8e3ab --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestRepositoryCustom.java @@ -0,0 +1,14 @@ +package io.oeid.mogakgo.domain.project_join_req.infrastruture; + +import io.oeid.mogakgo.common.base.CursorPaginationInfoReq; +import io.oeid.mogakgo.common.base.CursorPaginationResult; +import io.oeid.mogakgo.domain.project_join_req.domain.entity.enums.RequestStatus; +import io.oeid.mogakgo.domain.project_join_req.presentation.projectJoinRequestRes; + +public interface ProjectJoinRequestRepositoryCustom { + + CursorPaginationResult findByConditionWithPagination( + Long senderId, Long projectId, RequestStatus requestStatus, CursorPaginationInfoReq pageable + ); + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestRepositoryCustomImpl.java b/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestRepositoryCustomImpl.java new file mode 100644 index 00000000..73020a73 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project_join_req/infrastruture/ProjectJoinRequestRepositoryCustomImpl.java @@ -0,0 +1,71 @@ +package io.oeid.mogakgo.domain.project_join_req.infrastruture; + +import static io.oeid.mogakgo.domain.project_join_req.domain.entity.QProjectJoinRequest.projectJoinRequest; + +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.project_join_req.domain.entity.enums.RequestStatus; +import io.oeid.mogakgo.domain.project_join_req.presentation.projectJoinRequestRes; +import io.oeid.mogakgo.domain.user.presentation.dto.res.UserPreviewRes; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProjectJoinRequestRepositoryCustomImpl implements ProjectJoinRequestRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public CursorPaginationResult findByConditionWithPagination( + Long senderId, Long projectId, RequestStatus requestStatus, CursorPaginationInfoReq pageable + ) { + List result = jpaQueryFactory.select( + Projections.constructor( + projectJoinRequestRes.class, + projectJoinRequest.id, + Projections.constructor( + UserPreviewRes.class, + projectJoinRequest.sender.id, + projectJoinRequest.sender.username, + projectJoinRequest.sender.avatarUrl + ), + projectJoinRequest.requestStatus + ) + ) + .from(projectJoinRequest) + .join(projectJoinRequest.sender) + .where( + cursorIdCondition(pageable.getCursorId()), + senderIdEq(senderId), + projectIdEq(projectId), + requestStatusEq(requestStatus) + ) + .limit(pageable.getPageSize() + 1) + .fetch(); + + return CursorPaginationResult.fromDataWithExtraItemForNextCheck(result, + pageable.getPageSize()); + } + + private BooleanExpression senderIdEq(Long senderId) { + return senderId != null ? projectJoinRequest.sender.id.eq(senderId) : null; + } + + private BooleanExpression projectIdEq(Long projectId) { + return projectId != null ? projectJoinRequest.project.id.eq(projectId) : null; + } + + private BooleanExpression requestStatusEq(RequestStatus requestStatus) { + return requestStatus != null ? projectJoinRequest.requestStatus.eq(requestStatus) : null; + } + + private BooleanExpression cursorIdCondition(Long cursorId) { + return cursorId != null ? projectJoinRequest.id.gt(cursorId) : null; + } + +} diff --git a/src/main/java/io/oeid/mogakgo/domain/project_join_req/presentation/projectJoinRequestRes.java b/src/main/java/io/oeid/mogakgo/domain/project_join_req/presentation/projectJoinRequestRes.java new file mode 100644 index 00000000..d3ece9fb --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/project_join_req/presentation/projectJoinRequestRes.java @@ -0,0 +1,26 @@ +package io.oeid.mogakgo.domain.project_join_req.presentation; + +import io.oeid.mogakgo.domain.project_join_req.domain.entity.enums.RequestStatus; +import io.oeid.mogakgo.domain.user.presentation.dto.res.UserPreviewRes; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "프로젝트 요청 응답 DTO") +@Getter +public class projectJoinRequestRes { + + @Schema(description = "프로젝트 요청 ID") + private Long id; + @Schema(description = "요청자 정보 미리보기") + private UserPreviewRes senderPreview; + @Schema(description = "요청 상태") + private RequestStatus requestStatus; + + public projectJoinRequestRes( + Long id, UserPreviewRes senderPreview, RequestStatus requestStatus + ) { + this.id = id; + this.senderPreview = senderPreview; + this.requestStatus = requestStatus; + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/user/application/UserCommonService.java b/src/main/java/io/oeid/mogakgo/domain/user/application/UserCommonService.java new file mode 100644 index 00000000..d905d469 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/user/application/UserCommonService.java @@ -0,0 +1,22 @@ +package io.oeid.mogakgo.domain.user.application; + +import io.oeid.mogakgo.domain.user.domain.User; +import io.oeid.mogakgo.domain.user.exception.UserException; +import io.oeid.mogakgo.domain.user.infrastructure.UserJpaRepository; +import io.oeid.mogakgo.exception.code.ErrorCode404; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserCommonService { + + private final UserJpaRepository userRepository; + + public User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UserException(ErrorCode404.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/user/application/UserGeoService.java b/src/main/java/io/oeid/mogakgo/domain/user/application/UserGeoService.java new file mode 100644 index 00000000..953b194a --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/user/application/UserGeoService.java @@ -0,0 +1,27 @@ +package io.oeid.mogakgo.domain.user.application; + +import io.oeid.mogakgo.domain.geo.domain.enums.Region; +import io.oeid.mogakgo.domain.user.application.dto.res.UserGeoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserGeoService { + + private final UserCommonService userCommonService; + + public UserGeoResponse getUserGeo(Long userId) { + var user = userCommonService.getUserById(userId); + return new UserGeoResponse(user.getRegion(), user.getRegionAuthenticationAt()); + } + + @Transactional + public Long updateUserGeo(Long userId, Region region) { + var user = userCommonService.getUserById(userId); + user.updateRegion(region); + return userId; + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/user/application/UserService.java b/src/main/java/io/oeid/mogakgo/domain/user/application/UserService.java index 8f53e021..1ffc56a6 100644 --- a/src/main/java/io/oeid/mogakgo/domain/user/application/UserService.java +++ b/src/main/java/io/oeid/mogakgo/domain/user/application/UserService.java @@ -1,28 +1,27 @@ package io.oeid.mogakgo.domain.user.application; +import io.oeid.mogakgo.domain.user.application.dto.req.UserSignUpRequest; +import io.oeid.mogakgo.domain.user.application.dto.res.UserProfileResponse; +import io.oeid.mogakgo.domain.user.application.dto.res.UserSignUpResponse; import io.oeid.mogakgo.domain.user.domain.User; import io.oeid.mogakgo.domain.user.domain.UserWantedJobTag; import io.oeid.mogakgo.domain.user.domain.enums.WantedJob; -import io.oeid.mogakgo.domain.user.exception.UserException; -import io.oeid.mogakgo.domain.user.infrastructure.UserJpaRepository; import io.oeid.mogakgo.domain.user.infrastructure.UserWantedJobTagJpaRepository; -import io.oeid.mogakgo.domain.user.application.dto.req.UserSignUpRequest; -import io.oeid.mogakgo.domain.user.application.dto.res.UserSignUpResponse; -import io.oeid.mogakgo.exception.code.ErrorCode404; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor -@Transactional +@Transactional(readOnly = true) public class UserService { - private final UserJpaRepository userRepository; + private final UserCommonService userCommonService; private final UserWantedJobTagJpaRepository userWantedJobTagRepository; + @Transactional public UserSignUpResponse userSignUp(UserSignUpRequest userSignUpRequest) { - User user = getUserById(userSignUpRequest.getUserId()); + User user = userCommonService.getUserById(userSignUpRequest.getUserId()); user.updateUsername(userSignUpRequest.getUsername()); for (WantedJob wantedJob : userSignUpRequest.getWantedJobs()) { userWantedJobTagRepository.save(UserWantedJobTag.builder() @@ -30,16 +29,19 @@ public UserSignUpResponse userSignUp(UserSignUpRequest userSignUpRequest) { .wantedJob(wantedJob) .build()); } + user.signUpComplete(); return UserSignUpResponse.from(user); } + public UserProfileResponse getUserProfile(Long userId){ + User user = userCommonService.getUserById(userId); + return UserProfileResponse.from(user); + } + + @Transactional public void deleteUser(Long userId) { - User user = getUserById(userId); + User user = userCommonService.getUserById(userId); user.delete(); } - private User getUserById(Long userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new UserException(ErrorCode404.USER_NOT_FOUND)); - } } diff --git a/src/main/java/io/oeid/mogakgo/domain/user/application/dto/res/UserGeoResponse.java b/src/main/java/io/oeid/mogakgo/domain/user/application/dto/res/UserGeoResponse.java new file mode 100644 index 00000000..25d04f38 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/user/application/dto/res/UserGeoResponse.java @@ -0,0 +1,13 @@ +package io.oeid.mogakgo.domain.user.application.dto.res; + +import io.oeid.mogakgo.domain.geo.domain.enums.Region; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserGeoResponse { + private Region region; + private LocalDateTime regionAuthenticationAt; +} diff --git a/src/main/java/io/oeid/mogakgo/domain/user/application/dto/res/UserProfileResponse.java b/src/main/java/io/oeid/mogakgo/domain/user/application/dto/res/UserProfileResponse.java new file mode 100644 index 00000000..b533a790 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/user/application/dto/res/UserProfileResponse.java @@ -0,0 +1,44 @@ +package io.oeid.mogakgo.domain.user.application.dto.res; + +import io.oeid.mogakgo.domain.user.domain.User; +import io.oeid.mogakgo.domain.user.domain.UserDevelopLanguageTag; +import io.oeid.mogakgo.domain.user.domain.UserWantedJobTag; +import io.oeid.mogakgo.domain.user.domain.enums.DevelopLanguage; +import io.oeid.mogakgo.domain.user.domain.enums.WantedJob; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class UserProfileResponse { + + private final long id; + private final String username; + private final String githubId; + private final String avatarUrl; + private final String githubUrl; + private final String bio; + private final double jandiRate; + private final String achievementTitle; + private final List developLanguages; + private final List wantedJobs; + + public static UserProfileResponse from(User user) { + return new UserProfileResponse( + user.getId(), + user.getUsername(), + user.getGithubId(), + user.getAvatarUrl(), + user.getGithubUrl(), + user.getBio(), + user.getJandiRate(), + user.getAchievement().getTitle(), + user.getUserDevelopLanguageTags().stream().map( + UserDevelopLanguageTag::getDevelopLanguage).toList(), + user.getUserWantedJobTags().stream().map( + UserWantedJobTag::getWantedJob).toList() + ); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/user/domain/User.java b/src/main/java/io/oeid/mogakgo/domain/user/domain/User.java index 7623696f..a3884882 100644 --- a/src/main/java/io/oeid/mogakgo/domain/user/domain/User.java +++ b/src/main/java/io/oeid/mogakgo/domain/user/domain/User.java @@ -1,5 +1,6 @@ package io.oeid.mogakgo.domain.user.domain; +import io.oeid.mogakgo.domain.achievement.domain.Achievement; import io.oeid.mogakgo.domain.geo.domain.enums.Region; import io.oeid.mogakgo.domain.user.domain.enums.Role; import io.oeid.mogakgo.domain.user.exception.UserException; @@ -13,6 +14,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; @@ -57,6 +60,8 @@ public class User { @Column(name = "github_url") private String githubUrl; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @OrderBy("byteSize ASC") private final List userDevelopLanguageTags = new ArrayList<>(); @@ -94,6 +99,12 @@ public class User { @Column(name = "available_like_count") private int availableLikeCount; + @Column(name = "signup_yn") + private Boolean signupYn; + + @ManyToOne + @JoinColumn(name = "achievement_id") + private Achievement achievement; private User(Long githubPk, String githubId, String avatarUrl, String githubUrl) { this.githubPk = githubPk; @@ -103,6 +114,7 @@ private User(Long githubPk, String githubId, String avatarUrl, String githubUrl) this.githubUrl = githubUrl; this.role = Role.ROLE_USER; this.jandiRate = 0d; + this.signupYn = false; } public static User of(long githubPk, String username, String avatarUrl, String githubUrl) { @@ -144,4 +156,20 @@ public void delete(){ this.deletedAt = LocalDateTime.now(); } + public void signUpComplete(){ + this.signupYn = true; + } + + public void updateRegion(Region region) { + if(region == null){ + throw new UserException(ErrorCode400.USER_REGION_SHOULD_BE_NOT_EMPTY); + } + this.region = region; + this.regionAuthenticationAt = LocalDateTime.now(); + } + + //TODO : 추후 구현 필요 + public void decreaseJandiRate() { + return; + } } diff --git a/src/main/java/io/oeid/mogakgo/domain/user/presentation/UserController.java b/src/main/java/io/oeid/mogakgo/domain/user/presentation/UserController.java index 1349c30d..95d41def 100644 --- a/src/main/java/io/oeid/mogakgo/domain/user/presentation/UserController.java +++ b/src/main/java/io/oeid/mogakgo/domain/user/presentation/UserController.java @@ -1,6 +1,7 @@ package io.oeid.mogakgo.domain.user.presentation; import io.oeid.mogakgo.common.annotation.UserId; +import io.oeid.mogakgo.common.swagger.template.UserSwagger; import io.oeid.mogakgo.domain.user.application.UserService; import io.oeid.mogakgo.domain.user.presentation.dto.req.UserSignUpApiRequest; import io.oeid.mogakgo.domain.user.presentation.dto.res.UserSignUpApiResponse; @@ -17,7 +18,7 @@ @RestController @RequestMapping("/api/v1/user") @RequiredArgsConstructor -public class UserController { +public class UserController implements UserSwagger { private final UserService userService; diff --git a/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/req/UserSignUpApiRequest.java b/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/req/UserSignUpApiRequest.java index 6f0bf5db..46e43728 100644 --- a/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/req/UserSignUpApiRequest.java +++ b/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/req/UserSignUpApiRequest.java @@ -2,6 +2,7 @@ import io.oeid.mogakgo.domain.user.application.dto.req.UserSignUpRequest; import io.oeid.mogakgo.domain.user.domain.enums.WantedJob; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.util.List; @@ -10,14 +11,17 @@ import lombok.Getter; import lombok.NoArgsConstructor; +@Schema(description = "회원 가입 요청") @Getter @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PRIVATE) public class UserSignUpApiRequest { + @Schema(description = "회원명", example = "거루", implementation = String.class) @NotBlank(message = "username은 필수입니다.") private String username; + @Schema(description = "원하는 직군", example = "[\"BACKEND\", \"FRONTEND\"]", implementation = List.class, minLength = 1, maxLength = 3) @Size(min = 1, max = 3, message = "wantedJobs는 1개 이상 3개 이하로 선택해야 합니다.") private List wantedJobs; diff --git a/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/res/UserPreviewRes.java b/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/res/UserPreviewRes.java new file mode 100644 index 00000000..95c6ffe4 --- /dev/null +++ b/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/res/UserPreviewRes.java @@ -0,0 +1,21 @@ +package io.oeid.mogakgo.domain.user.presentation.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "유저 정보 미리보기 응답 DTO") +public class UserPreviewRes { + + private Long id; + private String username; + private String avatarUrl; + + public UserPreviewRes(Long id, String username, String avatarUrl) { + this.id = id; + this.username = username; + this.avatarUrl = avatarUrl; + } + + public static UserPreviewRes of(Long id, String username, String avatarUrl) { + return new UserPreviewRes(id, username, avatarUrl); + } +} diff --git a/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/res/UserSignUpApiResponse.java b/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/res/UserSignUpApiResponse.java index d2c2b52a..11c589f5 100644 --- a/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/res/UserSignUpApiResponse.java +++ b/src/main/java/io/oeid/mogakgo/domain/user/presentation/dto/res/UserSignUpApiResponse.java @@ -1,21 +1,30 @@ package io.oeid.mogakgo.domain.user.presentation.dto.res; import io.oeid.mogakgo.domain.user.application.dto.res.UserSignUpResponse; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; +@Schema(description = "회원 가입 응답") @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public class UserSignUpApiResponse { + @Schema(description = "회원 ID", example = "1") private Long userId; + @Schema(description = "회원 이름", example = "거루") private String username; + @Schema(description = "깃허브 ID", example = "tidavid1") private String githubId; + @Schema(description = "회원 프로필 이미지 URL", example = "https://avatars.githubusercontent.com/u/85854384?v=4") private String avatarUrl; + @Schema(description = "깃허브 URL", example = "https://github.com/tidavid1") private String githubUrl; + @Schema(description = "개발 언어", example = "[\"JAVA\", \"KOTLIN\"]") private List developLanguages; + @Schema(description = "희망 직무", example = "[\"BACKEND\", \"FRONTEND\"]") private List wantedJobs; public static UserSignUpApiResponse from(UserSignUpResponse response) { diff --git a/src/main/java/io/oeid/mogakgo/exception/GlobalExceptionHandler.java b/src/main/java/io/oeid/mogakgo/exception/GlobalExceptionHandler.java index c551779a..2f3f6fc0 100644 --- a/src/main/java/io/oeid/mogakgo/exception/GlobalExceptionHandler.java +++ b/src/main/java/io/oeid/mogakgo/exception/GlobalExceptionHandler.java @@ -2,13 +2,20 @@ import static io.oeid.mogakgo.exception.code.ErrorCode400.INVALID_INPUT_VALUE; import static io.oeid.mogakgo.exception.code.ErrorCode400.PATH_PARAMETER_BAD_REQUEST; +import static io.oeid.mogakgo.exception.code.ErrorCode401.AUTH_MISSING_CREDENTIALS; +import static io.oeid.mogakgo.exception.code.ErrorCode401.AUTH_TOKEN_EXPIRED; +import static io.oeid.mogakgo.exception.code.ErrorCode403.AUTH_ACCESS_DENIED; import static io.oeid.mogakgo.exception.code.ErrorCode500.INTERNAL_SERVER_ERROR; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.exceptions.TokenExpiredException; import io.oeid.mogakgo.exception.dto.ErrorResponse; import io.oeid.mogakgo.exception.exception_class.CustomException; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; @@ -65,6 +72,30 @@ public ResponseEntity missingPathVariableException( return ErrorResponse.from(PATH_PARAMETER_BAD_REQUEST); } + @ExceptionHandler(value = {TokenExpiredException.class}) + protected ResponseEntity handleTokenExpiredException( + TokenExpiredException e, HttpServletRequest request + ) { + log.warn("Token Expired: {}", e.getMessage()); + return ErrorResponse.from(AUTH_TOKEN_EXPIRED); + } + + @ExceptionHandler(value = {AuthenticationException.class, JWTVerificationException.class}) + protected ResponseEntity handleAuthenticationException( + AuthenticationException e, HttpServletRequest request + ) { + log.warn("Authentication Exception: {}", e.getMessage()); + return ErrorResponse.from(AUTH_MISSING_CREDENTIALS); + } + + @ExceptionHandler(value = {AccessDeniedException.class}) + protected ResponseEntity handleAccessDeniedException( + AccessDeniedException e, HttpServletRequest request + ) { + log.warn("Access Denied: {}", e.getMessage()); + return ErrorResponse.from(AUTH_ACCESS_DENIED); + } + @ExceptionHandler(value = Exception.class) protected ResponseEntity handleException( Exception e, HttpServletRequest request diff --git a/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode400.java b/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode400.java index d02daa89..7eeb9f2b 100644 --- a/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode400.java +++ b/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode400.java @@ -10,6 +10,7 @@ public enum ErrorCode400 implements ErrorCode { NOTIFICATION_TAG_NOT_NULL("E060001", "알림 태그는 필수값입니다."), NOTIFICATION_DETAIL_DATA_NOT_NULL("E060002", "알림 상세 데이터는 필수값입니다."), + NOTIFICATION_FCM_TOKEN_NOT_NULL("E060003", "FCM 토큰은 필수값입니다."), INVALID_PROJECT_MEETING_TIME("E030101", "프로젝트 만남 시간이 유효하지 않습니다."), INVALID_PROJECT_TAG_COUNT("E030102", "프로젝트 태그 갯수가 유효하지 않습니다. 1개 이상 3개 이하로 입력해야 합니다."), @@ -17,9 +18,14 @@ public enum ErrorCode400 implements ErrorCode { "프로젝트 태그 내용 길이가 유효하지 않습니다. 1자 이상 7자 이하로 입력해야 합니다."), INVALID_PROJECT_NULL_DATA("E030104", "프로젝트를 생성하기 위해 null 이여서는 안되는 데이터가 null 입니다."), NOT_MATCH_MEET_LOCATION("E030105", "프로젝트 만남 장소가 유저가 동네인증 한 구역이 아닙니다."), + PROJECT_DELETION_NOT_ALLOWED("E030106", "매칭 중이거나 대기중인 프로젝트는 삭제할 수 없습니다."), + PROJECT_CANCEL_NOT_ALLOWED("E030107", "이미 취소 되었거나 종료된 프로젝트는 취소할 수 없습니다."), + INVALID_SERVICE_REGION("E080101", "해당 지역은 서비스 지역이 아닙니다."), USER_DEVELOP_LANGUAGE_BAD_REQUEST("E020101", "개발 언어는 3개까지만 등록 가능합니다."), USER_WANTED_JOB_BAD_REQUEST("E020102", "희망 직무는 3개까지만 등록 가능합니다."), USERNAME_SHOULD_BE_NOT_EMPTY("E020103", "유저 이름은 비어있을 수 없습니다."), + USER_REGION_SHOULD_BE_NOT_EMPTY("E020104", "유저 지역은 비어있을 수 없습니다."), + USER_ID_NOT_NULL("E020001", "유저 아이디는 필수값입니다."), ; private final HttpStatus httpStatus = HttpStatus.BAD_REQUEST; diff --git a/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode401.java b/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode401.java index d4f46589..3bb563e6 100644 --- a/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode401.java +++ b/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode401.java @@ -7,6 +7,7 @@ public enum ErrorCode401 implements ErrorCode{ AUTH_MISSING_CREDENTIALS("E010201", "사용자의 인증 정보를 찾을 수 없습니다."), + AUTH_TOKEN_EXPIRED("E010202", "토큰이 만료되었습니다."), ; private final HttpStatus httpStatus = HttpStatus.UNAUTHORIZED; diff --git a/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode403.java b/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode403.java index 5f9e9021..1deaa9b9 100644 --- a/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode403.java +++ b/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode403.java @@ -5,7 +5,10 @@ @Getter public enum ErrorCode403 implements ErrorCode { + AUTH_ACCESS_DENIED("E010203", "접근 권한이 없습니다."), PROJECT_FORBIDDEN_OPERATION("E030201", "해당 프로젝트에 대한 권한이 없습니다."), + PROJECT_JOIN_REQUEST_FORBIDDEN_OPERATION("E050201", "해당 프로젝트 요청에 대한 권한이 없습니다."), + CERT_INVALID_INFORMATION("E070201", "동네 인증을 수행할 권한이 없습니다."), ; private final HttpStatus httpStatus = HttpStatus.FORBIDDEN; diff --git a/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode404.java b/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode404.java index b856cc2d..0cb1db46 100644 --- a/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode404.java +++ b/src/main/java/io/oeid/mogakgo/exception/code/ErrorCode404.java @@ -7,6 +7,7 @@ public enum ErrorCode404 implements ErrorCode { USER_NOT_FOUND("E020301", "해당 유저가 존재하지 않습니다."), PROJECT_NOT_FOUND("E030301", "해당 프로젝트가 존재하지 않습니다."), + NOTIFICATION_FCM_TOKEN_NOT_FOUND("E060301", "해당 유저의 FCM 토큰이 존재하지 않습니다."), ; private final HttpStatus httpStatus = HttpStatus.NOT_FOUND;