diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0bcab58 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# 빌드 아웃풋/임시파일/불필요한 것들 무시 +*.class +*.jar +*.war +*.log +*.iml +*.swp +*.swo +*~ +.DS_Store + +# IDE/빌드 시스템 폴더 +.gradle/ +.idea/ +.vscode/ +build/ +out/ +target/ + +# Git +.git +.gitignore + +do \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..d246aeb --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,61 @@ +name: CI/CD Deploy + +on: + push: + branches: [develop] #dev에 push 될 때 마다 실행 + +jobs: + build-and-deploy: + runs-on: ubuntu-latest #GitHub에서 제공하는 ubuntu-latest에서 실행 + + steps: + - name: Checkout repository #깃허브 저장소의 코드를 runner로 가져옴 + uses: actions/checkout@v4 + + - name: Set up Docker Buildx #도커 setup + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/vote-server:latest + + - name: Deploy to EC2 via SSH + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -e + echo "Stopping containers..." + sudo docker-compose down + CONTAINER_ID=$(sudo docker ps -qf "ancestor=${{ secrets.DOCKERHUB_USERNAME }}/vote-server:latest") + + if [ -n "$CONTAINER_ID" ]; then + USED_ID=$(sudo docker inspect "$CONTAINER_ID" --format='{{.Image}}') + else + USED_ID="none" + fi + + echo "Removing unused vote-server images..." + for IMG_ID in $(sudo docker images ${{ secrets.DOCKERHUB_USERNAME }}/vote-server -q); do + if [ "$IMG_ID" != "$USED_ID" ]; then + echo "Removing unused image: $IMG_ID" + sudo docker rmi $IMG_ID || true + fi + done + + echo "Pulling latest image..." + sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/vote-server:latest + + echo "Starting up containers..." + sudo docker-compose up -d \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d258044..9c8ec6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ WORKDIR /app #현재 디렉토리(소스코드 전체)를 컨테이너의 /app으로 복사. #도커파일 .과 ..은 각각 내 컴퓨터의 현재 디렉토리, 컨테이너의 현재 학업 디렉토리를 의미한다고 한다. COPY . . +RUN head -20 src/main/java/com/ceos/spring_vote_21st/security/config/SecurityConfig.java #gradle로 프로젝트 빌드 => JAR 파일 생성 RUN gradle clean build -x test diff --git a/build.gradle b/build.gradle index 9971e7f..1106f0b 100644 --- a/build.gradle +++ b/build.gradle @@ -45,8 +45,13 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + //swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + // dotenv + testImplementation 'io.github.cdimascio:dotenv-java:3.0.0' implementation 'com.h2database:h2' + } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 71f7e27..afa8f5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,14 @@ services: #컨테이너들 정의. 여기선 app(server)와 db SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} #db 비밀번호 SERVER_PORT: ${SERVER_PORT} + REDIS_HOST: ${REDIS_HOST} # redis 컨테이너 이름을 host로 사용 (Docker 네트워크상에서 가능) + REDIS_PORT: ${REDIS_PORT} + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + JWT_ACCESS_TOKEN_EXPIRATION: ${JWT_ACCESS_TOKEN_EXPIRATION} + JWT_REFRESH_TOKEN_EXPIRATION: ${JWT_REFRESH_TOKEN_EXPIRATION} depends_on: # db가 먼저 실행되어야 app이 실행됨 - db + - redis db: #컨테이너 이름 db image: mysql:8.0 #공식 mysql:8.0 이미지를 다운받아 컨테이너에서 바로 띄움 @@ -31,8 +37,19 @@ services: #컨테이너들 정의. 여기선 app(server)와 db volumes: #DB 데이터가 컨테이너 재시작/삭제되어도 날아가지 않게 호스트 pc에 저장 - mysql_data:/var/lib/mysql + redis: + image: redis:7.2-alpine # 공식 경량 이미지 추천 + container_name: vote-redis + ports: + - "${REDIS_HOST_PORT}:${REDIS_PORT}" # 필요에 따라 외부 노출 (보통은 내부 연결만 해도 충분) + volumes: + - redis_data:/data # Redis 데이터 영속화 (optional) + restart: unless-stopped + + volumes: mysql_data: + redis_data: #환경변수 흐름 #.env → docker-compose.yml → application-docker.yaml diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 9bbc975..1b33c55 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..ca025c8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index faf9300..23d15a9 100755 --- a/gradlew +++ b/gradlew @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9b42019..5eed7ee 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/src/main/java/com/ceos/spring_vote_21st/admin/controller/AdminController.java b/src/main/java/com/ceos/spring_vote_21st/admin/controller/AdminController.java new file mode 100644 index 0000000..6de85bd --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/admin/controller/AdminController.java @@ -0,0 +1,86 @@ +package com.ceos.spring_vote_21st.admin.controller; + +import com.ceos.spring_vote_21st.global.response.dto.CommonResponse; +import com.ceos.spring_vote_21st.member.service.MemberService; +import com.ceos.spring_vote_21st.vote.service.ElectionService; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateAddRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateModifyRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.ElectionCreateRequestDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/api/v1/admin") +@RequiredArgsConstructor +@RestController +public class AdminController { + private final MemberService memberService; + private final ElectionService electionService; + + /** + * Member + * */ + @DeleteMapping("/members/{memberId}") + public ResponseEntity delete(@PathVariable Long memberId) { + memberService.deleteMember(memberId); + return ResponseEntity.noContent().build(); // ResponseEntity.noContent()는 응답 바디를 아예 쓰지않음.(wrapping은 되지만 클라이언트는 바디를 안받는다) + } + + @DeleteMapping("/members") + public ResponseEntity deleteAll() { + memberService.deleteAll(); + return ResponseEntity.noContent().build(); + } + + /** + * Election + */ + @PostMapping("/elections") + public ResponseEntity> createElection(@RequestBody ElectionCreateRequestDTO dto) { + Long id = electionService.createElection(dto); + return ResponseEntity.ok(CommonResponse.success(id)); + } + + + @DeleteMapping("/elections/{electionId}") + public ResponseEntity deleteElection(@PathVariable Long electionId) { + electionService.deleteElection(electionId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/elections") + public ResponseEntity deleteAllElections() { + electionService.deleteAll(); + return ResponseEntity.noContent().build(); + } + + /** + * Candidate + */ + + // 후보 추가 + @PostMapping("/elections/{electionId}/candidates") + public ResponseEntity> addCandidate( + @RequestBody CandidateAddRequestDTO dto) { + Long id = electionService.addCandidate(dto); + return ResponseEntity.ok(CommonResponse.success(id)); + } + + // 후보 정보 수정 + @PutMapping("/elections/{electionId}/candidates") + public ResponseEntity> modifyCandidate( + @RequestBody CandidateModifyRequestDTO dto) { + Long id = electionService.modifyCandidate(dto); + return ResponseEntity.ok(CommonResponse.success(id)); + } + + // delete + @DeleteMapping("/elections/{electionId}/candidates/{candidateId}") + public ResponseEntity> deleteCandidate(@PathVariable Long electionId, @PathVariable Long candidateId) { + electionService.deleteCandidate(electionId, candidateId); + + return ResponseEntity.ok(CommonResponse.success(null)); + } + + +} diff --git a/src/main/java/com/ceos/spring_vote_21st/controller/HealthCheckController.java b/src/main/java/com/ceos/spring_vote_21st/controller/HealthCheckController.java index f5e9349..124ba64 100644 --- a/src/main/java/com/ceos/spring_vote_21st/controller/HealthCheckController.java +++ b/src/main/java/com/ceos/spring_vote_21st/controller/HealthCheckController.java @@ -7,8 +7,14 @@ @RestController public class HealthCheckController { + + @GetMapping("/health") public String healthCheck() { return "OK"; } + @GetMapping("/health/new") + public String healthCheckNew() { + return "OK2"; + } } diff --git a/src/main/java/com/ceos/spring_vote_21st/global/config/CorsConfig.java b/src/main/java/com/ceos/spring_vote_21st/global/config/CorsConfig.java new file mode 100644 index 0000000..44e8d05 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/global/config/CorsConfig.java @@ -0,0 +1,69 @@ +package com.ceos.spring_vote_21st.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +/** + * CORS 전역 설정 + * - Authorization 헤더를 클라이언트에 노출하여 Access Token 볼 수 있도록 지원함 + */ +@Configuration +public class CorsConfig { + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + // 허용할 오리진 패턴 (필요 시 특정 도메인만 허용) + config.setAllowedOriginPatterns(List.of( + "http://localhost:3000", + "https://next-vote-21th.vercel.app", + "https://hanihome-fe-test-gnos-projects-ab4a3758.vercel.app", + "https://hanihome-vote.shop", + "https://hanihome-fe-test.vercel.app" + )); + // 허용할 HTTP 메서드 + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + // 허용할 요청 헤더 + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Authorization")); // 클라이언트가 AccessToken 접근 가능하도록 지원 + // 쿠키 전송 허용 여부 + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); // 모든 URI에 대해서 + return source; + } + /* + * + @Bean + @Profile("local") // 로컬 프로필 + public CorsConfigurationSource devCorsConfig() { + CorsConfiguration c = new CorsConfiguration(); + c.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "https://*.ngrok-free.app" + )); + c.setAllowCredentials(false); + applyCommon(c); + return source(c); + } + + @Bean + @Profile("prod") // 운영 프로필 + public CorsConfigurationSource prodCorsConfig() { + CorsConfiguration c = new CorsConfiguration(); + c.setAllowedOriginPatterns(List.of( + "https://web.example.com", + "https://admin.example.com" + )); + c.setAllowCredentials(true); + applyCommon(c); + return source(c); + } + */ +} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/config/SwaggerConfig.java b/src/main/java/com/ceos/spring_vote_21st/global/config/SwaggerConfig.java new file mode 100644 index 0000000..4764a86 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/global/config/SwaggerConfig.java @@ -0,0 +1,55 @@ +package com.ceos.spring_vote_21st.global.config; + +import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.*; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + // /login api 추가 + Schema loginSchema = new ObjectSchema() + .addProperty("username", new StringSchema().example("string")) + .addProperty("password", new StringSchema().example("string")); + // request 형식 정의 + RequestBody loginRequestBody = new RequestBody() + .required(true) + .content(new Content().addMediaType("application/json", + new MediaType().schema(loginSchema))); + // response 형식 정의 + Operation loginOperation = new Operation() + .summary("로그인 (Spring Security 필터 사용)") + .addTagsItem("auth-controller") + .requestBody(loginRequestBody) + .responses(new ApiResponses() + .addApiResponse("201", new ApiResponse().description("로그인 성공 - JWT 반환")) + .addApiResponse("401", new ApiResponse().description("인증 실패"))); + + PathItem loginPathItem = new PathItem().post(loginOperation); + + Server server = new Server(); + server.setUrl("https://hanihome-vote.shop"); + return new OpenAPI() + .info(new Info().title("API 문서").version("v1")) + .addSecurityItem(new SecurityRequirement().addList("AccessToken( Bearer없이 토큰만 넣어주세요:) )")) + .components(new Components() + .addSecuritySchemes("AccessToken( Bearer없이 토큰만 넣어주세요:) )", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .bearerFormat("JWT"))) + + .paths(new Paths().addPathItem("/api/v1/auth/signin", loginPathItem)) + // Swagger-UI Try it out → "/api/..." 상대 경로로 호출 + .addServersItem(server); + } +} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/error/CustomException.java b/src/main/java/com/ceos/spring_vote_21st/global/error/CustomException.java deleted file mode 100644 index ac718a0..0000000 --- a/src/main/java/com/ceos/spring_vote_21st/global/error/CustomException.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ceos.spring_vote_21st.global.error; - -import lombok.Getter; - -@Getter -public class CustomException extends RuntimeException{ - private ErrorCode errorCode; - - public CustomException(ErrorCode errorCode, Throwable cause) { - super(cause); - this.errorCode = errorCode; - } - - public CustomException(ErrorCode errorCode) { - this.errorCode = errorCode; - } -} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/error/ErrorResponseEntity.java b/src/main/java/com/ceos/spring_vote_21st/global/error/ErrorResponseEntity.java deleted file mode 100644 index b6bb72b..0000000 --- a/src/main/java/com/ceos/spring_vote_21st/global/error/ErrorResponseEntity.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ceos.spring_vote_21st.global.error; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import org.springframework.http.ResponseEntity; - -@AllArgsConstructor -@Builder -public class ErrorResponseEntity { - private int statusCode; - private String message; - private String codeName; - public static ErrorResponseEntity of(ErrorCode errorCode) { - return ErrorResponseEntity.builder() - .statusCode(errorCode.getHttpStatus().value()) - .message(errorCode.getMessage()) - .codeName(errorCode.name()) - .build(); - } -} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/error/GlobalExceptionHandler.java b/src/main/java/com/ceos/spring_vote_21st/global/error/GlobalExceptionHandler.java deleted file mode 100644 index 2098976..0000000 --- a/src/main/java/com/ceos/spring_vote_21st/global/error/GlobalExceptionHandler.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ceos.spring_vote_21st.global.error; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - - -@Slf4j -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(CustomException.class) - public ResponseEntity handleCustomException(CustomException e) { - ErrorCode errorCode = e.getErrorCode(); - log.error("CustomException 발생: 상태코드={}, 메시지={}, 에러명={}", - errorCode.getHttpStatus().value(), - errorCode.getMessage(), - errorCode.name()); - - return ResponseEntity.status(errorCode.getHttpStatus().value()) - .body(ErrorResponseEntity.of(errorCode)); - } -} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/exception/CustomException.java b/src/main/java/com/ceos/spring_vote_21st/global/exception/CustomException.java new file mode 100644 index 0000000..18da349 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/global/exception/CustomException.java @@ -0,0 +1,18 @@ +package com.ceos.spring_vote_21st.global.exception; + +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException{ + private ServiceCode serviceCode; + + public CustomException(ServiceCode serviceCode, Throwable cause) { + super(cause); + this.serviceCode = serviceCode; + } + + public CustomException(ServiceCode serviceCode) { + this.serviceCode = serviceCode; + } +} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/exception/ErrorResponseDTO.java b/src/main/java/com/ceos/spring_vote_21st/global/exception/ErrorResponseDTO.java new file mode 100644 index 0000000..662067d --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/global/exception/ErrorResponseDTO.java @@ -0,0 +1,23 @@ +package com.ceos.spring_vote_21st.global.exception; + +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Builder +@Getter +public class ErrorResponseDTO { + private int statusCode; + private String message; + private String codeName; + + public static ErrorResponseDTO of(ServiceCode serviceCode) { + return ErrorResponseDTO.builder() + .statusCode(serviceCode.getHttpStatus().value()) + .message(serviceCode.getMessage()) + .codeName(serviceCode.name()) + .build(); + } +} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/exception/GlobalExceptionHandler.java b/src/main/java/com/ceos/spring_vote_21st/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..902fea9 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,36 @@ +package com.ceos.spring_vote_21st.global.exception; + +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; +import com.ceos.spring_vote_21st.global.response.dto.CommonResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + + +@Slf4j +@Order(1) +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e, HttpServletRequest request) { + if (request.getRequestURI().startsWith("/v3/api-docs") || request.getRequestURI().contains("swagger")) { + // Swagger 문서 생성용 요청은 예외 처리하지 않음 + return ResponseEntity.ok().build(); + } + + log.info("globalExceptionHandler에 진입" + e.getMessage()); + ServiceCode serviceCode = e.getServiceCode(); + log.error("CustomException 발생: 상태코드={}, 메시지={}, 에러명={}", + serviceCode.getHttpStatus().value(), + serviceCode.getMessage(), + serviceCode.name()); + + CommonResponse commonResponse = CommonResponse.failure(ErrorResponseDTO.of(serviceCode), serviceCode); + return ResponseEntity.status(serviceCode.getHttpStatus().value()) + .body(commonResponse); + } +} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/error/ErrorCode.java b/src/main/java/com/ceos/spring_vote_21st/global/response/domain/ServiceCode.java similarity index 66% rename from src/main/java/com/ceos/spring_vote_21st/global/error/ErrorCode.java rename to src/main/java/com/ceos/spring_vote_21st/global/response/domain/ServiceCode.java index 38aca13..7a39fd8 100644 --- a/src/main/java/com/ceos/spring_vote_21st/global/error/ErrorCode.java +++ b/src/main/java/com/ceos/spring_vote_21st/global/response/domain/ServiceCode.java @@ -1,26 +1,38 @@ -package com.ceos.spring_vote_21st.global.error; +package com.ceos.spring_vote_21st.global.response.domain; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -@RequiredArgsConstructor @Getter -public enum ErrorCode { +@RequiredArgsConstructor +public enum ServiceCode { + + /** + * 성공 + * */ + SUCCESS(HttpStatus.OK,"정상 처리되었습니다."), + + /** + * 실패 + * */ USERNAME_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 아이디입니다"), EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일입니다"), MEMBER_NOT_EXISTS(HttpStatus.BAD_REQUEST, "해당 ID의 회원이 존재하지 않습니다."), - ENTITY_NOT_EXISTS(HttpStatus.BAD_REQUEST, "해당 선거가 없습니다."), + ENTITY_NOT_EXISTS(HttpStatus.BAD_REQUEST, "해당 엔티티가 없습니다."), POSITION_NOT_MATCH(HttpStatus.BAD_REQUEST, "자신과 다른 파트에 투표했습니다"), CANNOT_VOTE_SAME_TEAM(HttpStatus.BAD_REQUEST, "자신의 팀에 투표할 수 없습니다"), - DUPLICATE_VOTE(HttpStatus.BAD_REQUEST, "중복투표할 수 없습니다"), + DUPLICATE_VOTE(HttpStatus.BAD_REQUEST, "이미 해당 선거에 투표했습니다"), //jwt INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "리프레시 토큰 검증에 실패했습니다."), TOKEN_MISMATCH(HttpStatus.BAD_REQUEST, "연관된 토큰이 아닙니다"), INVALID_TOKEN(HttpStatus.BAD_REQUEST, "유효한 토큰이 아닙니다"), MALFORMED_TOKEN(HttpStatus.BAD_REQUEST, "토큰의 구조가 올바르지 않습니다"), UNSUPPORTED_TOKEN(HttpStatus.BAD_REQUEST, "미지원하는 토큰입니다"), - ; + + // not defined + NOT_DEFINED_ERROR(HttpStatus.BAD_REQUEST, "정의 되지 않은 에러입니다"), + TOKEN_LOGOUT(HttpStatus.BAD_REQUEST, "로그아웃한 사용자입니다"); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/ceos/spring_vote_21st/global/response/dto/CommonResponse.java b/src/main/java/com/ceos/spring_vote_21st/global/response/dto/CommonResponse.java new file mode 100644 index 0000000..21b1566 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/global/response/dto/CommonResponse.java @@ -0,0 +1,54 @@ +package com.ceos.spring_vote_21st.global.response.dto; + +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Getter +@Builder +public class CommonResponse { + private boolean isSuccess; // 성공여부 + private String serviceCode; // http상태코드가 아닌 자체 서비스 코드 + private String message; // message + private T data; // 반환값 + + // GlobalResponseWrapper용 Success + public static CommonResponse success(T data) { + return CommonResponse.builder() + .isSuccess(true) + .serviceCode(ServiceCode.SUCCESS.name()) + .message(ServiceCode.SUCCESS.getMessage()) + .data(data) + .build(); + } + + public static CommonResponse success(ServiceCode code, T data) { + return CommonResponse.builder() + .isSuccess(true) + .serviceCode(code.name()) + .message(code.getMessage()) + .data(data) + .build(); + } + + // GlobalResponseWrapper용 failure + public static CommonResponse failure(T data) { + return CommonResponse.builder() + .isSuccess(false) + .serviceCode(ServiceCode.NOT_DEFINED_ERROR.name()) + .message(ServiceCode.NOT_DEFINED_ERROR.getMessage()) + .data(data) + .build(); + } + + public static CommonResponse failure(T data, ServiceCode code) { + return CommonResponse.builder() + .isSuccess(false) + .serviceCode(code.name()) + .message(code.getMessage()) + .data(data) + .build(); + } +} diff --git a/src/main/java/com/ceos/spring_vote_21st/global/response/wrapper/GlobalResponseWrapper.java b/src/main/java/com/ceos/spring_vote_21st/global/response/wrapper/GlobalResponseWrapper.java new file mode 100644 index 0000000..2fb0045 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/global/response/wrapper/GlobalResponseWrapper.java @@ -0,0 +1,92 @@ +package com.ceos.spring_vote_21st.global.response.wrapper; + +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; +import com.ceos.spring_vote_21st.global.response.dto.CommonResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/* +* 기능: 응답의 body만 wrapping함. +* 그 외의 status, header를 건드는건 목적이 아님 +* */ +@Slf4j +@Order(2) +@RestControllerAdvice +public class GlobalResponseWrapper implements ResponseBodyAdvice { + // Wrapping 대상 + @Override + public boolean supports(MethodParameter returnType, Class converterType) { + return true; // 모두 + } + + @Override + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { + // body가 이미 CommonResponse + if (body instanceof CommonResponse) { + return body; + } + //swagger 관련은 wrapping 안함 + String path = request.getURI().getPath(); + if (path.contains("swagger") + || path.contains("api-docs") + || path.contains("webjars")) { + return body; + } + + // CommonResponse 생성 + CommonResponse commonResponse; + ServiceCode serviceCode = getServiceCode((ServletServerHttpResponse) response); + if (serviceCode.equals(ServiceCode.SUCCESS)) { + commonResponse = CommonResponse.success(body); + } else { + // 원래 Exception은 여기까지 안옴: 전역예외 핸들러에서 CommonResponse로 변환 되었어야했음. + if(body instanceof CustomException){ + log.error("service Code: {}", ((CustomException) body).getServiceCode().getMessage()); + commonResponse = CommonResponse.failure(body, ((CustomException) body).getServiceCode()); + } + + else { + log.error("CustomException 말고 다른 Exception 터짐: {}", body.toString()); + commonResponse = CommonResponse.failure(body); + } + } + + + // case1: body가 String인 경우에는 String으로 재변환 필요 + if (body instanceof String) { + return convertToStringResponse(commonResponse); + } + + // case2: 그 외는 CommonResponse 그대로 반환 + return commonResponse; + } + + private String convertToStringResponse(CommonResponse commonResponse) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + // JSON 문자열 형태로 변환 → String 타입으로 반환해야 converter 충돌이 없음 + return objectMapper.writeValueAsString(commonResponse); + } catch (JsonProcessingException e) { + throw new RuntimeException("String response conversion error", e); + } + } + + private ServiceCode getServiceCode(ServletServerHttpResponse response) { + int status = response + .getServletResponse() + .getStatus(); + ServiceCode serviceCode = (status >= 200 && status < 300) ? ServiceCode.SUCCESS : ServiceCode.NOT_DEFINED_ERROR; + return serviceCode; + } + +} diff --git a/src/main/java/com/ceos/spring_vote_21st/member/domain/CeosTeam.java b/src/main/java/com/ceos/spring_vote_21st/member/domain/CeosTeam.java index a38dd44..e977d6a 100644 --- a/src/main/java/com/ceos/spring_vote_21st/member/domain/CeosTeam.java +++ b/src/main/java/com/ceos/spring_vote_21st/member/domain/CeosTeam.java @@ -6,7 +6,7 @@ @RequiredArgsConstructor @Getter public enum CeosTeam { - POP_UPCYCLE("팝업사이클"), + LOOPZ("룹즈"), HANI_HOME("하니홈"), DEAR_DREAM("이어드림"), PROMESA("프로메사"), diff --git a/src/main/java/com/ceos/spring_vote_21st/member/service/MemberService.java b/src/main/java/com/ceos/spring_vote_21st/member/service/MemberService.java index a7fedd3..4de8fb3 100644 --- a/src/main/java/com/ceos/spring_vote_21st/member/service/MemberService.java +++ b/src/main/java/com/ceos/spring_vote_21st/member/service/MemberService.java @@ -1,12 +1,11 @@ package com.ceos.spring_vote_21st.member.service; -import com.ceos.spring_vote_21st.global.error.CustomException; -import com.ceos.spring_vote_21st.global.error.ErrorCode; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; import com.ceos.spring_vote_21st.member.domain.Member; import com.ceos.spring_vote_21st.member.repository.MemberRepository; import com.ceos.spring_vote_21st.member.web.dto.MemberCreateRequestDTO; import com.ceos.spring_vote_21st.member.web.dto.MemberResponseDTO; -import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -28,10 +27,10 @@ public class MemberService { public Long createMember(MemberCreateRequestDTO dto) { Member entity = dto.toEntity(); if (memberRepository.existsByUsername(entity.getUsername())) { - throw new CustomException(ErrorCode.USERNAME_ALREADY_EXISTS); + throw new CustomException(ServiceCode.USERNAME_ALREADY_EXISTS); } if (memberRepository.existsByEmail(entity.getEmail())) { - throw new CustomException(ErrorCode.EMAIL_ALREADY_EXISTS); + throw new CustomException(ServiceCode.EMAIL_ALREADY_EXISTS); } return memberRepository.save(entity).getId(); } @@ -39,7 +38,7 @@ public Long createMember(MemberCreateRequestDTO dto) { /** READ (단건 조회) */ public MemberResponseDTO getMember(Long id) { Member member = memberRepository.findById(id) - .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_EXISTS)); + .orElseThrow(() -> new CustomException(ServiceCode.MEMBER_NOT_EXISTS)); return MemberResponseDTO.from(member); } @@ -79,8 +78,12 @@ public Member updateMember(Long id, Member updateData) { @Transactional public void deleteMember(Long id) { if (!memberRepository.existsById(id)) { - throw new CustomException(ErrorCode.MEMBER_NOT_EXISTS); + throw new CustomException(ServiceCode.MEMBER_NOT_EXISTS); } memberRepository.deleteById(id); } + @Transactional + public void deleteAll() { + memberRepository.deleteAll(); + } } \ No newline at end of file diff --git a/src/main/java/com/ceos/spring_vote_21st/member/web/MemberController.java b/src/main/java/com/ceos/spring_vote_21st/member/web/MemberController.java index 9214f08..3a52e18 100644 --- a/src/main/java/com/ceos/spring_vote_21st/member/web/MemberController.java +++ b/src/main/java/com/ceos/spring_vote_21st/member/web/MemberController.java @@ -1,6 +1,5 @@ package com.ceos.spring_vote_21st.member.web; -import com.ceos.spring_vote_21st.member.web.dto.MemberCreateRequestDTO; import com.ceos.spring_vote_21st.member.web.dto.MemberResponseDTO; import org.springframework.web.bind.annotation.RestController; @@ -19,21 +18,22 @@ public class MemberController { private final MemberService memberService; - @PostMapping - public ResponseEntity create(@RequestBody MemberCreateRequestDTO dto) { - return ResponseEntity.ok(memberService.createMember(dto)); - } - + /** + * read + * */ @GetMapping("/{memberId}") - public ResponseEntity findOne(@PathVariable Long memberId) { - return ResponseEntity.ok(memberService.getMember(memberId)); + public MemberResponseDTO findOne(@PathVariable Long memberId) { + return memberService.getMember(memberId); } @GetMapping - public ResponseEntity> findAll() { - return ResponseEntity.ok(memberService.getAllMembers()); + public List findAll() { + return memberService.getAllMembers(); } + /** + * update + * */ /* @PutMapping("/{id}") public ResponseEntity update(@PathVariable Long id, @RequestBody MemberRequestDTO request) { @@ -41,9 +41,8 @@ public ResponseEntity update(@PathVariable Long id, @RequestB } */ - @DeleteMapping("/{memberId}") - public ResponseEntity delete(@PathVariable Long memberId) { - memberService.deleteMember(memberId); - return ResponseEntity.noContent().build(); - } + /** + * other business + */ + } diff --git a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/filter/JwtAuthorizationFilter.java b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/filter/JwtAuthorizationFilter.java index 6f034b2..dacde13 100644 --- a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/filter/JwtAuthorizationFilter.java @@ -1,8 +1,9 @@ package com.ceos.spring_vote_21st.security.auth.application.filter; -import com.ceos.spring_vote_21st.global.error.CustomException; -import com.ceos.spring_vote_21st.global.error.ErrorCode; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; import com.ceos.spring_vote_21st.security.auth.application.jwt.JwtTokenProvider; +import com.ceos.spring_vote_21st.security.auth.application.jwt.blacklist.BlacklistTokenService; import com.ceos.spring_vote_21st.security.auth.application.jwt.refresh.RefreshTokenService; import com.ceos.spring_vote_21st.security.auth.user.detail.CustomUserDetails; import jakarta.servlet.FilterChain; @@ -38,12 +39,20 @@ public class JwtAuthorizationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; private final RefreshTokenService refreshTokenService; + private final BlacklistTokenService blacklistTokenService; + + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 헤더 Authorization 필드에서 토큰 추출 String accessToken = getTokenFromRequest(request); + // 인증없이도 조회가능한 URI를 위해서 토큰 없이도 다음 필터로 넘긴다. + if (accessToken == null) { + filterChain.doFilter(request, response); + return; + } // token 검증 (+ access token 재발행) validateAndReissue(request, response, accessToken); @@ -66,6 +75,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse * 토큰 검증 및 만료시 재발행 * */ private void validateAndReissue(HttpServletRequest request, HttpServletResponse response, String accessToken) { + // 블랙리스트 검증 + if (blacklistTokenService.isBlacklisted(accessToken)) { + log.info("해당 토큰은 블랙리스트입니다, token: {}", accessToken); + + throw new CustomException(ServiceCode.TOKEN_LOGOUT); + } + // 토큰 검증 if (!jwtTokenProvider.validateToken(accessToken)) { // access 토큰이 만료된거면.. log.info("Token Expired: 토큰 재발행 시작"); Cookie refreshCookie = WebUtils.getCookie(request, "refreshToken"); @@ -74,7 +90,7 @@ private void validateAndReissue(HttpServletRequest request, HttpServletResponse if (refreshCookie != null) { refreshToken = refreshCookie.getValue(); // 1.refreshToken 서명 검증 - if(!jwtTokenProvider.validateToken(refreshToken)) throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + if(!jwtTokenProvider.validateToken(refreshToken)) throw new CustomException(ServiceCode.INVALID_REFRESH_TOKEN); // 2. 본인 refresh token인지 검증 jwtTokenProvider.validateTokenOwnership(accessToken, refreshToken); @@ -103,7 +119,17 @@ private String getTokenFromRequest(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String requestURI = request.getRequestURI(); - return requestURI.startsWith("/api/v1/users/signup") || requestURI.equals("/api/v1/users/signin") || requestURI.equals("/api/v1/users/logout") || requestURI.equals("/api/v1/health"); + + return requestURI.startsWith( + "/api/v1/auth/signup") || + requestURI.equals("/api/v1/auth/signin") + || requestURI.equals("/api/v1/auth/logout") + || requestURI.equals("/health") + || requestURI.equals("/swagger-ui.html") + || requestURI.startsWith("/swagger-ui") + || requestURI.startsWith("/v3/api-docs") + ; + } } diff --git a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/jwt/JwtTokenProvider.java b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/jwt/JwtTokenProvider.java index cdde8f2..5814d01 100644 --- a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/jwt/JwtTokenProvider.java +++ b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/jwt/JwtTokenProvider.java @@ -1,8 +1,8 @@ package com.ceos.spring_vote_21st.security.auth.application.jwt; -import com.ceos.spring_vote_21st.global.error.CustomException; -import com.ceos.spring_vote_21st.global.error.ErrorCode; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; import com.ceos.spring_vote_21st.security.auth.application.jwt.refresh.RefreshTokenService; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; @@ -68,11 +68,11 @@ public Boolean validateToken(String token) { } catch (ExpiredJwtException e) { return false; } catch (UnsupportedJwtException e) { - throw new CustomException(ErrorCode.UNSUPPORTED_TOKEN); + throw new CustomException(ServiceCode.UNSUPPORTED_TOKEN); } catch (MalformedJwtException e) { - throw new CustomException(ErrorCode.MALFORMED_TOKEN); + throw new CustomException(ServiceCode.MALFORMED_TOKEN); } catch (SecurityException | IllegalArgumentException e) { - throw new CustomException(ErrorCode.INVALID_TOKEN); + throw new CustomException(ServiceCode.INVALID_TOKEN); } } @@ -86,7 +86,7 @@ public Long getUserIdFromToken(String token) { } catch (ExpiredJwtException e) { return Long.parseLong(e.getClaims().getSubject()); } catch (Exception e) { - throw new CustomException(ErrorCode.INVALID_TOKEN); + throw new CustomException(ServiceCode.INVALID_TOKEN); } } @@ -101,7 +101,7 @@ public String getUsernameFromToken(String token) { } catch (ExpiredJwtException e) { return e.getClaims().get("username", String.class); } catch (Exception e) { - throw new CustomException(ErrorCode.INVALID_TOKEN); + throw new CustomException(ServiceCode.INVALID_TOKEN.INVALID_TOKEN); } } @@ -147,7 +147,7 @@ public void validateTokenOwnership(String accessToken, String refreshToken) { String savedRefreshToken = refreshTokenService.getToken(userId); if (!refreshToken.equals(savedRefreshToken)) { - throw new CustomException(ErrorCode.TOKEN_MISMATCH); + throw new CustomException(ServiceCode.TOKEN_MISMATCH); } } diff --git a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/jwt/refresh/RefreshTokenService.java b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/jwt/refresh/RefreshTokenService.java index 1e4c0a4..6819d27 100644 --- a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/jwt/refresh/RefreshTokenService.java +++ b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/jwt/refresh/RefreshTokenService.java @@ -1,8 +1,11 @@ package com.ceos.spring_vote_21st.security.auth.application.jwt.refresh; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; import com.ceos.spring_vote_21st.security.auth.application.jwt.JwtProperties; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import java.time.Duration; diff --git a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/service/AuthService.java b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/service/AuthService.java index 4f6e055..7917ae4 100644 --- a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/service/AuthService.java +++ b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/service/AuthService.java @@ -1,11 +1,10 @@ package com.ceos.spring_vote_21st.security.auth.application.service; -import com.ceos.spring_vote_21st.global.error.CustomException; -import com.ceos.spring_vote_21st.global.error.ErrorCode; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; import com.ceos.spring_vote_21st.member.domain.Member; import com.ceos.spring_vote_21st.member.domain.Role; import com.ceos.spring_vote_21st.member.repository.MemberRepository; -import com.ceos.spring_vote_21st.member.web.dto.MemberResponseDTO; import com.ceos.spring_vote_21st.security.auth.application.jwt.JwtTokenProvider; import com.ceos.spring_vote_21st.security.auth.application.jwt.blacklist.BlacklistTokenService; import com.ceos.spring_vote_21st.security.auth.application.jwt.refresh.RefreshTokenService; @@ -16,9 +15,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.stream.Collectors; - @Slf4j @Transactional(readOnly = true) @RequiredArgsConstructor @@ -36,8 +32,13 @@ public class AuthService { public Long signUp(SignUpDTO dto) { // id 중복 체크 String username = dto.getUsername(); - if (!memberRepository.findByUsername(username).isEmpty()) { - throw new CustomException(ErrorCode.USERNAME_ALREADY_EXISTS); + if (isUsernameExists(username)) { + throw new CustomException(ServiceCode.USERNAME_ALREADY_EXISTS); + } + + // email 중복 체크 + if (isEmailExists(dto.getEmail())) { + throw new CustomException(ServiceCode.EMAIL_ALREADY_EXISTS); } Member entity = Member.builder() @@ -53,6 +54,16 @@ public Long signUp(SignUpDTO dto) { return memberRepository.save(entity).getId(); } + public boolean isEmailExists(String email) { + return memberRepository.existsByEmail(email); + } + + public boolean isUsernameExists(String username) { + return memberRepository.findByUsername(username).isPresent(); + } + + + /* public String signIn(SignInDTO dto) { User findUser = userRepository.findByUsername(dto.getUsername()).orElseThrow(); @@ -118,8 +129,8 @@ public void logout(String accessHeader, String refreshToken) { } // refreshToken 삭제 Long userId = jwtTokenProvider.getUserIdFromToken(accessToken); + //TODO: refreshToken은 검증을 전혀 안하고 있음. refreshToken 유효성 검증이 필요. & accessToken,refreshToken모두 무효화하게 되므로 둘의 소유자 일치 여부도 필요 refreshTokenService.deleteToken(userId); } - } diff --git a/src/main/java/com/ceos/spring_vote_21st/security/auth/application/service/TokenReissueService.java b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/service/TokenReissueService.java new file mode 100644 index 0000000..da0c1e1 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/security/auth/application/service/TokenReissueService.java @@ -0,0 +1,36 @@ +package com.ceos.spring_vote_21st.security.auth.application.service; + +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; +import com.ceos.spring_vote_21st.security.auth.application.jwt.JwtTokenProvider; +import com.ceos.spring_vote_21st.security.auth.application.jwt.refresh.RefreshTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class TokenReissueService { + private final RefreshTokenService refreshTokenService; + private final JwtTokenProvider tokenProvider; + private final JwtTokenProvider jwtTokenProvider; + + public String reissueAccessToken(String refreshToken) { + Long userIdFromToken = tokenProvider.getUserIdFromToken(refreshToken); + String usernameFromToken = tokenProvider.getUsernameFromToken(refreshToken); + + // rotation token 미적용 + if (!tokenProvider.validateToken(refreshToken)) { + throw new CustomException(ServiceCode.INVALID_TOKEN); + } + + // 서버에 저장된 refreshToken과 비교 + String savedRefreshToken = refreshTokenService.getToken(userIdFromToken); + if (!refreshToken.equals(savedRefreshToken)) { + throw new CustomException(ServiceCode.TOKEN_MISMATCH); + } + + // refreshToken이 validated & 서버의 refreshToken과 일치 + return tokenProvider.generateAccessToken(userIdFromToken, usernameFromToken); + } + +} diff --git a/src/main/java/com/ceos/spring_vote_21st/security/auth/user/detail/CustomUserDetailsService.java b/src/main/java/com/ceos/spring_vote_21st/security/auth/user/detail/CustomUserDetailsService.java index 9239e44..4926fc2 100644 --- a/src/main/java/com/ceos/spring_vote_21st/security/auth/user/detail/CustomUserDetailsService.java +++ b/src/main/java/com/ceos/spring_vote_21st/security/auth/user/detail/CustomUserDetailsService.java @@ -1,13 +1,17 @@ package com.ceos.spring_vote_21st.security.auth.user.detail; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; import com.ceos.spring_vote_21st.member.domain.Member; import com.ceos.spring_vote_21st.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +@Slf4j @RequiredArgsConstructor @Service public class CustomUserDetailsService implements UserDetailsService { @@ -19,7 +23,12 @@ public class CustomUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Member member = memberRepository.findByUsername(username) - .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> + { + log.warn(ServiceCode.MEMBER_NOT_EXISTS.getMessage() + "username: {}", username); + return new CustomException(ServiceCode.MEMBER_NOT_EXISTS); + } + ); return CustomUserDetails.from(member); } diff --git a/src/main/java/com/ceos/spring_vote_21st/security/auth/web/controller/AuthController.java b/src/main/java/com/ceos/spring_vote_21st/security/auth/web/controller/AuthController.java index a8a2c1e..dd03aac 100644 --- a/src/main/java/com/ceos/spring_vote_21st/security/auth/web/controller/AuthController.java +++ b/src/main/java/com/ceos/spring_vote_21st/security/auth/web/controller/AuthController.java @@ -1,40 +1,79 @@ package com.ceos.spring_vote_21st.security.auth.web.controller; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; +import com.ceos.spring_vote_21st.global.response.dto.CommonResponse; import com.ceos.spring_vote_21st.member.service.MemberService; import com.ceos.spring_vote_21st.member.web.dto.MemberResponseDTO; import com.ceos.spring_vote_21st.security.auth.application.jwt.JwtTokenProvider; import com.ceos.spring_vote_21st.security.auth.application.jwt.refresh.RefreshTokenService; import com.ceos.spring_vote_21st.security.auth.application.service.AuthService; +import com.ceos.spring_vote_21st.security.auth.application.service.TokenReissueService; import com.ceos.spring_vote_21st.security.auth.web.dto.SignUpDTO; +import io.netty.handler.ssl.PemPrivateKey; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.WebUtils; +import java.util.Map; + +@Tag(name = "auth-controller") @Slf4j @RequiredArgsConstructor -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/auth") @RestController public class AuthController { private final AuthService authService; - private final RefreshTokenService refreshTokenService; - private final JwtTokenProvider tokenProvider; + private final TokenReissueService tokenReissueService; // 회원가입 - @PostMapping("/users/signup") + @PostMapping("/signup") public ResponseEntity signUp(@RequestBody SignUpDTO dto) { Long id = authService.signUp(dto); return ResponseEntity.ok(id); } + // 로그인 및 토큰유지: Spring Security + // 로그아웃 - @PostMapping("/users/logout") + @PostMapping("/logout") public ResponseEntity logout(@RequestHeader("Authorization") String accessHeader, @CookieValue("refreshToken") String refreshToken) { - log.info("refreshTOken: " + refreshToken); + log.info("로그아웃 요청: refreshToken: " + refreshToken); authService.logout(accessHeader, refreshToken); return ResponseEntity.ok().build(); } -} \ No newline at end of file + // 액세스 토큰 재발급 + @PostMapping("/tokens/refresh") + public ResponseEntity> reissueAccessToken(@CookieValue("refreshToken") String refreshToken) { + String reissueAccessToken = tokenReissueService.reissueAccessToken(refreshToken); + + return ResponseEntity.ok() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + reissueAccessToken) + .body(CommonResponse.success(null)); + } + + // 아이디 중복 확인 + @GetMapping("/signup/username/exists") + public ResponseEntity> isDuplicateUsername(@RequestParam String username) { + boolean exists = authService.isUsernameExists(username); + + return ResponseEntity.ok(CommonResponse.success(Map.of("exists", exists))); + } + + // 이메일 중복 확인 + @GetMapping("/signup/email/exists") + public ResponseEntity> isDuplicateEmail(@RequestParam String email) { + boolean exists = authService.isEmailExists(email); + + return ResponseEntity.ok(CommonResponse.success(Map.of("exists", exists))); + } + +} diff --git a/src/main/java/com/ceos/spring_vote_21st/security/config/SecurityConfig.java b/src/main/java/com/ceos/spring_vote_21st/security/config/SecurityConfig.java index 40b05b8..237f742 100644 --- a/src/main/java/com/ceos/spring_vote_21st/security/config/SecurityConfig.java +++ b/src/main/java/com/ceos/spring_vote_21st/security/config/SecurityConfig.java @@ -1,9 +1,11 @@ package com.ceos.spring_vote_21st.security.config; +import com.ceos.spring_vote_21st.global.config.CorsConfig; import com.ceos.spring_vote_21st.member.domain.Role; import com.ceos.spring_vote_21st.security.auth.application.filter.CustomAuthenticationFilter; import com.ceos.spring_vote_21st.security.auth.application.filter.JwtAuthorizationFilter; import com.ceos.spring_vote_21st.security.auth.application.jwt.JwtTokenProvider; +import com.ceos.spring_vote_21st.security.auth.application.jwt.blacklist.BlacklistTokenService; import com.ceos.spring_vote_21st.security.auth.application.jwt.refresh.RefreshTokenService; import com.ceos.spring_vote_21st.security.handler.JwtAuthenticationFailureHandler; import com.ceos.spring_vote_21st.security.handler.JwtAuthenticationSuccessHandler; @@ -11,15 +13,19 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; +@EnableWebSecurity @RequiredArgsConstructor @Configuration public class SecurityConfig { @@ -28,23 +34,50 @@ public class SecurityConfig { private final JwtAuthenticationSuccessHandler jwtSuccessHandler; private final JwtAuthenticationFailureHandler jwtFailureHandler; private final RefreshTokenService refreshTokenService; + private final BlacklistTokenService blacklistTokenService; + private final CorsConfig corsConfig; @Bean public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationConfiguration authConfig) throws Exception { http + .cors(cors->cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) .httpBasic(httpBasic -> httpBasic.disable()) .formLogin(formLogin -> formLogin.disable()) .sessionManagement(session -> session. sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(authorize -> authorize. - requestMatchers("/api/v1/users/signin", "/api/v1/users/signup", "/api/v1/users/logout","/health").permitAll() // 인증 불필요 - .requestMatchers("/api/v1/admin/**").hasRole(Role.ROLE_ADMIN.getKey()) - .anyRequest().hasRole(Role.ROLE_USER.getKey()) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/v1/elections/{electionId}/votes", + "/api/v1/elections/{electionId}/my-vote") + .hasRole(Role.ROLE_USER.getKey()) + .requestMatchers( + "/api/v1/auth/signup", + "/api/v1/auth/signup/**", + "/api/v1/auth/signin", + "/api/v1/auth/logout", + "/api/v1/auth/tokens/refresh", + // swagger + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + // admin + "/api/v1/admin/**", + // member + "/api/v1/members/**", + // election + "/api/v1/elections/**", + // health + "/health", + "/health/new", + "/error" + + ).permitAll() // 인증 불필요 +// .requestMatchers("/api/v1/admin/**").hasRole(Role.ROLE_ADMIN.getKey()) +// .anyRequest().hasRole(Role.ROLE_USER.getKey()) // .anyRequest().authenticated() ) .addFilterBefore( - new JwtAuthorizationFilter(jwtTokenProvider, userDetailsService, refreshTokenService), // JWT 인가 필터 추가 + new JwtAuthorizationFilter(jwtTokenProvider, userDetailsService, refreshTokenService, blacklistTokenService), // JWT 인가 필터 추가 UsernamePasswordAuthenticationFilter.class ) .addFilterAt(customAuthenticationFilter(authConfig.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class) // 자체 인증 필터 추가 @@ -52,10 +85,34 @@ public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationConfigur return http.build(); } +/* +@Bean +public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationConfiguration authConfig) throws Exception { + http + .cors(cors->cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .httpBasic(httpBasic -> httpBasic.disable()) + .formLogin(formLogin -> formLogin.disable()) + .sessionManagement(session -> session. + sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> authorize. + requestMatchers("/**").permitAll() // 인증 불필요 + + ) + .addFilterBefore( + new JwtAuthorizationFilter(jwtTokenProvider, userDetailsService, refreshTokenService), // JWT 인가 필터 추가 + UsernamePasswordAuthenticationFilter.class + ) + .addFilterAt(customAuthenticationFilter(authConfig.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class) // 자체 인증 필터 추가 + ; + + return http.build(); +} +*/ private CustomAuthenticationFilter customAuthenticationFilter(AuthenticationManager authenticationManager) { CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager, jwtSuccessHandler, jwtFailureHandler); - customAuthenticationFilter.setFilterProcessesUrl("/api/v1/users/signin"); + customAuthenticationFilter.setFilterProcessesUrl("/api/v1/auth/signin"); return customAuthenticationFilter; } @@ -71,4 +128,11 @@ public BCryptPasswordEncoder passwordEncoder() { public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } + + + // CorsConfig + private CorsConfigurationSource corsConfigurationSource() { + return corsConfig.corsConfigurationSource(); + + } } diff --git a/src/main/java/com/ceos/spring_vote_21st/security/handler/JwtAuthenticationSuccessHandler.java b/src/main/java/com/ceos/spring_vote_21st/security/handler/JwtAuthenticationSuccessHandler.java index 6cd838b..5c6c306 100644 --- a/src/main/java/com/ceos/spring_vote_21st/security/handler/JwtAuthenticationSuccessHandler.java +++ b/src/main/java/com/ceos/spring_vote_21st/security/handler/JwtAuthenticationSuccessHandler.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; @@ -28,17 +29,21 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String accessToken = tokenProvider.generateAccessToken(userDetails.getUserId(), userDetails.getUsername()); String refreshToken = tokenProvider.generateRefreshToken(userDetails.getUserId(), userDetails.getUsername()); - refreshService.saveToken(userDetails.getUserId(), refreshToken); // accessToken 헤더 담기 response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer "+accessToken); + //refreshToken 서버 저장 + refreshService.saveToken(userDetails.getUserId(), refreshToken); //refershToken 쿠키 담기 long cookieAge = tokenProvider.getJwtProperties().getRefreshTokenExpiration() / 1000; // 초 단위 String refreshCookie = ResponseCookie.from("refreshToken", refreshToken) - .httpOnly(true) + .httpOnly(true) //필수 + .sameSite("None") //필수, CORS가 도메인이 달라도 쿠키전송 & secure 필수 + .secure(true) //필수, https필수 -> 이것들을 해야 쿠키가 브라우저에 저장되고 전송됨 .path("/") .maxAge(cookieAge) +// .domain("hanihome-api.dev") .build() .toString(); diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/domain/Candidate.java b/src/main/java/com/ceos/spring_vote_21st/vote/domain/Candidate.java index 73f70e6..979926d 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/domain/Candidate.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/domain/Candidate.java @@ -1,7 +1,7 @@ package com.ceos.spring_vote_21st.vote.domain; import com.ceos.spring_vote_21st.member.domain.CeosTeam; -import com.ceos.spring_vote_21st.vote.web.dto.CandidateModifyRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateModifyRequestDTO; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/domain/Election.java b/src/main/java/com/ceos/spring_vote_21st/vote/domain/Election.java index ad7b59b..89885d3 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/domain/Election.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/domain/Election.java @@ -1,6 +1,8 @@ package com.ceos.spring_vote_21st.vote.domain; import com.ceos.spring_vote_21st.global.domain.BaseEntity; +import com.ceos.spring_vote_21st.vote.domain.enums.ElectionStatus; +import com.ceos.spring_vote_21st.vote.domain.enums.Section; import jakarta.persistence.*; import lombok.*; @@ -25,6 +27,7 @@ public class Election extends BaseEntity { @Enumerated(STRING) private ElectionStatus electionStatus; + @Enumerated(STRING) private Section section; private LocalDateTime startedAt; @@ -33,10 +36,12 @@ public class Election extends BaseEntity { /** Vote는 단독 조회 필요*/ @OneToMany(mappedBy = "election") + @Builder.Default private List votes = new ArrayList<>(); /** 양방향 연관관계는 지양하기로 했으나, Candidate는 단독조회하는 경우가 잘 없으며 Election과 영속성 상태를 같이 가져가는 것이 자연스러워 양방향 cascade를 택함 */ @OneToMany(mappedBy = "election", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default private List candidates = new ArrayList<>(); //연관관계 편의 메서드 @@ -48,4 +53,8 @@ public void addCandidate(Candidate candidate) { candidates.add(candidate); } + public void removeCandidate(Candidate candidate) { + candidates.remove(candidate); + } + } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/domain/Vote.java b/src/main/java/com/ceos/spring_vote_21st/vote/domain/Vote.java index 57bfabb..1a17dcd 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/domain/Vote.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/domain/Vote.java @@ -1,12 +1,10 @@ package com.ceos.spring_vote_21st.vote.domain; +import com.ceos.spring_vote_21st.global.domain.BaseEntity; import com.ceos.spring_vote_21st.member.domain.Member; -import com.ceos.spring_vote_21st.vote.web.dto.VoteCreateRequestDTO; import jakarta.persistence.*; import lombok.*; -import javax.annotation.processing.Generated; - import static jakarta.persistence.FetchType.LAZY; import static lombok.AccessLevel.PROTECTED; @@ -16,15 +14,15 @@ @AllArgsConstructor @Builder @Entity -public class Vote { +public class Vote extends BaseEntity { @Id @GeneratedValue private Long id; @ManyToOne(fetch = LAZY) @JoinColumn(name = "member_id") - private Member member; + private Member member; // 유권자 @ManyToOne(fetch = LAZY) @JoinColumn(name = "candidate_id") - private Candidate candidate; + private Candidate candidate; //후보자 @ManyToOne(fetch = LAZY) @JoinColumn(name = "election_id") private Election election; diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/domain/ElectionStatus.java b/src/main/java/com/ceos/spring_vote_21st/vote/domain/enums/ElectionStatus.java similarity index 81% rename from src/main/java/com/ceos/spring_vote_21st/vote/domain/ElectionStatus.java rename to src/main/java/com/ceos/spring_vote_21st/vote/domain/enums/ElectionStatus.java index 1987ed0..0e33506 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/domain/ElectionStatus.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/domain/enums/ElectionStatus.java @@ -1,4 +1,4 @@ -package com.ceos.spring_vote_21st.vote.domain; +package com.ceos.spring_vote_21st.vote.domain.enums; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/domain/Section.java b/src/main/java/com/ceos/spring_vote_21st/vote/domain/enums/Section.java similarity index 84% rename from src/main/java/com/ceos/spring_vote_21st/vote/domain/Section.java rename to src/main/java/com/ceos/spring_vote_21st/vote/domain/enums/Section.java index c01ab5a..3f8401d 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/domain/Section.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/domain/enums/Section.java @@ -1,4 +1,4 @@ -package com.ceos.spring_vote_21st.vote.domain; +package com.ceos.spring_vote_21st.vote.domain.enums; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/repository/ElectionRepository.java b/src/main/java/com/ceos/spring_vote_21st/vote/repository/ElectionRepository.java index d487241..1618b7e 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/repository/ElectionRepository.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/repository/ElectionRepository.java @@ -1,7 +1,12 @@ package com.ceos.spring_vote_21st.vote.repository; import com.ceos.spring_vote_21st.vote.domain.Election; +import com.ceos.spring_vote_21st.vote.domain.enums.Section; +import io.lettuce.core.ScanIterator; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface ElectionRepository extends JpaRepository { + List findBySection(Section section); } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/repository/VoteRepository.java b/src/main/java/com/ceos/spring_vote_21st/vote/repository/VoteRepository.java index 2de330f..05d4c59 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/repository/VoteRepository.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/repository/VoteRepository.java @@ -4,14 +4,27 @@ import com.ceos.spring_vote_21st.vote.domain.Candidate; import com.ceos.spring_vote_21st.vote.domain.Election; import com.ceos.spring_vote_21st.vote.domain.Vote; +import com.ceos.spring_vote_21st.vote.web.dto.response.VoteCount4CandidateDTO; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface VoteRepository extends JpaRepository { List findAllByCandidateAndElection(Candidate candidate, Election election); -// long countByCandidateAndElection(Candidate candidate, Election election); + @Query(value = "select * from vote v where v.member_id=:memberId and v.election_id=:electionId for update", nativeQuery = true) + Optional findVoteForUpdate(@Param("memberId") Long memberId, @Param("electionId") Long electionId); + + Optional findByElectionIdAndMemberId(Long electionId, Long memberId); + + + @Query("select new com.ceos.spring_vote_21st.vote.web.dto.response.VoteCount4CandidateDTO(c.election.id, c.name, c.id, COUNT(v)) " + + "from Candidate c left join Vote v on c.id = v.candidate.id " + + "where c.election.id = :electionId " + + "group by c.id, c.name, c.election.id") + List findVoteCountsByElection(@Param("electionId") Long electionId); - boolean existsByMemberAndElection(Member member, Election election); } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/service/ElectionService.java b/src/main/java/com/ceos/spring_vote_21st/vote/service/ElectionService.java index 4ef49e1..9ca3442 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/service/ElectionService.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/service/ElectionService.java @@ -1,23 +1,26 @@ package com.ceos.spring_vote_21st.vote.service; -import com.ceos.spring_vote_21st.global.error.CustomException; -import com.ceos.spring_vote_21st.global.error.ErrorCode; -import com.ceos.spring_vote_21st.member.domain.Member; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; import com.ceos.spring_vote_21st.member.repository.MemberRepository; import com.ceos.spring_vote_21st.vote.domain.*; +import com.ceos.spring_vote_21st.vote.domain.enums.Section; import com.ceos.spring_vote_21st.vote.repository.ElectionRepository; import com.ceos.spring_vote_21st.vote.repository.VoteRepository; -import com.ceos.spring_vote_21st.vote.web.dto.*; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateAddRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateCreateRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateModifyRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.ElectionCreateRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.response.*; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; +@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) @Service @@ -35,12 +38,16 @@ public Long createElection(ElectionCreateRequestDTO dto) { Election election = Election.builder() .name(dto.getName()) .electionStatus(dto.getElectionStatus()) + .section(dto.getSection()) + .startedAt(dto.getStartedAt()) + .finishedAt(dto.getFinishedAt()) .build(); + log.info("candidatesDTO 로깅: "+ dto.getCandidates().toString()); // cascade = ALL 이므로, Election에 붙여 두면 save() 시 함께 persist 됩니다. dto.getCandidates().forEach(candidateDTO -> { Candidate c = Candidate.create(election, candidateDTO.getName(), candidateDTO.getTeam()); - election.getCandidates().add(c); + election.addCandidate(c); }); @@ -53,18 +60,23 @@ public Long createElection(ElectionCreateRequestDTO dto) { public ElectionResponseDTO getElection(Long id) { return electionRepository.findById(id) .map(ElectionResponseDTO::from) - .orElseThrow(() -> new CustomException(ErrorCode.ENTITY_NOT_EXISTS)); + .orElseThrow(() -> new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); } /** * 전체 조회 */ - public List getAllElections() { - return electionRepository.findAll() - .stream() + public List getAllElections(Section section) { + List elections = (section == null) ? + electionRepository.findAll() : + electionRepository.findBySection(section); + + return elections.stream() .map(ElectionResponseDTO::from) .collect(Collectors.toList()); } + + /* */ @@ -104,7 +116,7 @@ public ElectionResponseDTO updateElection(Long id, ElectionRequestDTO dto) { @Transactional public void deleteElection(Long id) { if (!electionRepository.existsById(id)) { - throw new CustomException(ErrorCode.ENTITY_NOT_EXISTS); + throw new CustomException(ServiceCode.INVALID_TOKEN.ENTITY_NOT_EXISTS); } electionRepository.deleteById(id); } @@ -116,10 +128,10 @@ public void deleteElection(Long id) { //create @Transactional - public Long addCandidate(CandidateCreateRequestDTO dto) { + public Long addCandidate(CandidateAddRequestDTO dto) { //find election Election findElection = electionRepository.findById(dto.getElectionId()) - .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_EXISTS)); + .orElseThrow(() -> new CustomException(ServiceCode.MEMBER_NOT_EXISTS)); Candidate candidate = Candidate.create(findElection, dto.getName(),dto.getTeam()); @@ -133,7 +145,7 @@ public Long addCandidate(CandidateCreateRequestDTO dto) { //read public List getAllCandidatesByElection(Long electionId) { Election findElection = electionRepository.findById(electionId) - .orElseThrow(()->new CustomException(ErrorCode.ENTITY_NOT_EXISTS)); + .orElseThrow(()->new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); return findElection.getCandidates().stream() .map(CandidateResponseDTO::from) @@ -142,11 +154,12 @@ public List getAllCandidatesByElection(Long electionId) { public List getAllCandidatesByElectionOrderByVoteCount(Long electionId) { Election findElection = electionRepository.findById(electionId) - .orElseThrow(()->new CustomException(ErrorCode.ENTITY_NOT_EXISTS)); + .orElseThrow(()->new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); List dtos = findElection.getCandidates().stream() .map(candidate -> { + // TODO: 어차피 후보자별 투표 수를 보여주는게 목표니까 VoteRepository에서도 조회 가능 int voteCount = voteRepository.findAllByCandidateAndElection(candidate, findElection).size(); return CandidateWithVoteResponseDTO.from(candidate, voteCount); }) @@ -161,15 +174,12 @@ public List getAllCandidatesByElectionOrderByVoteC public Long modifyCandidate(CandidateModifyRequestDTO dto) { //find election Election findElection = electionRepository.findById(dto.getElectionId()) - .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_EXISTS)); + .orElseThrow(() -> new CustomException(ServiceCode.MEMBER_NOT_EXISTS)); List candidates = findElection.getCandidates(); //find candidate - Candidate findCandidate = candidates.stream() - .filter(candidate -> candidate.getId().equals(dto.getCandidateId())) - .findFirst() - .orElseThrow(() -> new CustomException(ErrorCode.ENTITY_NOT_EXISTS)); + Candidate findCandidate = getCandidateById(dto.getCandidateId(), candidates); findCandidate.update(dto); @@ -177,6 +187,44 @@ public Long modifyCandidate(CandidateModifyRequestDTO dto) { return findCandidate.getId(); } + @Transactional + public void deleteCandidate(Long electionId, Long candidateId) { + Election findElection = electionRepository.findById(electionId) + .orElseThrow(() -> new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); + + Candidate findCandidate = findElection.getCandidates() + .stream() + .filter(candidate -> candidate.getId().equals(candidateId)) + .findFirst() + .orElseThrow(() -> new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); + + findElection.removeCandidate(findCandidate); + } + @Transactional + public void deleteAll() { + electionRepository.deleteAll(); + } + /** + * other business + * */ + private static Candidate getCandidateById(Long candidateId, List candidates) { + Candidate findCandidate = candidates.stream() + .filter(candidate -> candidate.getId().equals(candidateId)) + .findFirst() + .orElseThrow(() -> new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); + return findCandidate; + } + + + // 선겨별 투표 결과 조회 + public ElectionResultDTO getElectionResult(Long electionId) { + Election findElection = electionRepository.findById(electionId) + .orElseThrow(() -> new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); + + List voteCounts = voteRepository.findVoteCountsByElection(electionId); + + return ElectionResultDTO.from(ElectionResponseDTO.from(findElection), voteCounts); + } } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/service/VoteService.java b/src/main/java/com/ceos/spring_vote_21st/vote/service/VoteService.java index a997871..2656ef6 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/service/VoteService.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/service/VoteService.java @@ -1,21 +1,25 @@ package com.ceos.spring_vote_21st.vote.service; -import com.ceos.spring_vote_21st.global.error.CustomException; -import com.ceos.spring_vote_21st.global.error.ErrorCode; +import com.ceos.spring_vote_21st.global.exception.CustomException; +import com.ceos.spring_vote_21st.global.response.domain.ServiceCode; import com.ceos.spring_vote_21st.member.domain.CeosPosition; import com.ceos.spring_vote_21st.member.domain.Member; import com.ceos.spring_vote_21st.member.repository.MemberRepository; import com.ceos.spring_vote_21st.vote.domain.Candidate; import com.ceos.spring_vote_21st.vote.domain.Election; -import com.ceos.spring_vote_21st.vote.domain.Section; +import com.ceos.spring_vote_21st.vote.domain.enums.Section; import com.ceos.spring_vote_21st.vote.domain.Vote; import com.ceos.spring_vote_21st.vote.repository.ElectionRepository; import com.ceos.spring_vote_21st.vote.repository.VoteRepository; -import com.ceos.spring_vote_21st.vote.web.dto.VoteCreateRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.VoteCreateRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.response.CandidateResponseDTO; +import com.ceos.spring_vote_21st.vote.web.dto.response.MyVoteDTO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Transactional(readOnly = true) @RequiredArgsConstructor @Service @@ -25,41 +29,58 @@ public class VoteService { private final VoteRepository voteRepository; @Transactional - public Long voteToCandidate(VoteCreateRequestDTO dto) { + public Long voteToCandidate(VoteCreateRequestDTO dto, Long voterId) { //find Election findElection = electionRepository.findById(dto.getElectionId()) - .orElseThrow(() -> new CustomException(ErrorCode.ENTITY_NOT_EXISTS)); + .orElseThrow(() -> new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); Candidate findCandidate = findElection.getCandidates() .stream() .filter(candidate -> candidate.getId().equals(dto.getCandidateId())) .findFirst() - .orElseThrow(() -> new CustomException(ErrorCode.ENTITY_NOT_EXISTS)); - Member findMember = memberRepository.findById(dto.getMemberId()) - .orElseThrow(() -> new CustomException(ErrorCode.ENTITY_NOT_EXISTS)); + .orElseThrow(() -> new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); + Member findMember = memberRepository.findById(voterId) + .orElseThrow(() -> new CustomException(ServiceCode.ENTITY_NOT_EXISTS)); //create //1인 한번 투표 - if (voteRepository.existsByMemberAndElection(findMember, findElection)) - throw new CustomException(ErrorCode.DUPLICATE_VOTE); + if (voteRepository.findVoteForUpdate(findMember.getId(), findElection.getId()).isPresent()) + throw new CustomException(ServiceCode.DUPLICATE_VOTE); CeosPosition position = findMember.getPosition(); if (findElection.getSection().equals(Section.FRONT_KING) && !position.equals(CeosPosition.FRONTEND)) { //SRS: 본인 파트에만 투표 가능 - throw new CustomException(ErrorCode.POSITION_NOT_MATCH); + throw new CustomException(ServiceCode.POSITION_NOT_MATCH); } else if (findElection.getSection().equals(Section.BACK_KING) && !position.equals(CeosPosition.BACKEND)) { - throw new CustomException(ErrorCode.POSITION_NOT_MATCH); + throw new CustomException(ServiceCode.POSITION_NOT_MATCH); //SRS: 본인 파트에만 투표 가능 } else if (findElection.getSection().equals(Section.DEMO_DAY)) { //SRS: 본인 팀은 투표 불가능 if (findCandidate.getTeam().equals(findMember.getTeam())) - throw new CustomException(ErrorCode.CANNOT_VOTE_SAME_TEAM); + throw new CustomException(ServiceCode.CANNOT_VOTE_SAME_TEAM); } Vote vote = Vote.create(findMember, findCandidate, findElection); return voteRepository.save(vote).getId(); } + + public MyVoteDTO getMyVote(Long electionId, Long memberId) { + if (memberId == null) { + return MyVoteDTO.create(false, null); + } + + Optional optionalVote = voteRepository.findByElectionIdAndMemberId(electionId, memberId); + + if (optionalVote.isPresent()) { + // 투표한 경우 → true + 후보자 정보 반환 + Vote vote = optionalVote.get(); + return MyVoteDTO.create(true, CandidateResponseDTO.from(vote.getCandidate())); + } else { + // 투표 안 한 경우 + return MyVoteDTO.create(false, null); + } + } } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/controller/ElectionController.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/controller/ElectionController.java new file mode 100644 index 0000000..aa3422f --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/controller/ElectionController.java @@ -0,0 +1,83 @@ +package com.ceos.spring_vote_21st.vote.web.controller; + +import com.ceos.spring_vote_21st.global.response.dto.CommonResponse; +import com.ceos.spring_vote_21st.vote.domain.enums.Section; +import com.ceos.spring_vote_21st.vote.service.ElectionService; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateAddRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateCreateRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.CandidateModifyRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.request.ElectionCreateRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.response.CandidateResponseDTO; +import com.ceos.spring_vote_21st.vote.web.dto.response.CandidateWithVoteResponseDTO; +import com.ceos.spring_vote_21st.vote.web.dto.response.ElectionResultDTO; +import com.ceos.spring_vote_21st.vote.web.dto.response.ElectionResponseDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/elections") +@RequiredArgsConstructor +public class ElectionController { + + private final ElectionService electionService; + + + + /** + * read + */ + @GetMapping("/{electionId}") + public ResponseEntity> getElection(@PathVariable Long electionId) { + ElectionResponseDTO dto = electionService.getElection(electionId); + return ResponseEntity.ok(CommonResponse.success(dto)); + } + + // 전체 조회 + @GetMapping + public ResponseEntity>> getAllElections(@RequestParam(required = false) Section section) { + List list = electionService.getAllElections(section); + return ResponseEntity.ok(CommonResponse.success(list)); + } + + + + /** + * // —— 후보(Candidate) 관련 —— // + */ + + + + // 선거별 후보자 조회 + @GetMapping("/{electionId}/candidates") + public ResponseEntity>> getAllCandidatesByElection(@PathVariable Long electionId) { + List dtos = + electionService.getAllCandidatesByElection(electionId); + return ResponseEntity.ok(CommonResponse.success(dtos)); + } + + // 득표 순 정렬 조회 + @GetMapping("/{electionId}/candidates/sorts/vote-count") + public ResponseEntity>> getAllCandidatesByVoteCount( + @PathVariable Long electionId) { + List list = + electionService.getAllCandidatesByElectionOrderByVoteCount(electionId); + return ResponseEntity.ok(CommonResponse.success(list)); + } + + + /** + * other business + */ + + // 선거별 투표 결과 조회 + @GetMapping("/{electionId}/results") + public ResponseEntity> getElectionResult(@PathVariable Long electionId) { + ElectionResultDTO dto = electionService.getElectionResult(electionId); + + return ResponseEntity.ok(CommonResponse.success(dto)); + } +} + diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/controller/VoteController.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/controller/VoteController.java new file mode 100644 index 0000000..bef07d6 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/controller/VoteController.java @@ -0,0 +1,40 @@ +package com.ceos.spring_vote_21st.vote.web.controller; + +import com.ceos.spring_vote_21st.global.response.dto.CommonResponse; +import com.ceos.spring_vote_21st.security.auth.user.detail.CustomUserDetails; +import com.ceos.spring_vote_21st.vote.service.VoteService; +import com.ceos.spring_vote_21st.vote.web.dto.request.VoteCreateRequestDTO; +import com.ceos.spring_vote_21st.vote.web.dto.response.MyVoteDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class VoteController { + + private final VoteService voteService; + + /** + * 후보에 투표합니다. + * @param dto electionId, candidateId, memberId 가 포함된 요청 DTO + * @return 생성된 Vote 엔티티 ID + */ + @PostMapping("/elections/{electionId}/votes") + public ResponseEntity> voteToCandidate( + @RequestBody VoteCreateRequestDTO dto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long voteId = voteService.voteToCandidate(dto, userDetails.getUserId()); + return ResponseEntity.ok(CommonResponse.success(voteId)); + } + + // 투표 여부 및 투표한 후보 + @GetMapping("/elections/{electionId}/my-vote") + public MyVoteDTO getMyVote(@PathVariable Long electionId, @AuthenticationPrincipal CustomUserDetails userDetails) { + MyVoteDTO dto = voteService.getMyVote(electionId, userDetails != null ? userDetails.getUserId() : null); + + return dto; + } +} diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/VoteResult4CandidateResponseDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/VoteResult4CandidateResponseDTO.java deleted file mode 100644 index 6de3ed6..0000000 --- a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/VoteResult4CandidateResponseDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.ceos.spring_vote_21st.vote.web.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@AllArgsConstructor -@Builder -@Getter -public class VoteResult4CandidateResponseDTO { - private long electionId; - private String candidateName; - private int voteCount; -} diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateAddRequestDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateAddRequestDTO.java new file mode 100644 index 0000000..8038221 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateAddRequestDTO.java @@ -0,0 +1,16 @@ +package com.ceos.spring_vote_21st.vote.web.dto.request; + +import com.ceos.spring_vote_21st.member.domain.CeosTeam; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CandidateAddRequestDTO { + private Long electionId; + + private String name; + + private CeosTeam team; + +} diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateCreateRequestDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateCreateRequestDTO.java similarity index 78% rename from src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateCreateRequestDTO.java rename to src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateCreateRequestDTO.java index 4c9db70..f6be646 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateCreateRequestDTO.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateCreateRequestDTO.java @@ -1,4 +1,4 @@ -package com.ceos.spring_vote_21st.vote.web.dto; +package com.ceos.spring_vote_21st.vote.web.dto.request; import com.ceos.spring_vote_21st.member.domain.CeosTeam; import com.ceos.spring_vote_21st.vote.domain.Election; @@ -8,8 +8,6 @@ @Getter @NoArgsConstructor public class CandidateCreateRequestDTO { - private Long electionId; - private String name; private CeosTeam team; diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateModifyRequestDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateModifyRequestDTO.java similarity index 79% rename from src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateModifyRequestDTO.java rename to src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateModifyRequestDTO.java index f60e9aa..8f7a947 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateModifyRequestDTO.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/CandidateModifyRequestDTO.java @@ -1,4 +1,4 @@ -package com.ceos.spring_vote_21st.vote.web.dto; +package com.ceos.spring_vote_21st.vote.web.dto.request; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/ElectionCreateRequestDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/ElectionCreateRequestDTO.java similarity index 61% rename from src/main/java/com/ceos/spring_vote_21st/vote/web/dto/ElectionCreateRequestDTO.java rename to src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/ElectionCreateRequestDTO.java index c40caee..0b62c67 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/ElectionCreateRequestDTO.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/ElectionCreateRequestDTO.java @@ -1,10 +1,9 @@ -package com.ceos.spring_vote_21st.vote.web.dto; +package com.ceos.spring_vote_21st.vote.web.dto.request; // ElectionRequestDTO.java -import com.ceos.spring_vote_21st.vote.domain.ElectionStatus; -import com.ceos.spring_vote_21st.vote.domain.Section; -import lombok.Builder; +import com.ceos.spring_vote_21st.vote.domain.enums.ElectionStatus; +import com.ceos.spring_vote_21st.vote.domain.enums.Section; import lombok.Getter; import lombok.NoArgsConstructor; @@ -21,6 +20,10 @@ public class ElectionCreateRequestDTO { private Section section; + private LocalDateTime startedAt; + + private LocalDateTime finishedAt; + private List candidates = new ArrayList<>(); } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/VoteCreateRequestDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/VoteCreateRequestDTO.java similarity index 75% rename from src/main/java/com/ceos/spring_vote_21st/vote/web/dto/VoteCreateRequestDTO.java rename to src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/VoteCreateRequestDTO.java index 221d5d7..593e8f3 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/VoteCreateRequestDTO.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/request/VoteCreateRequestDTO.java @@ -1,4 +1,4 @@ -package com.ceos.spring_vote_21st.vote.web.dto; +package com.ceos.spring_vote_21st.vote.web.dto.request; import com.ceos.spring_vote_21st.vote.domain.Vote; import lombok.Getter; @@ -8,6 +8,5 @@ @NoArgsConstructor public class VoteCreateRequestDTO { private Long electionId; - private Long memberId; private Long candidateId; } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateResponseDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/CandidateResponseDTO.java similarity index 75% rename from src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateResponseDTO.java rename to src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/CandidateResponseDTO.java index f7b7e01..1c15957 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateResponseDTO.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/CandidateResponseDTO.java @@ -1,5 +1,6 @@ -package com.ceos.spring_vote_21st.vote.web.dto; +package com.ceos.spring_vote_21st.vote.web.dto.response; +import com.ceos.spring_vote_21st.member.domain.CeosTeam; import com.ceos.spring_vote_21st.vote.domain.Candidate; import lombok.*; @@ -11,12 +12,15 @@ public class CandidateResponseDTO { private Long id; private String name; private Long electionId; + private CeosTeam team; public static CandidateResponseDTO from(Candidate entity) { return CandidateResponseDTO.builder() .id(entity.getId()) .name(entity.getName()) .electionId(entity.getElection().getId()) + .team(entity.getTeam()) .build(); } + } \ No newline at end of file diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateWithVoteResponseDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/CandidateWithVoteResponseDTO.java similarity index 81% rename from src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateWithVoteResponseDTO.java rename to src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/CandidateWithVoteResponseDTO.java index 06a3222..7a36204 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/CandidateWithVoteResponseDTO.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/CandidateWithVoteResponseDTO.java @@ -1,4 +1,4 @@ -package com.ceos.spring_vote_21st.vote.web.dto; +package com.ceos.spring_vote_21st.vote.web.dto.response; import com.ceos.spring_vote_21st.vote.domain.Candidate; @@ -9,16 +9,16 @@ @NoArgsConstructor @Getter public class CandidateWithVoteResponseDTO { - private Long id; - private String name; private Long electionId; + private Long candidateId; + private String name; private int voteCount; public static CandidateWithVoteResponseDTO from(Candidate candidate, int voteCount) { return CandidateWithVoteResponseDTO.builder() - .id(candidate.getId()) - .name(candidate.getName()) .electionId(candidate.getElection().getId()) + .candidateId(candidate.getId()) + .name(candidate.getName()) .voteCount(voteCount) .build(); } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/ElectionResponseDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/ElectionResponseDTO.java similarity index 58% rename from src/main/java/com/ceos/spring_vote_21st/vote/web/dto/ElectionResponseDTO.java rename to src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/ElectionResponseDTO.java index c564471..2a3d2ec 100644 --- a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/ElectionResponseDTO.java +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/ElectionResponseDTO.java @@ -1,9 +1,9 @@ -package com.ceos.spring_vote_21st.vote.web.dto; +package com.ceos.spring_vote_21st.vote.web.dto.response; import com.ceos.spring_vote_21st.vote.domain.Election; -import com.ceos.spring_vote_21st.vote.domain.ElectionStatus; -import com.ceos.spring_vote_21st.vote.domain.Section; +import com.ceos.spring_vote_21st.vote.domain.enums.ElectionStatus; +import com.ceos.spring_vote_21st.vote.domain.enums.Section; import lombok.Builder; import lombok.Getter; @@ -19,7 +19,6 @@ public class ElectionResponseDTO { private ElectionStatus electionStatus; private LocalDateTime startedAt; private LocalDateTime finishedAt; - private List candidates; private Section section; public static ElectionResponseDTO from(Election e) { @@ -29,13 +28,6 @@ public static ElectionResponseDTO from(Election e) { .electionStatus(e.getElectionStatus()) .startedAt(e.getStartedAt()) .finishedAt(e.getFinishedAt()) - .candidates(e.getCandidates().stream() - .map(c -> CandidateResponseDTO.builder() - .electionId(e.getId()) - .name(c.getName()) - .id(c.getId()) - .build()) - .collect(Collectors.toList())) .section(e.getSection()) .build(); } diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/ElectionResultDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/ElectionResultDTO.java new file mode 100644 index 0000000..646c08d --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/ElectionResultDTO.java @@ -0,0 +1,23 @@ +package com.ceos.spring_vote_21st.vote.web.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +@AllArgsConstructor +@Builder +@Getter +@NoArgsConstructor +public class ElectionResultDTO { + private ElectionResponseDTO election; + private List voteCounts; + + public static ElectionResultDTO from(ElectionResponseDTO electionResponseDTO, List voteCounts) { + return ElectionResultDTO.builder() + .election(electionResponseDTO) + .voteCounts(voteCounts) + .build(); + } +} diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/MyVoteDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/MyVoteDTO.java new file mode 100644 index 0000000..2ba6b52 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/MyVoteDTO.java @@ -0,0 +1,20 @@ +package com.ceos.spring_vote_21st.vote.web.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder(access = AccessLevel.PROTECTED) +@Getter +public class MyVoteDTO { + private boolean isVoted; + private CandidateResponseDTO candidate; + + public static MyVoteDTO create(boolean isVoted, CandidateResponseDTO candidateResponseDTO) { + return MyVoteDTO.builder() + .isVoted(isVoted) + .candidate(candidateResponseDTO) + .build(); + } +} diff --git a/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/VoteCount4CandidateDTO.java b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/VoteCount4CandidateDTO.java new file mode 100644 index 0000000..a3b8454 --- /dev/null +++ b/src/main/java/com/ceos/spring_vote_21st/vote/web/dto/response/VoteCount4CandidateDTO.java @@ -0,0 +1,20 @@ +package com.ceos.spring_vote_21st.vote.web.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class VoteCount4CandidateDTO { + private long electionId; + private String candidateName; + private long candidateId; + private long voteCount; + + public VoteCount4CandidateDTO(long electionId, String candidateName, long candidateId, long voteCount) { + this.electionId = electionId; + this.candidateName = candidateName; + this.candidateId = candidateId; + this.voteCount = voteCount; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c03a1a0..270d642 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -23,3 +23,19 @@ jwt: secret: ${JWT_SECRET_KEY} # secret key는 base64로 인코딩해놨음 access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} # 1분(ms 단위) refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} # 1일 (ms 단위) + +server: + port: 8084 + ssl: + enabled: true + key-store: file:./keystore.p12 + key-store-password: password + key-store-type: PKCS12 +#❯ keytool -genkeypair -alias hanihome-local -keyalg RSA -keysize 2048 -storetype PKCS12 \ +# -keystore keystore.p12 -validity 3650 \ +# -storepass password \ +# -dname "CN=localhost" + +logging: + level: + org.springframework.security: DEBUG \ No newline at end of file diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 0173290..e427c61 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -12,5 +12,15 @@ spring: hibernate: format_sql: true + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + +jwt: + secret: ${JWT_SECRET_KEY} # secret key는 base64로 인코딩해놨음 + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} # 1분(ms 단위) + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} # 1일 (ms 단위) + server: - port: ${SERVER_PORT} \ No newline at end of file + port: ${SERVER_PORT} diff --git a/src/test/java/com/ceos/spring_vote_21st/CeosBeSpringVote21stApplicationTests.java b/src/test/java/com/ceos/spring_vote_21st/CeosBeSpringVote21stApplicationTests.java index 16fc6d2..964d9cb 100644 --- a/src/test/java/com/ceos/spring_vote_21st/CeosBeSpringVote21stApplicationTests.java +++ b/src/test/java/com/ceos/spring_vote_21st/CeosBeSpringVote21stApplicationTests.java @@ -1,11 +1,24 @@ package com.ceos.spring_vote_21st; +import io.github.cdimascio.dotenv.Dotenv; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class CeosBeSpringVote21stApplicationTests { + @BeforeAll + static void setUp() { + Dotenv env = Dotenv.configure() + .filename(".env.test") + .ignoreIfMissing() + .load(); + env.entries().forEach(dotenvEntry -> System.setProperty(dotenvEntry.getKey(), dotenvEntry.getValue())); + + } @Test void contextLoads() { } diff --git a/src/test/java/com/ceos/spring_vote_21st/vote/service/VoteServiceTest.java b/src/test/java/com/ceos/spring_vote_21st/vote/service/VoteServiceTest.java new file mode 100644 index 0000000..ee915df --- /dev/null +++ b/src/test/java/com/ceos/spring_vote_21st/vote/service/VoteServiceTest.java @@ -0,0 +1,30 @@ +package com.ceos.spring_vote_21st.vote.service; + +import io.github.cdimascio.dotenv.Dotenv; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.*; + +@ActiveProfiles("test") +@SpringBootTest +@Transactional +class VoteServiceTest { + @BeforeAll + static void setUp() { + Dotenv env = Dotenv.configure() + .filename(".env.test") + .ignoreIfMissing() + .load(); + env.entries().forEach(dotenvEntry -> System.setProperty(dotenvEntry.getKey(), dotenvEntry.getValue())); + + } + + @Test + void hello() { + + } +} \ No newline at end of file