diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 18b4d62..f48c5fd 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -9,9 +9,9 @@ name: Java CI with Gradle on: push: - branches: [ "master", "develop" ] + branches: [ "master", "dev" ] pull_request: - branches: [ "master", "deveop" ] + branches: [ "master", "dev" ] jobs: build: @@ -44,6 +44,10 @@ jobs: - name: Grant permission to Gradle wrapper run: chmod +x ./gradlew + + # Docker Clean + - name: Clean up Docker disk space + run: docker system prune -af || true - name: Build with Gradle run: ./gradlew bootJar -x test @@ -77,6 +81,12 @@ jobs: sudo docker rmi ${{ secrets.DOCKER_USERNAME }}/ceos-vote-dream || true sudo docker pull ${{ secrets.DOCKER_USERNAME }}/ceos-vote-dream sudo docker run -e SPRING_PROFILES_ACTIVE=prod \ + -e SPRING_DATASOURCE_URL=${{ secrets.SPRING_DATASOURCE_URL }} \ + -e SPRING_DATASOURCE_USERNAME=${{ secrets.SPRING_DATASOURCE_USERNAME }} \ + -e SPRING_DATASOURCE_PASSWORD=${{ secrets.SPRING_DATASOURCE_PASSWORD }} \ + -e JWT_SECRET=${{ secrets.JWT_SECRET }} \ + -e JWT_ACCESS_EXPIRE=${{ secrets.JWT_ACCESS_EXPIRE }} \ + -e JWT_REFRESH_EXPIRE=${{ secrets.JWT_REFRESH_EXPIRE }} \ --name ceos-vote-dream \ -p 8080:8080 -d ${{ secrets.DOCKER_USERNAME }}/ceos-vote-dream diff --git a/README.md b/README.md index d725d9e..b50c281 100644 --- a/README.md +++ b/README.md @@ -3,210 +3,70 @@ ceos back-end 21st vote project

- --- -# 1. 프백 합동과제 협업 환경 세팅 -### (1) git convention은 어떻게 세팅했는가? -**1. commit & pr** : commit 메세지와 pr 제목을 동일하게 작성합니다. -pr 메세지의 경우 전달사항을 간략하게 적습니다. - -**2. issue convention** : pr을 올릴 시 어떤 이슈인지 태그를 사용합니다. (feat/fix/docs 등) +# [프백 합동과제] -**3. branch 전략** : 현재 과제는 브랜치 1개만 이용했지만 프로젝트에서는 feature, dev, master 3개 브랜치를 이용하여 배포 및 기능 구현 브랜치를 분리하였습니다. +### (1) 프백 합동과제 요약 +- 20기 파트장 및 데모데이 투표 서비스 만들기 +- 배포 링크 : https://vote-dream.p-e.kr/swagger-ui/index.html#/ +
-### (2) repository 세팅 -**- 프론트/백 repository** -: organization에서 back-end, front-end 레포지토리 개발 공간을 마련했습니다. - -
- -### (3) 개발 스타일 맞추기 -**1. 폴더 구조** -- **도메인 중심 설계 기반** -- **domain / global 폴더 구분** : domain 패키지 아래에 auth, jwt, user 등 기능별 하위 도메인을 분리했습니다. - global 패키지에는 공통적인 사용되는 설정 클래스(config), 응답 및 예외 처리(common), 유틸 함수(util) 등을 분리했습니다. -- **계층형 아키텍쳐 구조** : 각 도메인(auth, jwt, user 등) 하위에 controller, service, repository 등의 폴더를 두었습니다. +### (2) ERD 설계 +![image](https://github.com/user-attachments/assets/af8dfbe1-67a3-450f-8a7e-6b3bfecf3a33) +> [**주요 고려사항**]
+> 1. 같은 기능을 담당하는 api를 데모데이/파트장으로 따로 만들 것인가? -> X
+> 2. 모아서 만들되, voteType의 enum타입으로 데모데이/파트장 투표를 구별하자!
+> 3. voteType으로 Vote table에서 데모데이/파트장 투표 구별 -> Vote table의 vote_id로 voteRecord(투표 내역), voteItem(투표 항목)을 조회합니다.
-**2. 파일명, 변수명 convention** - -**- 기술 스택 선정** -| 언어 | Java 17 | -| --- | --- | -| framework | spring Boot 3.x | -| 빌드 도구 | Gradle | -| DB | MySQL | -| ORM | Spring data jpa + Hibernate | -| API 문서화 | Swagger | -| 테스트 | JUnit5 + Mockito + Swagger | -| 로그 | Spring Boot Logging (로그 포맷 통일 필요) | -| 배포 | Docker + AWS EC2 | -| 형상 관리 | Git + github | -| 인증/인가 | Spring Security + JWT | +### (3) 프론트엔드의 FIGMA 설계 +![image](https://github.com/user-attachments/assets/2aff6a5f-5a50-4026-8ca2-abcf04a7f4f9) +![image](https://github.com/user-attachments/assets/5cb2389a-ec7f-49a1-9a90-859dc65b3763)
- -**- git repo setup** -- **Repository 이름** : DearDream / VoteDream -- **branch 전략** - - **main** : 운영용 - - **dev** : 개발 통합 - - feature/* : 기능 단위 브랜치 → 뒤에는 feature/#1 과 같이 순서를 진행시킴 - - → **feature/기능 단위** 브랜치로 하기로 결정 - - 예시) [feature/attendance-fix], [feature/attendance-pagination], [feature/celebrationMsg-admin] - -- **PR 템플릿 생성** - - ```java - ## 작업 개요 - - 작업 내용 요약 - - ## 주요 변경 사항 - - 상세 변경내용 bulltet 형식으로 - - ## 참고 사항 - - 테스트 방법/ 관련 이슈 등 - ``` - -- **Issue 템플릿** - - `FEAT` → 새로운 기능 추가 - - `REFACTOR` → 코드 리팩토링 - - `TEST` → 테스트 코드, 리팩토링 테스트 코드 추가 - - `FIX` → 버그 수정 - - `DOCS` → 문서 수정 - - `BAD` → Write bad code that needs to be improved (리팩토링 필요하다) - - `CHORE` → 기타 변경 사항 - -- pr 작성 시 아래 방식으로 작성 - -→ [FIX] ~~ - -→ [CHORE] sorting the numbers - -→ [DOCS] api 명세서 작성 - -- pr 작성 할 때 라벨 선택 - -
-**3. - 프로젝트 구조 예시** -
-``` -com.example.project -├── domain -│ ├── user -│ │ ├── controller -│ │ ├── service -│ │ ├── repository -│ │ ├── dto -| | ├── converter -| | ├── exception -│ │ └── entity -│ └── ... (다른 도메인) -│ -├── global -│ ├── config # 전역 설정 (Security, CORS 등) -│ ├── exception # 전역 예외 처리 -│ ├── util # 유틸리티 클래스 -│ ├── common # 공통 모듈 (ResponseDto 등) -│ ├── security # JWT, 필터, 인증/인가 설정 등 -│ └── advice # 예외 핸들링 @ControllerAdvice 등 -│ -└── Application.java # main 클래스 - -``` - ---- - -### **(4) response format, exception 관리** -**4-1. Response Format 설계 방식** -- 프로젝트 전반에서 모든 api 응답은 공통 포맷(ApiResponseObject)으로 응답합니다. -- 기본 구조 - ``` - { - "isSuccess": true, - "code": "SUCCESS", - "message": "요청이 성공적으로 처리되었습니다.", - "result": { ... } - } - ``` -- 이를 위해 아래와 같은 DTO를 사용합니다.
+### (4) API 설계 +![image](https://github.com/user-attachments/assets/a959ed60-c3d1-4f65-ad67-a2548824bd4a) +> [**고려사항/어려웠던 점/리팩토링사항**]
+> 1. api 간단 설명
+> ``` +> (1) vote/vote : 투표하는 api +> (2) vote/status : 개인 투표 여부 확인 api +> (3) vote/results : 전체 투표 결과를 반환하는 api +> (4) vote/items : 투표 항목 조회 api +> (5) vote/ping : 테스트용 동적 메서드 api (DEMODAY와 PARTLEADER enum 구분을 확인하기 위함..) +> (6) user/register : 회원가입 api +> (7) user/login : 로그인 api +> (8) user/check : 로그인 확인 api +> (9) user/reissue : 리프레쉬 토큰 관련 api +> ``` +> 2. local로 테스트를 하다가 막날에 rds 데이터베이스 스키마를 만들며 연결 이슈가 있었지만 가볍게 해결하였고..
-**(1) ApiResponseObject** +> 3. 토큰 포함한 api 테스트를 swagger에서 할 수 있다는 사실을 처음 알았습니다. **(여지껏 토큰을 열심히 넣어가며 postman에서 테스트 했는데)** 배포한 후 pr을 올리면 설정값에 따라 자동으로 swagger가 업데이트 되고, 테스트를 간편하게 해볼 수 있다는 점이 편리했습니다.
-**(2) ApiResponseJwtDto** : 로그인 시 JWT 토큰 응답 전용 +> 4. rds에 더미 데이터가(프론트엔드/백엔드 팀원분들의 이름, 팀명 리스트) 들어가 있어야 해서 **CommandLineRunner**라는 메서드를 통해 기본 데이터 값을 데이터베이스에 넣어 보았습니다. 처음 실행될 때에만 넣어주고 이후에는 신경을 쓰지 않아도 되어서 좋았고.. 세상에는 편리한 메서드가 많은 것 같아요..
-**(3) JwtDto** : accessToken, refreshToken 포함한 내부 DTO +> 5. 리팩토링 1 : 업데이트가 너무 잘 되어서 테스트로 넣어둔 api도 같이 올라가서.. (/ping) 지우는 리팩토링을 할 계획입니다.
+> 6. 리팩토링 2 : voteController와 voteService 간 타입을 바꿔주는 등의 잡다한 작업을 converter라는 폴더를 만들어 따로 빼려고 생각중입니다. **(controller - converter - service 구조가 되는 셈.)** 매번 계층 구조 분리의 중요성에 대해 각 과목에서 공부를 하게 되는데 막상 각 계층에 맞는 작업만을 잘 분리해서 넣는건 고민할만한 문제가 되는 것 같습니다.
-**4-2. 예외 처리 방식 요약** -- 전역 예외 처리(GlobalExceptionHandler) - -> @RestControllerAdvice + @ExceptionHandler로 통합 처리합니다. -- 예시 : +> 7. 리팩토링 3 : 이것저것 테스트 하다가 발견한 오류 : 팀원분들의 teamId와 Team table의 teamId가 매칭이 뭔가 안되더군요..뭔가 꼬인 것 같아 리팩토링 중에 있습니다.
-``` -{ - "isSuccess": false, - "code": "VALIDATION_ERROR", - "message": "비밀번호는 8자 이상이어야 합니다.", - "result": null -} -``` +> 8. 리팩토링 4 : 로그인/회원가입에는 테스트 코드가 있고, vote에 테스트 코드를 추가하려 합니다. -
-# 2. 로그인/회원가입 기능 구현 -**- DTO 구조 예시** -- 회원가입 요청 DTO : SignUpRequestDto -``` -{ - "loginId": "string", - "password": "string", - "email": "string", - "part": "FRONTEND", - "username": "string", - "team": "DEARDREAM" -} -``` - -- 로그인 요청 DTO : LoginRequestDto -``` -{ - "loginId": "string", - "password": "string" -} -``` - -- 로그인 응답 DTO : ApiResponseJwtDto -``` -{ - "isSuccess": true, - "code": "SUCCESS", - "message": "로그인 성공", - "result": { - "accessToken": "string", - "refreshToken": "string" - } -} -```
---- - - -# 3. 수동 배포 -- AWS EC2 기반 수동 배포. -- http://52.78.76.206:8080/swagger-ui/index.html#/ -- 프론트엔드와 연결 예시 -![image](https://github.com/user-attachments/assets/a7b6a07c-0e29-4052-b24d-f495b261ae9c) +### (5) 배포 +1. EC2 route53으로 https 배포하려고 했습니다. 그러나 1개 등록 시 달마다 0.5달러를 내야했기에 nginx를 이용하여 무료로 바꿨습니다.. +2. 도메인은 한글에서 무료로 발급받았는데 kro.~로 시작하는 도메인은 예뻐서 그런지 쓰는 사람이 많아서 ssl 인증이 잘 안됐습니다! ..그래서 못생긴 도메인으로 다시 바꿨습니다. + +
-![image](https://github.com/user-attachments/assets/0bd79ea4-1503-43c2-ab1f-5293e558c64a) diff --git a/build.gradle b/build.gradle index 2ab36ad..e4a09fc 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,9 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // test + testImplementation 'org.springframework.security:spring-security-test' + // MySQL Connector implementation("com.mysql:mysql-connector-j:9.2.0") diff --git a/src/main/java/vote/dream/server/domain/user/controller/UserController.java b/src/main/java/vote/dream/server/domain/user/controller/UserController.java index 76c9774..70786ab 100644 --- a/src/main/java/vote/dream/server/domain/user/controller/UserController.java +++ b/src/main/java/vote/dream/server/domain/user/controller/UserController.java @@ -1,29 +1,56 @@ package vote.dream.server.domain.user.controller; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import vote.dream.server.domain.jwt.dto.JwtDto; +import vote.dream.server.domain.jwt.filter.JwtUtil; import vote.dream.server.domain.user.dto.request.LoginRequestDto; import vote.dream.server.domain.user.dto.request.SignUpRequestDto; +import vote.dream.server.domain.user.entity.User; import vote.dream.server.domain.user.service.AuthService; import vote.dream.server.global.apiPayload.ApiResponse; +import vote.dream.server.global.apiPayload.exception.GeneralException; +import vote.dream.server.global.apiPayload.status.ErrorStatus; import vote.dream.server.global.apiPayload.status.SuccessStatus; +import java.security.SignatureException; +import java.util.HashMap; +import java.util.Map; + @RestController @AllArgsConstructor @RequestMapping("/api/v1/users") public class UserController { private final AuthService authService; + private final JwtUtil jwtUtil; @PostMapping("/login") - public ApiResponse login(@RequestBody LoginRequestDto request) { - JwtDto jwtDto = authService.login(request.loginId(), request.password()); - return ApiResponse.onSuccess(jwtDto); + public ApiResponse> login(@RequestBody LoginRequestDto request, + HttpServletResponse response) { + Map result= authService.login(request.loginId(), request.password()); + + JwtDto jwt = result.keySet().iterator().next(); // JwtDto + User user = result.get(jwt); + + // 쿠키 설정 + Cookie cookie = new Cookie("refreshToken", jwt.refreshToken()); + cookie.setHttpOnly(true); + cookie.setSecure(true); // HTTPS 환경일 때 true + cookie.setPath("/"); + cookie.setMaxAge(7 * 24 * 60 * 60); // 7일 + response.addCookie(cookie); + + // 응답 바디 + Map responseBody = new HashMap<>(); + responseBody.put("accessToken", jwt.accessToken()); + responseBody.put("user", user); + + return ApiResponse.onSuccess(responseBody); } @PostMapping("/register") @@ -31,4 +58,30 @@ public ApiResponse signUp(@RequestBody SignUpRequestDto request) { authService.register(request); return ApiResponse.onSuccess(SuccessStatus._CREATED); } + + @GetMapping("/check") + public ApiResponse checkId(@RequestParam String loginId){ + authService.checkLoginId(loginId); + return ApiResponse.onSuccess(SuccessStatus._OK); + } + + @PostMapping("/reissue") + public ApiResponse reissueToken(HttpServletRequest request, HttpServletResponse response) throws SignatureException { + String refreshToken = extractRefreshFromCookie(request); + + return ApiResponse.onSuccess(authService.checkRefreshToken(refreshToken)); + + } + + private String extractRefreshFromCookie(HttpServletRequest request) { + if(request.getCookies() == null) + return null; + + for(Cookie cookie : request.getCookies()) { + if("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } } diff --git a/src/main/java/vote/dream/server/domain/user/entity/User.java b/src/main/java/vote/dream/server/domain/user/entity/User.java index 079a175..35a8780 100644 --- a/src/main/java/vote/dream/server/domain/user/entity/User.java +++ b/src/main/java/vote/dream/server/domain/user/entity/User.java @@ -32,7 +32,10 @@ public class User { private String username; @NotNull + @Enumerated(EnumType.STRING) private Team team; + @NotNull + @Enumerated(EnumType.STRING) private Part part; } diff --git a/src/main/java/vote/dream/server/domain/user/repository/UserRepository.java b/src/main/java/vote/dream/server/domain/user/repository/UserRepository.java index d3e3cc9..05603bf 100644 --- a/src/main/java/vote/dream/server/domain/user/repository/UserRepository.java +++ b/src/main/java/vote/dream/server/domain/user/repository/UserRepository.java @@ -1,6 +1,8 @@ package vote.dream.server.domain.user.repository; import org.springframework.data.jpa.repository.JpaRepository; +import vote.dream.server.domain.user.entity.Part; +import vote.dream.server.domain.user.entity.Team; import vote.dream.server.domain.user.entity.User; import java.util.Optional; @@ -11,4 +13,7 @@ public interface UserRepository extends JpaRepository { boolean existsByLoginId(String loginId); boolean existsByEmail(String email); + // 이름 + 파트 + 팀 이 모두 같을 수는 없음 + boolean existsByUsernameAndPartAndTeam (String username, Part part, Team team); + } diff --git a/src/main/java/vote/dream/server/domain/user/service/AuthService.java b/src/main/java/vote/dream/server/domain/user/service/AuthService.java index 53e4612..96a914e 100644 --- a/src/main/java/vote/dream/server/domain/user/service/AuthService.java +++ b/src/main/java/vote/dream/server/domain/user/service/AuthService.java @@ -1,5 +1,6 @@ package vote.dream.server.domain.user.service; +import jakarta.servlet.http.Cookie; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -13,6 +14,10 @@ import vote.dream.server.global.apiPayload.exception.GeneralException; import vote.dream.server.global.apiPayload.status.ErrorStatus; +import java.security.SignatureException; +import java.util.HashMap; +import java.util.Map; + @Service @RequiredArgsConstructor public class AuthService { @@ -22,12 +27,12 @@ public class AuthService { private final PasswordEncoder passwordEncoder; // 로그인 로직 - public JwtDto login(String loginId, String password) { + public Map login(String loginId, String password) { User newUser = userRepository.findByLoginId(loginId) .orElseThrow(() -> new GeneralException(ErrorStatus._USER_NOT_FOUND)); if(!passwordEncoder.matches(password, newUser.getPassword())) { - throw new GeneralException(ErrorStatus._PASSWORD_NOT_MATCH); + throw new GeneralException(ErrorStatus._BAD_PASSWORD); } // CustomDetails 생성 @@ -37,7 +42,13 @@ public JwtDto login(String loginId, String password) { String accessToken = jwtUtil.createJwtAccessToken(customDetails); String refreshToken = jwtUtil.createJwtRefreshToken(customDetails); - return new JwtDto(accessToken, refreshToken); + + JwtDto jwt = new JwtDto(accessToken, refreshToken); + + Map result= new HashMap<>(); + result.put(jwt, newUser); + + return result; } // 회원가입 로직 @@ -45,7 +56,7 @@ public JwtDto login(String loginId, String password) { public void register(SignUpRequestDto request) { // 로그인 아이디 중복 체크 if(userRepository.existsByLoginId(request.loginId())) { - throw new GeneralException(ErrorStatus._DUPLICATED_LOGINID); + throw new GeneralException(ErrorStatus._DUPLICATED_LOGIN_ID); } // 이메일 중복 체크 @@ -53,6 +64,10 @@ public void register(SignUpRequestDto request) { throw new GeneralException(ErrorStatus._DUPLICATED_EMAIL); } + if(userRepository.existsByUsernameAndPartAndTeam(request.username(), request.part(), request.team())) { + throw new GeneralException(ErrorStatus._DUPLICATED_USERNAME); + } + String encoded = passwordEncoder.encode(request.password()); User user = UserConverter.toEntity(request, encoded); @@ -61,5 +76,31 @@ public void register(SignUpRequestDto request) { } + // 로그인 중복 확인 + public void checkLoginId(String loginId) { + if (userRepository.existsByLoginId(loginId)) { + throw new GeneralException(ErrorStatus._DUPLICATED_LOGIN_ID); + } + } + + // refresh token 검증 + public String checkRefreshToken(String refreshToken) throws SignatureException { + if(!jwtUtil.validateRefreshToken(refreshToken)) { + throw new GeneralException(ErrorStatus._TOKEN_INVALID); + } + + String loginId = jwtUtil.getLoginId(refreshToken); + User newUser = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new GeneralException(ErrorStatus._USER_NOT_FOUND)); + + CustomDetails details = new CustomDetails(newUser); + String newAccessToken = jwtUtil.createJwtAccessToken(details); + + return newAccessToken; + + + + } + } diff --git a/src/main/java/vote/dream/server/domain/vote/controller/VoteController.java b/src/main/java/vote/dream/server/domain/vote/controller/VoteController.java new file mode 100644 index 0000000..99bad13 --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/controller/VoteController.java @@ -0,0 +1,78 @@ +package vote.dream.server.domain.vote.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import vote.dream.server.domain.jwt.Custom.CustomDetails; +import vote.dream.server.domain.vote.dto.VoteItemDto; +import vote.dream.server.domain.vote.dto.VoteRequestDto; +import vote.dream.server.domain.vote.dto.VoteResponseDto; +import vote.dream.server.domain.vote.entity.Vote; +import vote.dream.server.domain.vote.entity.VoteType; +import vote.dream.server.domain.vote.service.VoteService; +import vote.dream.server.global.apiPayload.ApiResponse; + +import java.util.List; +import java.util.Collections; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/vote") +public class VoteController { + @Autowired + private VoteService voteService; + +// // 1. 투표 항목 목록 조회 (비로그인 허용) // 가장 처음 코드 +// @GetMapping("/{voteType}/items") +// public ApiResponse> getVoteItems(@PathVariable VoteType voteType) { +// return ApiResponse.onSuccess(voteService.getVoteItems(voteType)); +// } + +// @GetMapping("/{voteType}/items") +// public ApiResponse>> getVoteItems(@PathVariable VoteType voteType) { +// return ApiResponse.onSuccess(voteService.getVoteItemsGroupedByTeam(voteType)); +// } + + // 1. 투표 항목 목록 조회 -> PARTLEADER만 2차원 배열, DEMODAY는 1차원 배열로 변환(막판) + @GetMapping("/{voteType}/items") + public ApiResponse getVoteItems(@PathVariable VoteType voteType) { + return ApiResponse.onSuccess(voteService.getVoteItems(voteType)); + } + + +// // 2. 투표 결과 조회 (비로그인 허용) -> 가장 처음 코드 +// @GetMapping("/{voteType}/results") +// public ApiResponse> getVoteResults(@PathVariable VoteType voteType) { +// return ApiResponse.onSuccess(voteService.getVoteResults(voteType)); +// } + + // 2. 투표 결과 조회 -> PARTLEADER만 2차원 배열, DEMODAY는 1차원 배열로 변환(막판) + // 반환 타입을 와일드카드로 두면 두 경우 모두 커버 + @GetMapping("/{voteType}/results") + public ApiResponse getVoteResults(@PathVariable VoteType voteType) { + return ApiResponse.onSuccess(voteService.getVoteResults(voteType)); + } + + + + // 3. 내 투표 여부 확인 (로그인 필요) + @GetMapping("/{voteType}/status") + public ApiResponse> hasVoted(@PathVariable("voteType") VoteType voteType, @AuthenticationPrincipal CustomDetails user) { + Vote vote = voteService.findByType(voteType) + .orElseThrow(() -> new IllegalArgumentException("해당 타입의 투표가 없습니다.")); + boolean voted = voteService.hasVoted(user.getUser().getId(), voteType); + return ApiResponse.onSuccess( + Collections.singletonMap("voted", voted) + ); + } + + // 4. 투표하기 (로그인 필요) + @PostMapping("/{voteType}/vote") + public ApiResponse voteByType(@PathVariable VoteType voteType, + @RequestBody VoteRequestDto dto, + @AuthenticationPrincipal CustomDetails user) { + voteService.voteByType(user.getUser().getId(), voteType, dto.getVoteItemId()); + return ApiResponse.onSuccess(null); + } + +} diff --git a/src/main/java/vote/dream/server/domain/vote/dto/VoteItemDto.java b/src/main/java/vote/dream/server/domain/vote/dto/VoteItemDto.java new file mode 100644 index 0000000..27f4c4c --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/dto/VoteItemDto.java @@ -0,0 +1,14 @@ +package vote.dream.server.domain.vote.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class VoteItemDto { + private Long voteItemId; + private String subject; + private Long teamId; + private Integer voteCount; +} + diff --git a/src/main/java/vote/dream/server/domain/vote/dto/VoteRequestDto.java b/src/main/java/vote/dream/server/domain/vote/dto/VoteRequestDto.java new file mode 100644 index 0000000..0ec58bc --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/dto/VoteRequestDto.java @@ -0,0 +1,11 @@ +package vote.dream.server.domain.vote.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class VoteRequestDto { + private Long voteId; + private Long voteItemId; +} \ No newline at end of file diff --git a/src/main/java/vote/dream/server/domain/vote/dto/VoteResponseDto.java b/src/main/java/vote/dream/server/domain/vote/dto/VoteResponseDto.java new file mode 100644 index 0000000..2a6c743 --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/dto/VoteResponseDto.java @@ -0,0 +1,12 @@ +package vote.dream.server.domain.vote.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class VoteResponseDto { + private Long voteItemId; + private String subject; + private Integer voteCount; +} \ No newline at end of file diff --git a/src/main/java/vote/dream/server/domain/vote/entity/Team.java b/src/main/java/vote/dream/server/domain/vote/entity/Team.java new file mode 100644 index 0000000..baf82cd --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/entity/Team.java @@ -0,0 +1,20 @@ +package vote.dream.server.domain.vote.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "team") +@Getter @Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Team { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_id") + private Long teamId; + + @Column(name = "team_name", nullable = false, unique = true) + private String teamName; +} \ No newline at end of file diff --git a/src/main/java/vote/dream/server/domain/vote/entity/Vote.java b/src/main/java/vote/dream/server/domain/vote/entity/Vote.java new file mode 100644 index 0000000..b772d03 --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/entity/Vote.java @@ -0,0 +1,25 @@ +package vote.dream.server.domain.vote.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "`vote`") +public class Vote { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long voteId; + + @NotNull + @Enumerated(EnumType.STRING) + private VoteType type; + + @NotNull + @Enumerated(EnumType.STRING) + private VoteStatus status; +} diff --git a/src/main/java/vote/dream/server/domain/vote/entity/VoteItem.java b/src/main/java/vote/dream/server/domain/vote/entity/VoteItem.java new file mode 100644 index 0000000..4525120 --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/entity/VoteItem.java @@ -0,0 +1,39 @@ +package vote.dream.server.domain.vote.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "vote_item") +public class VoteItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "vote_item_id") + private Long voteItemId; + + @NotNull + private String subject; + + private Integer voteCount; + + @NotNull + private Long voteId; + + @NotNull + private Long teamId; + + public VoteItem incrementVoteCount() { + return VoteItem.builder() + .voteItemId(this.voteItemId) + .subject(this.subject) + .voteCount(this.voteCount + 1) + .voteId(this.voteId) + .teamId(this.teamId) + .build(); + } +} diff --git a/src/main/java/vote/dream/server/domain/vote/entity/VoteRecord.java b/src/main/java/vote/dream/server/domain/vote/entity/VoteRecord.java new file mode 100644 index 0000000..f1ec062 --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/entity/VoteRecord.java @@ -0,0 +1,27 @@ +package vote.dream.server.domain.vote.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "vote_record") +public class VoteRecord { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "vote_record_id") + private Long voteRecordId; + + @NotNull + private Long userId; + + @NotNull + private Long voteId; + + @NotNull + private Long voteItemId; +} diff --git a/src/main/java/vote/dream/server/domain/vote/entity/VoteStatus.java b/src/main/java/vote/dream/server/domain/vote/entity/VoteStatus.java new file mode 100644 index 0000000..516dfaa --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/entity/VoteStatus.java @@ -0,0 +1,6 @@ +package vote.dream.server.domain.vote.entity; + +public enum VoteStatus { +WAITING, ONGOING, FINISHED; +} + diff --git a/src/main/java/vote/dream/server/domain/vote/entity/VoteType.java b/src/main/java/vote/dream/server/domain/vote/entity/VoteType.java new file mode 100644 index 0000000..f2c0003 --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/entity/VoteType.java @@ -0,0 +1,6 @@ +package vote.dream.server.domain.vote.entity; + +public enum VoteType { + DEMODAY, + PARTLEADER +} diff --git a/src/main/java/vote/dream/server/domain/vote/repository/TeamRepository.java b/src/main/java/vote/dream/server/domain/vote/repository/TeamRepository.java new file mode 100644 index 0000000..51d6d83 --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/repository/TeamRepository.java @@ -0,0 +1,10 @@ +package vote.dream.server.domain.vote.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import vote.dream.server.domain.vote.entity.Team; + +import java.util.Optional; + +public interface TeamRepository extends JpaRepository { + Optional findByTeamName(String teamName); +} \ No newline at end of file diff --git a/src/main/java/vote/dream/server/domain/vote/repository/VoteItemRepository.java b/src/main/java/vote/dream/server/domain/vote/repository/VoteItemRepository.java new file mode 100644 index 0000000..1881f0f --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/repository/VoteItemRepository.java @@ -0,0 +1,13 @@ +package vote.dream.server.domain.vote.repository; + + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import vote.dream.server.domain.vote.entity.VoteItem; + +import java.util.List; + +public interface VoteItemRepository extends JpaRepository { + List findByVoteId(Long voteId); + List findByVoteId(Long voteId, Sort sort); +} diff --git a/src/main/java/vote/dream/server/domain/vote/repository/VoteRecordRepository.java b/src/main/java/vote/dream/server/domain/vote/repository/VoteRecordRepository.java new file mode 100644 index 0000000..5e1d67e --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/repository/VoteRecordRepository.java @@ -0,0 +1,8 @@ +package vote.dream.server.domain.vote.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import vote.dream.server.domain.vote.entity.VoteRecord; + +public interface VoteRecordRepository extends JpaRepository { + boolean existsByUserIdAndVoteId(Long userId, Long voteId); +} diff --git a/src/main/java/vote/dream/server/domain/vote/repository/VoteRepository.java b/src/main/java/vote/dream/server/domain/vote/repository/VoteRepository.java new file mode 100644 index 0000000..9129e2d --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/repository/VoteRepository.java @@ -0,0 +1,13 @@ +package vote.dream.server.domain.vote.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import vote.dream.server.domain.vote.entity.Vote; +import vote.dream.server.domain.vote.entity.VoteType; + +import java.util.List; + +public interface VoteRepository extends JpaRepository { + Optional findByType(VoteType type); +} + diff --git a/src/main/java/vote/dream/server/domain/vote/service/VoteService.java b/src/main/java/vote/dream/server/domain/vote/service/VoteService.java new file mode 100644 index 0000000..94a2535 --- /dev/null +++ b/src/main/java/vote/dream/server/domain/vote/service/VoteService.java @@ -0,0 +1,196 @@ +package vote.dream.server.domain.vote.service; + +import jakarta.transaction.Transactional; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import vote.dream.server.domain.vote.dto.VoteItemDto; +import vote.dream.server.domain.vote.dto.VoteResponseDto; +import vote.dream.server.domain.vote.entity.Vote; +import vote.dream.server.domain.vote.entity.VoteItem; +import vote.dream.server.domain.vote.entity.VoteRecord; +import vote.dream.server.domain.vote.entity.VoteType; +import vote.dream.server.domain.vote.repository.VoteItemRepository; +import vote.dream.server.domain.vote.repository.VoteRecordRepository; +import vote.dream.server.domain.vote.repository.VoteRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +@Service +@Transactional +public class VoteService { + @Autowired + private VoteRepository voteRepository; + @Autowired + private VoteItemRepository voteItemRepository; + @Autowired + private VoteRecordRepository voteRecordRepository; + + + /* + * 주어진 voteType에 해당하는 Vote 엔티티 조회 + * */ + public Optional findByType(VoteType voteType) { + return voteRepository.findByType(voteType); + } + +// // 1. 투표 항목 목록 조회 (GET) - 사용자가 투표하기 전에 어떤 항목이 있는지 보여줌 // 가장 초기 코드 +// public List getVoteItems(VoteType voteType) { +// Vote vote = findByType(voteType) +// .orElseThrow(() -> new IllegalArgumentException("해당 타입의 투표가 없습니다.")); +// return voteItemRepository.findByVoteId(vote.getVoteId()) +// .stream() +// .map(item -> new VoteItemDto(item.getVoteItemId(), item.getSubject())) +// .collect(Collectors.toList()); +// } + + // 수정본 -> Items +// public List> getVoteItemsGroupedByTeam(VoteType voteType) { +// Vote vote = findByType(voteType) +// .orElseThrow(() -> new IllegalArgumentException("해당 타입의 투표가 없습니다.")); +// List items = voteItemRepository.findByVoteId(vote.getVoteId()); +// +// // 백엔드/프론트엔드 인원 리스트 +// Set backend = Set.of("박정하", "서채연", "박채연", "박서연", "오지현", "한혜수", "이석원", "최근호", "임도현", "박준형"); +// Set frontend = Set.of("권동욱", "김서연", "최서연", "한서정", "신수진", "원채영", "김철흥", "송아영", "이주희", "김영서"); +// +// List backendList = new ArrayList<>(); +// List frontendList = new ArrayList<>(); +// +// for (VoteItem item : items) { +// if (backend.contains(item.getSubject())) { +// backendList.add(new VoteItemDto(item.getVoteItemId(), item.getSubject())); +// } else if (frontend.contains(item.getSubject())) { +// frontendList.add(new VoteItemDto(item.getVoteItemId(), item.getSubject())); +// } +// } +// return List.of(backendList, frontendList); +// } + + + // 1번 + public Object getVoteItems(VoteType voteType) { + Vote vote = findByType(voteType) + .orElseThrow(() -> new IllegalArgumentException("해당 타입의 투표가 없습니다.")); + List items = voteItemRepository.findByVoteId(vote.getVoteId()); + + if (voteType == VoteType.PARTLEADER) { + // 백엔드/프론트엔드 분류 + Set backend = Set.of("박정하", "서채연", "박채연", "박서연", "오지현", "한혜수", "이석원", "최근호", "임도현", "박준형"); + Set frontend = Set.of("권동욱", "김서연", "최서연", "한서정", "신수진", "원채영", "김철흥", "송아영", "이주희", "김영서"); + + List backendList = new ArrayList<>(); + List frontendList = new ArrayList<>(); + + for (VoteItem item : items) { + if (backend.contains(item.getSubject())) { + backendList.add(new VoteItemDto(item.getVoteItemId(), item.getSubject(), item.getTeamId(), item.getVoteCount())); + } else if (frontend.contains(item.getSubject())) { + frontendList.add(new VoteItemDto(item.getVoteItemId(), item.getSubject(), item.getTeamId(), item.getVoteCount())); + } + } + return List.of(backendList, frontendList); + } else { + // DEMODAY 등은 기존 1차원 배열 반환 + return items.stream() + .map(item -> new VoteItemDto(item.getVoteItemId(), item.getSubject(), item.getTeamId(), item.getVoteCount())) + .collect(Collectors.toList()); + } + } + + + +// // 2. 투표 결과 조회 (GET) - 각 투표 항목이 몇 표를 받았는지 결과를 보여줌 +// public List getVoteResults(VoteType voteType) { +// Vote vote = findByType(voteType) +// .orElseThrow(() -> new IllegalArgumentException("해당 타입의 투표가 없습니다.")); +// return voteItemRepository.findByVoteId( +// vote.getVoteId(), +// Sort.by( +// Sort.Order.desc("voteCount"), +// Sort.Order.asc("voteItemId") // 동점일 때 아이디 오름차순 +// ) +// ) +// .stream() +// .map(item -> new VoteResponseDto(item.getVoteItemId(), item.getSubject(), item.getVoteCount())) +// .collect(Collectors.toList()); +// } + + + // 2번 + public Object getVoteResults(VoteType voteType) { + Vote vote = findByType(voteType) + .orElseThrow(() -> new IllegalArgumentException("해당 타입의 투표가 없습니다.")); + List items = voteItemRepository.findByVoteId( + vote.getVoteId(), + Sort.by( + Sort.Order.desc("voteCount"), + Sort.Order.asc("voteItemId") + ) + ); + + if (voteType == VoteType.PARTLEADER) { + // 백엔드/프론트엔드 분류 + Set backend = Set.of("박정하", "서채연", "박채연", "박서연", "오지현", "한혜수", "이석원", "최근호", "임도현", "박준형"); + Set frontend = Set.of("권동욱", "김서연", "최서연", "한서정", "신수진", "원채영", "김철흥", "송아영", "이주희", "김영서"); + + List backendList = new ArrayList<>(); + List frontendList = new ArrayList<>(); + + for (VoteItem item : items) { + if (backend.contains(item.getSubject())) { + backendList.add(new VoteResponseDto(item.getVoteItemId(), item.getSubject(), item.getVoteCount())); + } else if (frontend.contains(item.getSubject())) { + frontendList.add(new VoteResponseDto(item.getVoteItemId(), item.getSubject(), item.getVoteCount())); + } + } + return List.of(backendList, frontendList); + } else { + // DEMODAY 등은 기존 1차원 배열 반환 + return items.stream() + .map(item -> new VoteResponseDto(item.getVoteItemId(), item.getSubject(), item.getVoteCount())) + .collect(Collectors.toList()); + } + } + + + // 3. 개인 투표 여부 확인 (GET, 인증 필요) + public boolean hasVoted(Long userId, VoteType voteType) { + Vote vote = findByType(voteType) + .orElseThrow(() -> new IllegalArgumentException("해당 타입의 투표가 없습니다.")); + return voteRecordRepository.existsByUserIdAndVoteId(userId, vote.getVoteId()); + } + + // 4. 개인 투표 등록 (POST, 인증 필요) + public void voteByType(Long userId, VoteType voteType, Long voteItemId) { + + // 1. voteType에 해당하는 Vote 엔티티 조회 + Vote vote = voteRepository.findByType(voteType) + .orElseThrow(() -> new IllegalArgumentException("해당 타입의 투표가 없습니다.")); + + // 2. 이미 투표했는지 확인 + if(voteRecordRepository.existsByUserIdAndVoteId(userId, vote.getVoteId())) { + throw new IllegalArgumentException("이미 투표하셨습니다."); + } + // 3. 투표 기록 저장 + VoteRecord record = VoteRecord.builder() + .userId(userId) + .voteId(vote.getVoteId()) + .voteItemId(voteItemId) + .build(); + voteRecordRepository.save(record); + + // 4. 투표 항목 득표수 증가 + VoteItem item = voteItemRepository.findById(voteItemId).orElseThrow(); + VoteItem updatedItem = item.incrementVoteCount(); + voteItemRepository.save(updatedItem); + + } +} + diff --git a/src/main/java/vote/dream/server/global/apiPayload/status/ErrorStatus.java b/src/main/java/vote/dream/server/global/apiPayload/status/ErrorStatus.java index f74d98d..fe8b883 100644 --- a/src/main/java/vote/dream/server/global/apiPayload/status/ErrorStatus.java +++ b/src/main/java/vote/dream/server/global/apiPayload/status/ErrorStatus.java @@ -10,23 +10,31 @@ @AllArgsConstructor public enum ErrorStatus implements BaseErrorCode { + // 500 서버 에러 _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "500", "서버 에러, 관리자에게 문의 바랍니다."), - _BAD_REQUEST(HttpStatus.BAD_REQUEST,"400","잘못된 요청입니다."), - _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"401","인증이 필요합니다."), - _MAIL_ERROR(HttpStatus.BAD_REQUEST, "400", "인증 이메일 전송에 실패하였습니다."), + // 400 클라이언트 에러 + _BAD_REQUEST(HttpStatus.BAD_REQUEST,"400","잘못된 요청입니다."), _DUPLICATED_EMAIL(HttpStatus.BAD_REQUEST, "400", "중복된 이메일입니다."), - _DUPLICATED_LOGINID(HttpStatus.BAD_REQUEST, "400", "중복된 로그인 ID입니다."), - _USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "400", "해당 유저를 찾을 수 없습니다."), - _BAD_PASSWORD(HttpStatus.BAD_REQUEST, "400", "잘못된 패스워드입니다."), - _NON_EXIST_EMAIL(HttpStatus.BAD_REQUEST, "400", "존재하지 않는 이메일입니다."), - _FORBIDDEN_PASSWORD(HttpStatus.FORBIDDEN, "403", "불가능한 패스워드입니다. 패스워드는 영어, 숫자 8~13글자만 가능합니다."), + _DUPLICATED_LOGIN_ID(HttpStatus.BAD_REQUEST, "400", "중복된 로그인 아이디입니다."), + _USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "400", "아이디와 일치하는 계정이 존재하지 않습니다"), + _BAD_PASSWORD(HttpStatus.BAD_REQUEST, "400", "비밀번호가 일치하지 않습니다."), + _DUPLICATED_USERNAME(HttpStatus.BAD_REQUEST, "400", "중복된 사용자 이름입니다."), + - _TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "404", "해당 토큰을 찾을 수 없습니다."), + // 401 인증 에러 + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"401","인증이 필요합니다."), _TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "401", "해당 토큰이 만료되었습니다."), _TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "401", "해당 토큰이 유효하지 않습니다."), - _PASSWORD_NOT_MATCH(HttpStatus.BAD_REQUEST, "400", "패스워드가 일치하지 않습니다."),; + // 403 권한 에러 + + // 404 리소스 에러 + _TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "404", "토큰이 없습니다."),; + + // 409 충돌 에러 + + // 기타 에러 private final HttpStatus httpStatus; private final String code; private final String message; diff --git a/src/main/java/vote/dream/server/global/config/CorsConfig.java b/src/main/java/vote/dream/server/global/config/CorsConfig.java index b981609..7dcd5b6 100644 --- a/src/main/java/vote/dream/server/global/config/CorsConfig.java +++ b/src/main/java/vote/dream/server/global/config/CorsConfig.java @@ -19,10 +19,13 @@ public static CorsConfigurationSource apiConfigurationSource() { configuration.setAllowCredentials(true); // 💡 여기에 프론트 주소를 명시적으로 추가 + // 백엔드 주소 추가 configuration.setAllowedOriginPatterns(List.of( "http://localhost:8080", "http://localhost:3000", - "https://vote.dream.team")); + "https://vote.dream.team", + "https://vote-dream.p-e.kr", + "https://next-vote-21th-omega.vercel.app/")); configuration.addAllowedHeader("*"); configuration.setAllowedMethods(List.of("*")); diff --git a/src/main/java/vote/dream/server/global/config/SwaggerConfig.java b/src/main/java/vote/dream/server/global/config/SwaggerConfig.java index bf0976b..ada6df7 100644 --- a/src/main/java/vote/dream/server/global/config/SwaggerConfig.java +++ b/src/main/java/vote/dream/server/global/config/SwaggerConfig.java @@ -5,9 +5,12 @@ import io.swagger.v3.oas.models.info.Info; 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; +import java.util.List; + @Configuration public class SwaggerConfig { @Bean @@ -21,7 +24,12 @@ public OpenAPI openAPI() { .bearerFormat("JWT") ); + return new OpenAPI() + .servers(List.of( + new Server().url("https://vote-dream.p-e.kr").description("Production"), + new Server().url("http://localhost:8081").description("Local Dev") + )) .components(components) .info(apiInfo()) .addSecurityItem(securityRequirement); diff --git a/src/main/java/vote/dream/server/global/config/VoteDataInitializer.java b/src/main/java/vote/dream/server/global/config/VoteDataInitializer.java new file mode 100644 index 0000000..6e63444 --- /dev/null +++ b/src/main/java/vote/dream/server/global/config/VoteDataInitializer.java @@ -0,0 +1,109 @@ +package vote.dream.server.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import vote.dream.server.domain.vote.entity.Vote; +import vote.dream.server.domain.vote.entity.VoteStatus; +import vote.dream.server.domain.vote.entity.VoteType; +import vote.dream.server.domain.vote.repository.VoteItemRepository; +import vote.dream.server.domain.vote.repository.VoteRepository; +import vote.dream.server.domain.vote.entity.VoteItem; +import vote.dream.server.domain.vote.entity.Team; +import vote.dream.server.domain.vote.repository.TeamRepository; + +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class VoteDataInitializer implements CommandLineRunner { + private final TeamRepository teamRepo; + private final VoteRepository voteRepo; + private final VoteItemRepository itemRepo; + + @Override + @Transactional + public void run(String... args) throws Exception { + + // 1) 팀 세팅 + List teamNames = List.of("POPUPCYCLE", "HONEYHOME", "DEARDREAM", "INFLUEE", "PROMETHA"); + for (String name : teamNames) { + teamRepo.findByTeamName(name) + .orElseGet(() -> teamRepo.save(new Team(null, name))); + } + + // 2) 투표 종류 세팅 + for (VoteType type : VoteType.values()) { + voteRepo.findByType(type) + .orElseGet(() -> voteRepo.save(new Vote(null, type, VoteStatus.WAITING))); + } + + + // 3) 파트장 투표용 후보 세팅 + Vote plVote = voteRepo.findByType(VoteType.PARTLEADER).get(); + Long plVoteId = plVote.getVoteId(); + List existingPlItems = itemRepo.findByVoteId(plVoteId); + + // 팀별 후보 매핑 + Map> plCandidatesByTeam = Map.of( + "POPUPCYCLE", List.of("박준형", "임도현", "김철흥", "송아영"), + "HONEYHOME", List.of("이석원", "최근호", "신수진", "원채영"), + "DEARDREAM", List.of("오지현", "한혜수", "김영서", "이주희"), + "PROMETHA", List.of("박정하", "서채연", "권동욱", "김서연"), + "INFLUEE", List.of("박채연", "박서연", "최서연", "한서정") + ); + + for (var entry : plCandidatesByTeam.entrySet()) { + String teamName = entry.getKey(); + Long teamId = teamRepo.findByTeamName(teamName) + .orElseThrow() + .getTeamId(); + + for (String candidate : entry.getValue()) { + boolean exists = existingPlItems.stream() + .anyMatch(i -> + i.getSubject().equals(candidate) && + i.getTeamId().equals(teamId) + ); + if (!exists) { + itemRepo.save(new VoteItem( + null, // voteItemId (auto-generated) + candidate, // subject + 0, // voteCount + plVoteId, // voteId + teamId // teamId + )); + } + } + } + + + // 4) 데모데이 투표용 후보 세팅 (팀 단위) + Vote ddVote = voteRepo.findByType(VoteType.DEMODAY).get(); + Long ddVoteId = ddVote.getVoteId(); + for (Team t : teamRepo.findAll(Sort.by(Sort.Direction.ASC, "teamId"))) { + String teamName = t.getTeamName(); + Long teamId = t.getTeamId(); + + boolean exists = itemRepo.findByVoteId(ddVoteId).stream() + .anyMatch(i -> + i.getSubject().equals(teamName) && + i.getTeamId().equals(teamId) + ); + if (!exists) { + itemRepo.save(new VoteItem( + null, // voteItemId (auto-generated) + teamName, // subject + 0, // voteCount + ddVoteId, // voteId(Long) + teamId // teamId(Long) + )); + } + + + } + } +} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 9f3d021..a14f6a0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -5,20 +5,24 @@ server: charset: UTF-8 force: true + + spring: datasource: url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: create-drop properties: hibernate: show_sql: true format_sql: true + dialect: org.hibernate.dialect.MySQLDialect logging: level: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 41ba440..db3f5ce 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,4 @@ spring: profiles: active: - - dev \ No newline at end of file + - prod diff --git a/src/test/java/vote/dream/server/AuthControllerTest.java b/src/test/java/vote/dream/server/AuthControllerTest.java new file mode 100644 index 0000000..df3d419 --- /dev/null +++ b/src/test/java/vote/dream/server/AuthControllerTest.java @@ -0,0 +1,141 @@ +package vote.dream.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import vote.dream.server.domain.jwt.dto.JwtDto; +import vote.dream.server.domain.jwt.filter.JwtUtil; +import vote.dream.server.domain.user.controller.UserController; +import vote.dream.server.domain.user.dto.request.LoginRequestDto; +import vote.dream.server.domain.user.dto.request.SignUpRequestDto; +import vote.dream.server.domain.user.entity.Part; +import vote.dream.server.domain.user.entity.Team; +import vote.dream.server.domain.user.entity.User; +import vote.dream.server.domain.user.service.AuthService; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; + + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; + + +@WebMvcTest(UserController.class) +@Import({MockConfig.class, TestSecurityConfig.class}) +public class AuthControllerTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private AuthService authService; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("로그인 성공 - accessToken 반환 & refreshToken 쿠키") + void login_success() throws Exception { + // given + String accessToken = "access.token"; + String refreshToken = "refresh.token"; + + JwtDto jwtDto = new JwtDto(accessToken, refreshToken); + User user = User.builder() + .id(1L) + .loginId("testuser") + .email("example@gmail.com") + .password("1234") + .username("test") + .team(Team.DEARDREAM) + .part(Part.BACKEND) + .build(); + + Map result = new HashMap<>(); + result.put(jwtDto, user); + + LoginRequestDto loginRequest = new LoginRequestDto("testuser", "password"); + + when(authService.login(any(), any())).thenReturn(result); + + // when & then + mockMvc.perform(post("/api/v1/users/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.accessToken").value(accessToken)) + .andExpect(cookie().exists("refreshToken")) + .andExpect(cookie().httpOnly("refreshToken", true)); + } + + @Test + @DisplayName("회원가입 성공") + void register_success() throws Exception { + SignUpRequestDto signUpRequest = new SignUpRequestDto("testuser", "password", "example@gmail.com", + Part.BACKEND, "test", Team.DEARDREAM); + + /* + id(1L) + .loginId("testuser") + .email("example@gmail.com") + .password("1234") + .username("test") + .team(Team.DEARDREAM) + .part(Part.BACKEND) + */ + // void method라서 doNothing 필요 없음 + + mockMvc.perform(post("/api/v1/users/register").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(signUpRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) // ✅ 존재하는 key + .andExpect(jsonPath("$.result").value("_CREATED")); + } + + @Test + @DisplayName("아이디 중복 확인 성공") + void check_login_id() throws Exception { + String loginId = "testuser"; + + mockMvc.perform(get("/api/v1/users/check") + .with(csrf()) + .param("loginId", loginId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.result").value("_OK")); + } + + @Test + @DisplayName("refreshToken 재발급 성공") + void reissue_token_success() throws Exception { + String newAccessToken = "new.access.token"; + + when(authService.checkRefreshToken(any())).thenReturn(newAccessToken); + + mockMvc.perform(post("/api/v1/users/reissue") + .with(csrf()) + .cookie(new Cookie("refreshToken", "valid.refresh.token"))) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.result").value(newAccessToken)); + } +} diff --git a/src/test/java/vote/dream/server/MockConfig.java b/src/test/java/vote/dream/server/MockConfig.java new file mode 100644 index 0000000..072ceb4 --- /dev/null +++ b/src/test/java/vote/dream/server/MockConfig.java @@ -0,0 +1,20 @@ +package vote.dream.server; + +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import vote.dream.server.domain.jwt.filter.JwtUtil; +import vote.dream.server.domain.user.service.AuthService; + +@TestConfiguration +public class MockConfig { + @Bean + public AuthService authService() { + return Mockito.mock(AuthService.class); + } + + @Bean + public JwtUtil jwtUtil() { + return Mockito.mock(JwtUtil.class); + } +} diff --git a/src/test/java/vote/dream/server/TestSecurityConfig.java b/src/test/java/vote/dream/server/TestSecurityConfig.java new file mode 100644 index 0000000..d6496b9 --- /dev/null +++ b/src/test/java/vote/dream/server/TestSecurityConfig.java @@ -0,0 +1,19 @@ +package vote.dream.server; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +public class TestSecurityConfig { + @Bean + public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception { + http + .csrf(scrf->scrf.disable()) + .authorizeHttpRequests(authz -> authz + .anyRequest().permitAll()); // ✅ 테스트에선 모든 요청 허용 + + return http.build(); + } +}