diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 9bdb7d6c..f277b5eb 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -93,7 +93,7 @@ jobs: username: ec2-user key: ${{ secrets.EC2_SSH_KEY }} # EC2 서버에 접근하기 위한 SSH 개인 키 script: | - + echo "DB_URL=${{ secrets.DB_URL }}" >> ~/deploy/.env echo "DB_USERNAME=${{ secrets.DB_USERNAME }}" >> ~/deploy/.env echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> ~/deploy/.env @@ -101,7 +101,11 @@ jobs: echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> ~/deploy/.env echo "BUCKET_NAME=${{ secrets.BUCKET_NAME }}" >> ~/deploy/.env echo "REGION=${{ secrets.REGION }}" >> ~/deploy/.env - echo "dockerhubuserid=${{ secrets.DOCKERHUB_USERNAME }}" >> ~/deploy/.env + echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> ~/deploy/.env + echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> ~/deploy/.env + echo "SEND_EMAIL_PORT=${{ secrets.SEND_EMAIL_PORT }}" >> ~/deploy/.env + echo "SEND_EMAIL=${{ secrets.SEND_EMAIL }}" >> ~/deploy/.env + echo "SEND_EMAIL_PASSWORD=${{ secrets.SEND_EMAIL_PASSWORD }}" >> ~/deploy/.env sudo docker-compose -f ~/deploy/docker-compose.yml pull sudo docker-compose -f ~/deploy/docker-compose.yml up -d diff --git a/build.gradle b/build.gradle index c7890ae1..cca7132a 100644 --- a/build.gradle +++ b/build.gradle @@ -24,12 +24,14 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + // spring compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web-services' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -41,8 +43,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' // security - //implementation 'org.springframework.boot:spring-boot-starter-security' - //implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' @@ -53,10 +54,24 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'com.h2database:h2' + // cache + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // s3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // json web token + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + implementation 'org.springdoc:springdoc-openapi-ui:1.6.15' + implementation 'io.springfox:springfox-swagger2:2.9.2' + implementation 'io.springfox:springfox-swagger-ui:2.9.2' + } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 6abea8d1..c2926766 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ - version: '3.8' services: @@ -14,16 +13,17 @@ services: SECRET_KEY: ${SECRET_KEY} BUCKET_NAME: ${BUCKET_NAME} REGION: ${REGION} -# JWT_SECRET_KEY: ${JWT_SECRET_KEY} - # MAIL_USERNAME : ${MAIL_USERNAME} - # MAIL_PASSWORD : ${MAIL_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + SEND_EMAIL_PORT: ${SEND_EMAIL_PORT} + SEND_EMAIL: ${SEND_EMAIL} + SEND_EMAIL_PASSWORD: ${SEND_EMAIL_PASSWORD} - hibernate_ddl_auto: create -# REDIS_HOST: redis -# depends_on: -# - redis + hibernate_ddl_auto: update + REDIS_HOST: redis + depends_on: + - redis -# redis: -# image: "redis:latest" -# ports: -# - "6379:6379" + redis: + image: "redis:latest" + ports: + - "6379:6379" diff --git a/src/main/java/com/tave/tavewebsite/TaveWebsiteApplication.java b/src/main/java/com/tave/tavewebsite/TaveWebsiteApplication.java index 5535431e..9649ae75 100644 --- a/src/main/java/com/tave/tavewebsite/TaveWebsiteApplication.java +++ b/src/main/java/com/tave/tavewebsite/TaveWebsiteApplication.java @@ -3,9 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.web.config.EnableSpringDataWebSupport; + +import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO; @SpringBootApplication @EnableJpaAuditing +@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) public class TaveWebsiteApplication { public static void main(String[] args) { diff --git a/src/main/java/com/tave/tavewebsite/domain/history/controller/ManagerHistoryController.java b/src/main/java/com/tave/tavewebsite/domain/history/controller/ManagerHistoryController.java new file mode 100644 index 00000000..1cbab06f --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/history/controller/ManagerHistoryController.java @@ -0,0 +1,50 @@ +package com.tave.tavewebsite.domain.history.controller; + +import com.tave.tavewebsite.domain.history.dto.request.HistoryRequestDto; +import com.tave.tavewebsite.domain.history.dto.response.HistoryResponseDto; +import com.tave.tavewebsite.domain.history.service.HistoryService; +import com.tave.tavewebsite.global.success.SuccessResponse; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/manager/history") +@RequiredArgsConstructor +public class ManagerHistoryController { + + private final HistoryService historyService; + + @GetMapping + public SuccessResponse> getAllHistory() { + List allOrderByGenerationDesc = historyService.findAllOrderByGenerationDesc(); + return new SuccessResponse<>(allOrderByGenerationDesc); + } + + @PostMapping + public SuccessResponse postHistory(@RequestBody @Valid HistoryRequestDto historyRequestDto) { + historyService.save(historyRequestDto); + return SuccessResponse.ok(); + } + + @PatchMapping("/{historyId}") + public SuccessResponse updateHistory(@PathVariable("historyId") Long id, + @RequestBody @Valid HistoryRequestDto historyRequestDto) { + historyService.patch(id, historyRequestDto); + return SuccessResponse.ok(); + } + + @DeleteMapping("/{historyId}") + public SuccessResponse deleteHistory(@PathVariable("historyId") Long id) { + historyService.delete(id); + return SuccessResponse.ok(); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/history/controller/NormalHistoryController.java b/src/main/java/com/tave/tavewebsite/domain/history/controller/NormalHistoryController.java new file mode 100644 index 00000000..9125de40 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/history/controller/NormalHistoryController.java @@ -0,0 +1,24 @@ +package com.tave.tavewebsite.domain.history.controller; + +import com.tave.tavewebsite.domain.history.dto.response.HistoryResponseDto; +import com.tave.tavewebsite.domain.history.service.HistoryService; +import com.tave.tavewebsite.global.success.SuccessResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/normal/history") +public class NormalHistoryController { + + private final HistoryService historyService; + + @GetMapping + public SuccessResponse> getPublicHistory() { + List publicOrderByGenerationDesc = historyService.findPublicOrderByGenerationDesc(); + return new SuccessResponse<>(publicOrderByGenerationDesc); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/history/dto/request/HistoryRequestDto.java b/src/main/java/com/tave/tavewebsite/domain/history/dto/request/HistoryRequestDto.java new file mode 100644 index 00000000..2b55abac --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/history/dto/request/HistoryRequestDto.java @@ -0,0 +1,22 @@ +package com.tave.tavewebsite.domain.history.dto.request; + +import com.tave.tavewebsite.domain.history.entity.History; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record HistoryRequestDto( + @NotNull(message = "필수로 입력하셔야합니다.") @Size(min = 1, max = 5, message = "1~5 글자 사이로 입력해주세요.") + String generation, + @NotNull(message = "필수로 입력하셔야합니다.") @Size(min = 1, max = 500, message = "최대 500 글자까지 입력 가능합니다.") + String description, + @NotNull(message = "필수로 입력하셔야합니다.") + Boolean isPublic +) { + public History toHistory() { + return History.builder() + .generation(this.generation) + .description(this.description) + .isPublic(this.isPublic) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/domain/history/dto/response/HistoryResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/history/dto/response/HistoryResponseDto.java new file mode 100644 index 00000000..2325ec58 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/history/dto/response/HistoryResponseDto.java @@ -0,0 +1,15 @@ +package com.tave.tavewebsite.domain.history.dto.response; + +import com.tave.tavewebsite.domain.history.entity.History; + +public record HistoryResponseDto( + Long id, + String generation, + String description, + Boolean isPublic +) { + public static HistoryResponseDto of(History history) { + return new HistoryResponseDto(history.getId(), history.getGeneration(), history.getDescription(), + history.isPublic()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/history/entity/History.java b/src/main/java/com/tave/tavewebsite/domain/history/entity/History.java index 9363a0a1..469d1fb1 100644 --- a/src/main/java/com/tave/tavewebsite/domain/history/entity/History.java +++ b/src/main/java/com/tave/tavewebsite/domain/history/entity/History.java @@ -1,7 +1,13 @@ package com.tave.tavewebsite.domain.history.entity; +import com.tave.tavewebsite.domain.history.dto.request.HistoryRequestDto; import com.tave.tavewebsite.global.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.AccessLevel; @@ -41,4 +47,10 @@ public History(String generation, String description, boolean isPublic) { this.description = description; this.isPublic = isPublic; } + + public void patchHistory(HistoryRequestDto historyResponseDto) { + generation = historyResponseDto.generation(); + description = historyResponseDto.description(); + isPublic = historyResponseDto.isPublic(); + } } diff --git a/src/main/java/com/tave/tavewebsite/domain/history/exception/HistoryErrorException.java b/src/main/java/com/tave/tavewebsite/domain/history/exception/HistoryErrorException.java new file mode 100644 index 00000000..04cf02ce --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/history/exception/HistoryErrorException.java @@ -0,0 +1,13 @@ +package com.tave.tavewebsite.domain.history.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +public abstract class HistoryErrorException { + + public static class HistoryNotFoundException extends BaseErrorException { + public HistoryNotFoundException() { + super(HistoryErrorMessage.NOT_FOUND_HISTORY.getCode(), HistoryErrorMessage.NOT_FOUND_HISTORY.getMessage()); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/domain/history/exception/HistoryErrorMessage.java b/src/main/java/com/tave/tavewebsite/domain/history/exception/HistoryErrorMessage.java new file mode 100644 index 00000000..e2a890ec --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/history/exception/HistoryErrorMessage.java @@ -0,0 +1,15 @@ +package com.tave.tavewebsite.domain.history.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum HistoryErrorMessage { + + NOT_FOUND_HISTORY(400, "History를 찾을 수 없습니다."); + + final int code; + final String message; + +} diff --git a/src/main/java/com/tave/tavewebsite/domain/history/repository/HistoryRepository.java b/src/main/java/com/tave/tavewebsite/domain/history/repository/HistoryRepository.java new file mode 100644 index 00000000..53bb34d3 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/history/repository/HistoryRepository.java @@ -0,0 +1,13 @@ +package com.tave.tavewebsite.domain.history.repository; + +import com.tave.tavewebsite.domain.history.entity.History; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface HistoryRepository extends JpaRepository { + List findByIsPublicOrderByGenerationDesc(Boolean isPublic); + + List findAllByOrderByGenerationDesc(); +} diff --git a/src/main/java/com/tave/tavewebsite/domain/history/service/HistoryService.java b/src/main/java/com/tave/tavewebsite/domain/history/service/HistoryService.java new file mode 100644 index 00000000..e66dd6ae --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/history/service/HistoryService.java @@ -0,0 +1,58 @@ +package com.tave.tavewebsite.domain.history.service; + +import com.tave.tavewebsite.domain.history.dto.request.HistoryRequestDto; +import com.tave.tavewebsite.domain.history.dto.response.HistoryResponseDto; +import com.tave.tavewebsite.domain.history.entity.History; +import com.tave.tavewebsite.domain.history.exception.HistoryErrorException.HistoryNotFoundException; +import com.tave.tavewebsite.domain.history.repository.HistoryRepository; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HistoryService { + + private final HistoryRepository historyRepository; + + @Transactional(readOnly = true) + public List findPublicOrderByGenerationDesc() { + List isPublicOrderByGenerationDesc = historyRepository.findByIsPublicOrderByGenerationDesc(true); + List historyResponseDtos = new ArrayList<>(); + for (History history : isPublicOrderByGenerationDesc) { + historyResponseDtos.add(HistoryResponseDto.of(history)); + } + return historyResponseDtos; + } + + @Transactional(readOnly = true) + public List findAllOrderByGenerationDesc() { + List orderByGenerationDesc = historyRepository.findAllByOrderByGenerationDesc(); + List historyResponseDtos = new ArrayList<>(); + for (History history : orderByGenerationDesc) { + historyResponseDtos.add(HistoryResponseDto.of(history)); + } + return historyResponseDtos; + } + + public void save(HistoryRequestDto dto) { + historyRepository.save(dto.toHistory()); + } + + @Transactional + public void patch(Long id, HistoryRequestDto dto) { + History history = validate(id); + history.patchHistory(dto); + } + + public void delete(Long id) { + validate(id); + historyRepository.deleteById(id); + } + + private History validate(Long id) { + return historyRepository.findById(id).orElseThrow(HistoryNotFoundException::new); + } +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/domain/member/controller/AdminController.java b/src/main/java/com/tave/tavewebsite/domain/member/controller/AdminController.java new file mode 100644 index 00000000..82063041 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/controller/AdminController.java @@ -0,0 +1,31 @@ +package com.tave.tavewebsite.domain.member.controller; + +import com.tave.tavewebsite.domain.member.dto.response.AuthorizedManagerResponseDto; +import com.tave.tavewebsite.domain.member.dto.response.UnauthorizedManagerResponseDto; +import com.tave.tavewebsite.domain.member.service.AdminService; +import com.tave.tavewebsite.global.success.SuccessResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminService adminService; + + @GetMapping("/unauthorized") + public SuccessResponse> getUnauthorizedManager() { + List response = adminService.getUnauthorizedManager(); + return new SuccessResponse<>(response); + } + + @GetMapping("/authorized") + public SuccessResponse> getAuthorizedAdmins() { + List response = adminService.getAuthorizedAdmins(); + return new SuccessResponse<>(response); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/controller/AuthController.java b/src/main/java/com/tave/tavewebsite/domain/member/controller/AuthController.java new file mode 100644 index 00000000..e169673d --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/controller/AuthController.java @@ -0,0 +1,64 @@ +package com.tave.tavewebsite.domain.member.controller; + +import com.tave.tavewebsite.domain.member.dto.request.RefreshTokenRequestDto; +import com.tave.tavewebsite.domain.member.dto.request.RegisterManagerRequestDto; +import com.tave.tavewebsite.domain.member.dto.request.SignUpRequestDto; +import com.tave.tavewebsite.domain.member.dto.response.RefreshResponseDto; +import com.tave.tavewebsite.domain.member.dto.response.SignInResponseDto; +import com.tave.tavewebsite.domain.member.service.AuthService; +import com.tave.tavewebsite.domain.member.service.MemberService; +import com.tave.tavewebsite.global.mail.dto.MailResponseDto; +import com.tave.tavewebsite.global.success.SuccessResponse; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/auth") +public class AuthController { + + private final MemberService memberService; + private final AuthService authService; + + @PostMapping("/signup") + public SuccessResponse registerManager(@RequestBody @Valid RegisterManagerRequestDto requestDto) { + MailResponseDto response = memberService.saveMember(requestDto); + return new SuccessResponse<>(response); + } + + @PostMapping("/signin") + public SuccessResponse signIn(@RequestBody SignUpRequestDto requestDto, + HttpServletResponse response) { + SignInResponseDto signInResponseDto = authService.signIn(requestDto, response); + return new SuccessResponse<>(signInResponseDto); + } + + @PostMapping("/refresh") + public SuccessResponse refreshToken(@RequestBody RefreshTokenRequestDto requestDto, + @CookieValue("refreshToken") String refreshToken, + HttpServletResponse response) { + return new SuccessResponse<>(authService.reissueToken(requestDto, refreshToken, response)); + } + + @GetMapping("/signout") + public SuccessResponse signOut(@RequestHeader("Authorization") String token) { + authService.singOut(token); + return SuccessResponse.ok(); + } + + @DeleteMapping("/delete/{memberId}") + public SuccessResponse deleteMember(@PathVariable("memberId") Long memberId) { + memberService.deleteMember(memberId); + return SuccessResponse.ok(); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/controller/ManagerController.java b/src/main/java/com/tave/tavewebsite/domain/member/controller/ManagerController.java index 9a98a740..e21e3231 100644 --- a/src/main/java/com/tave/tavewebsite/domain/member/controller/ManagerController.java +++ b/src/main/java/com/tave/tavewebsite/domain/member/controller/ManagerController.java @@ -1,40 +1,75 @@ package com.tave.tavewebsite.domain.member.controller; -import com.tave.tavewebsite.domain.member.dto.request.RegisterManagerRequestDto; +import com.tave.tavewebsite.domain.member.dto.request.ResetPasswordReq; +import com.tave.tavewebsite.domain.member.dto.request.ValidateEmailReq; import com.tave.tavewebsite.domain.member.dto.response.CheckNickNameResponseDto; -import com.tave.tavewebsite.domain.member.dto.response.UnauthorizedManagerResponseDto; +import com.tave.tavewebsite.domain.member.service.AdminService; import com.tave.tavewebsite.domain.member.service.MemberService; -import com.tave.tavewebsite.global.mail.dto.MailResponseDto; import com.tave.tavewebsite.global.success.SuccessResponse; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/manager") +@RequestMapping("/v1") @RequiredArgsConstructor public class ManagerController { private final MemberService memberService; + private final AdminService adminService; + + @PostMapping("/normal/authenticate/email") + public SuccessResponse sendEmail(@RequestBody ValidateEmailReq requestDto) { + + memberService.sendMessage(requestDto); + + return SuccessResponse.ok("이메일로 인증 번호가 전송되었습니다!"); + } + + @GetMapping("/normal/verify/number") + public SuccessResponse verifyNumber(@RequestBody ValidateEmailReq requestDto) { + memberService.verityNumber(requestDto); - @PostMapping - public SuccessResponse registerManager(@RequestBody @Valid RegisterManagerRequestDto requestDto) { - MailResponseDto response = memberService.saveMember(requestDto); - return new SuccessResponse<>(response); + return SuccessResponse.ok("인증되었습니다!"); } - @GetMapping("/unauthorized") - public SuccessResponse> getUnauthorizedManager(){ - List response = memberService.getUnauthorizedManager(); - return new SuccessResponse<>(response); + @GetMapping("/normal/upgrade/{memberId}") + public SuccessResponse updateAuthentication(@PathVariable("memberId") String memberId) { + adminService.updateAuthentication(memberId); + + return new SuccessResponse("update Success."); } - @GetMapping("/{nickName}") - public SuccessResponse checkNickName(@PathVariable String nickName){ + @GetMapping("/normal/validate/{nickName}") + public SuccessResponse checkNickName(@PathVariable("nickName") String nickName) { memberService.validateNickname(nickName); CheckNickNameResponseDto response = new CheckNickNameResponseDto(nickName); - return new SuccessResponse<>(response, nickName+" 사용가능합니다."); + return new SuccessResponse<>(response, nickName + " 사용가능합니다."); } + + @DeleteMapping("/{memberId}") + public SuccessResponse deleteMember(@PathVariable("memberId") Long memberId) { + memberService.deleteMember(memberId); + return SuccessResponse.ok(); + } + + @PutMapping("/normal/reset/password") + public SuccessResponse resetPassword(@RequestBody ResetPasswordReq requestDto) { + memberService.resetPassword(requestDto); + + return SuccessResponse.ok("비밀번호가 재설정되었습니다.\n 다시 로그인해주세요!"); + } + + // ci/cd 이후 배포 성공 테스트용 엔드포인트 + @GetMapping("/test") + public String test() { + return "test"; + } + } diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/request/RefreshTokenRequestDto.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/request/RefreshTokenRequestDto.java new file mode 100644 index 00000000..c89ad6c7 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/request/RefreshTokenRequestDto.java @@ -0,0 +1,6 @@ +package com.tave.tavewebsite.domain.member.dto.request; + +public record RefreshTokenRequestDto( + String email +) { +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/request/ResetPasswordReq.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/request/ResetPasswordReq.java new file mode 100644 index 00000000..86f8a5bc --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/request/ResetPasswordReq.java @@ -0,0 +1,12 @@ +package com.tave.tavewebsite.domain.member.dto.request; + +import jakarta.validation.constraints.Pattern; + +public record ResetPasswordReq( + String nickname, + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\W).+$", + message = "비밀번호는 8자 이상이어야 하며, 대문자, 소문자, 특수문자를 포함해야 합니다.") + String password, + String validatedPassword +) { +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/request/SignUpRequestDto.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/request/SignUpRequestDto.java new file mode 100644 index 00000000..ee61a8cd --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/request/SignUpRequestDto.java @@ -0,0 +1,7 @@ +package com.tave.tavewebsite.domain.member.dto.request; + +public record SignUpRequestDto( + String email, + String password +) { +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/request/ValidateEmailReq.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/request/ValidateEmailReq.java new file mode 100644 index 00000000..90edd1a1 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/request/ValidateEmailReq.java @@ -0,0 +1,8 @@ +package com.tave.tavewebsite.domain.member.dto.request; + +public record ValidateEmailReq( + String nickname, + String email, + String number +){ +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/response/AuthorizedManagerResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/AuthorizedManagerResponseDto.java new file mode 100644 index 00000000..6a454616 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/AuthorizedManagerResponseDto.java @@ -0,0 +1,25 @@ +package com.tave.tavewebsite.domain.member.dto.response; + +import com.tave.tavewebsite.domain.member.entity.DepartmentType; +import com.tave.tavewebsite.domain.member.entity.JobType; +import com.tave.tavewebsite.domain.member.entity.Member; + +public record AuthorizedManagerResponseDto( + String username, + String nickname, + DepartmentType department, + JobType job, + String generation, + String agitId +) { + public static AuthorizedManagerResponseDto fromEntity(Member member) { + return new AuthorizedManagerResponseDto( + member.getUsername(), + member.getNickname(), + member.getDepartment(), + member.getJob(), + member.getGeneration(), + member.getAgitId() + ); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/response/CheckNickNameResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/CheckNickNameResponseDto.java index 8e747ba9..933261cb 100644 --- a/src/main/java/com/tave/tavewebsite/domain/member/dto/response/CheckNickNameResponseDto.java +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/CheckNickNameResponseDto.java @@ -1,6 +1,6 @@ package com.tave.tavewebsite.domain.member.dto.response; public record CheckNickNameResponseDto( - String email + String nickName ) { } diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/response/RefreshResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/RefreshResponseDto.java new file mode 100644 index 00000000..775cef72 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/RefreshResponseDto.java @@ -0,0 +1,12 @@ +package com.tave.tavewebsite.domain.member.dto.response; + +import com.tave.tavewebsite.global.security.entity.JwtToken; + +public record RefreshResponseDto( + String grantType, + String accessToken +) { + public static RefreshResponseDto from(JwtToken jwtToken) { + return new RefreshResponseDto(jwtToken.getGrantType(), jwtToken.getAccessToken()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/response/SignInResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/SignInResponseDto.java new file mode 100644 index 00000000..99372c3c --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/SignInResponseDto.java @@ -0,0 +1,32 @@ +package com.tave.tavewebsite.domain.member.dto.response; + +import com.tave.tavewebsite.domain.member.entity.DepartmentType; +import com.tave.tavewebsite.domain.member.entity.JobType; +import com.tave.tavewebsite.domain.member.entity.Member; +import com.tave.tavewebsite.global.security.entity.JwtToken; + +public record SignInResponseDto( + String grantType, + String accessToken, + Long memberId, + String email, + String nickname, + String username, + String agitId, + String generation, + DepartmentType department, + JobType job +) { + public static SignInResponseDto from(JwtToken token, Member member) { + return new SignInResponseDto(token.getGrantType(), + token.getAccessToken(), + member.getId(), + member.getEmail(), + member.getNickname(), + member.getUsername(), + member.getAgitId(), + member.getGeneration(), + member.getDepartment(), + member.getJob()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/dto/response/SingUpResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/SingUpResponseDto.java new file mode 100644 index 00000000..3cd46402 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/dto/response/SingUpResponseDto.java @@ -0,0 +1,8 @@ +package com.tave.tavewebsite.domain.member.dto.response; + +import com.tave.tavewebsite.global.security.entity.JwtToken; + +public record SingUpResponseDto( + JwtToken jwtToken +) { +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/entity/Member.java b/src/main/java/com/tave/tavewebsite/domain/member/entity/Member.java index 0b3d3984..0336fcc0 100644 --- a/src/main/java/com/tave/tavewebsite/domain/member/entity/Member.java +++ b/src/main/java/com/tave/tavewebsite/domain/member/entity/Member.java @@ -1,13 +1,24 @@ package com.tave.tavewebsite.domain.member.entity; +import static com.tave.tavewebsite.domain.member.entity.RoleType.MANAGER; +import static com.tave.tavewebsite.domain.member.entity.RoleType.UNAUTHORIZED_MANAGER; + import com.tave.tavewebsite.domain.member.dto.request.RegisterManagerRequestDto; import com.tave.tavewebsite.global.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.*; - -import static com.tave.tavewebsite.domain.member.entity.RoleType.UNAUTHORIZED_MANAGER; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; @Entity @Getter @@ -58,7 +69,8 @@ public class Member extends BaseEntity { @Builder - public Member(String email, String password, RoleType role, String nickname, String username, String agitId, String generation, DepartmentType department, JobType job) { + public Member(String email, String password, RoleType role, String nickname, String username, String agitId, + String generation, DepartmentType department, JobType job) { this.email = email; this.password = password; this.role = role; @@ -71,10 +83,11 @@ public Member(String email, String password, RoleType role, String nickname, Str } // 패스워드 인코딩 필요 - public static Member toMember(RegisterManagerRequestDto registerManagerRequestDto) { + public static Member toMember(RegisterManagerRequestDto registerManagerRequestDto, + PasswordEncoder passwordEncoder) { return Member.builder() .email(registerManagerRequestDto.email()) - .password(registerManagerRequestDto.password()) + .password(passwordEncoder.encode(registerManagerRequestDto.password())) .agitId(registerManagerRequestDto.agitId()) .nickname(registerManagerRequestDto.nickname()) .username(registerManagerRequestDto.username()) @@ -84,4 +97,12 @@ public static Member toMember(RegisterManagerRequestDto registerManagerRequestDt .department(registerManagerRequestDto.department()) .build(); } + + public void update(String validatedPassword, PasswordEncoder passwordEncoder) { + this.password = passwordEncoder.encode(validatedPassword); + } + + public void updateRole() { + this.role = MANAGER; + } } diff --git a/src/main/java/com/tave/tavewebsite/domain/member/exception/ErrorMessage.java b/src/main/java/com/tave/tavewebsite/domain/member/exception/ErrorMessage.java index e36a84c0..7d82f702 100644 --- a/src/main/java/com/tave/tavewebsite/domain/member/exception/ErrorMessage.java +++ b/src/main/java/com/tave/tavewebsite/domain/member/exception/ErrorMessage.java @@ -9,7 +9,10 @@ public enum ErrorMessage { NOT_FOUNT_USER(400, "유저를 찾을 수 없습니다."), DUPLICATE_EMAIL(400, "중복되는 이메일입니다."), - DUPLICATE_NICKNAME(400, "이미 사용중인 아이디입니다. 다른 아이디로 가입해주세요."); + DUPLICATE_NICKNAME(400, "이미 사용중인 아이디입니다. 다른 아이디로 가입해주세요."), + _NOT_MATCHED_VERIFIED_NUMBER(400, "인증 번호가 일치하지 않습니다."), + _EXPIRED_NUMBER(401, "인증번호의 만료시간이 지났습니다."), + _NOT_MATCHED_PASSWORD(400, "비밀번호가 일치하지 않습니다."); final int code; final String message; diff --git a/src/main/java/com/tave/tavewebsite/domain/member/exception/ExpiredNumberException.java b/src/main/java/com/tave/tavewebsite/domain/member/exception/ExpiredNumberException.java new file mode 100644 index 00000000..a03027c1 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/exception/ExpiredNumberException.java @@ -0,0 +1,11 @@ +package com.tave.tavewebsite.domain.member.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +import static com.tave.tavewebsite.domain.member.exception.ErrorMessage._EXPIRED_NUMBER; + +public class ExpiredNumberException extends BaseErrorException { + public ExpiredNumberException() { + super(_EXPIRED_NUMBER.getCode(), _EXPIRED_NUMBER.getMessage()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/exception/NotMatchedNumberException.java b/src/main/java/com/tave/tavewebsite/domain/member/exception/NotMatchedNumberException.java new file mode 100644 index 00000000..79a0995b --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/exception/NotMatchedNumberException.java @@ -0,0 +1,11 @@ +package com.tave.tavewebsite.domain.member.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +import static com.tave.tavewebsite.domain.member.exception.ErrorMessage._NOT_MATCHED_VERIFIED_NUMBER; + +public class NotMatchedNumberException extends BaseErrorException { + public NotMatchedNumberException() { + super(_NOT_MATCHED_VERIFIED_NUMBER.getCode(), _NOT_MATCHED_VERIFIED_NUMBER.getMessage()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/exception/NotMatchedPassword.java b/src/main/java/com/tave/tavewebsite/domain/member/exception/NotMatchedPassword.java new file mode 100644 index 00000000..7f6f764e --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/exception/NotMatchedPassword.java @@ -0,0 +1,11 @@ +package com.tave.tavewebsite.domain.member.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +import static com.tave.tavewebsite.domain.member.exception.ErrorMessage._NOT_MATCHED_PASSWORD; + +public class NotMatchedPassword extends BaseErrorException { + public NotMatchedPassword() { + super(_NOT_MATCHED_PASSWORD.getCode(), _NOT_MATCHED_PASSWORD.getMessage()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/service/AdminService.java b/src/main/java/com/tave/tavewebsite/domain/member/service/AdminService.java new file mode 100644 index 00000000..313773c2 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/service/AdminService.java @@ -0,0 +1,42 @@ +package com.tave.tavewebsite.domain.member.service; + +import static com.tave.tavewebsite.domain.member.entity.RoleType.MANAGER; +import static com.tave.tavewebsite.domain.member.entity.RoleType.UNAUTHORIZED_MANAGER; + +import com.tave.tavewebsite.domain.member.dto.response.AuthorizedManagerResponseDto; +import com.tave.tavewebsite.domain.member.dto.response.UnauthorizedManagerResponseDto; +import com.tave.tavewebsite.domain.member.entity.Member; +import com.tave.tavewebsite.domain.member.exception.NotFoundMemberException; +import com.tave.tavewebsite.domain.member.memberRepository.MemberRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AdminService { + + private final MemberRepository memberRepository; + + public void updateAuthentication(String memberId) { + Long id = Long.valueOf(memberId); + Member member = memberRepository.findById(id).orElseThrow(NotFoundMemberException::new); + member.updateRole(); + } + + @Transactional(readOnly = true) + public List getAuthorizedAdmins() { + return memberRepository.findByRole(MANAGER).stream() + .map(AuthorizedManagerResponseDto::fromEntity) + .toList(); + } + + @Transactional(readOnly = true) + public List getUnauthorizedManager() { + return memberRepository.findByRole(UNAUTHORIZED_MANAGER).stream() + .map(UnauthorizedManagerResponseDto::fromEntity) + .toList(); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/service/AuthService.java b/src/main/java/com/tave/tavewebsite/domain/member/service/AuthService.java new file mode 100644 index 00000000..830261ff --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/member/service/AuthService.java @@ -0,0 +1,70 @@ +package com.tave.tavewebsite.domain.member.service; + +import com.tave.tavewebsite.domain.member.dto.request.RefreshTokenRequestDto; +import com.tave.tavewebsite.domain.member.dto.request.SignUpRequestDto; +import com.tave.tavewebsite.domain.member.dto.response.RefreshResponseDto; +import com.tave.tavewebsite.domain.member.dto.response.SignInResponseDto; +import com.tave.tavewebsite.domain.member.exception.NotFoundMemberException; +import com.tave.tavewebsite.domain.member.memberRepository.MemberRepository; +import com.tave.tavewebsite.global.redis.utils.RedisUtil; +import com.tave.tavewebsite.global.security.entity.JwtToken; +import com.tave.tavewebsite.global.security.exception.JwtValidException.NotMatchRefreshTokenException; +import com.tave.tavewebsite.global.security.utils.CookieUtil; +import com.tave.tavewebsite.global.security.utils.JwtTokenProvider; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AuthService { + private final MemberRepository memberRepository; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final JwtTokenProvider jwtTokenProvider; + private final RedisUtil redisUtil; + + private static final int REFRESH_TOKEN_MAX_AGE = 60 * 24 * 3; + private static final int ACCESS_TOKEN_MAX_AGE = 30; + + public SignInResponseDto signIn(SignUpRequestDto requestDto, HttpServletResponse response) { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + requestDto.email(), + requestDto.password()); + authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + JwtToken jwtToken = generateToken(requestDto.email()); + CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken()); + + return SignInResponseDto.from(generateToken(requestDto.email()), + memberRepository.findByEmail(requestDto.email()).get()); + + } + + public void singOut(String accessToken) { + redisUtil.setBlackList(accessToken, "ban accessToken", ACCESS_TOKEN_MAX_AGE); + } + + public RefreshResponseDto reissueToken(RefreshTokenRequestDto requestDto, String refreshToken, + HttpServletResponse response) { + Object refreshTokenByRedis = redisUtil.get(requestDto.email()); + if (!refreshTokenByRedis.equals(refreshToken)) { + throw new NotMatchRefreshTokenException(); + } + + JwtToken jwtToken = generateToken(requestDto.email()); + CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken()); + return RefreshResponseDto.from(jwtToken); + } + + private JwtToken generateToken(String email) { + JwtToken jwtToken = jwtTokenProvider.generateToken( + memberRepository.findByEmail(email).orElseThrow(NotFoundMemberException::new)); + redisUtil.set(email, jwtToken.getRefreshToken(), REFRESH_TOKEN_MAX_AGE); + + return jwtToken; + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/member/service/MemberService.java b/src/main/java/com/tave/tavewebsite/domain/member/service/MemberService.java index b35bf86c..61199335 100644 --- a/src/main/java/com/tave/tavewebsite/domain/member/service/MemberService.java +++ b/src/main/java/com/tave/tavewebsite/domain/member/service/MemberService.java @@ -1,22 +1,24 @@ package com.tave.tavewebsite.domain.member.service; import com.tave.tavewebsite.domain.member.dto.request.RegisterManagerRequestDto; -import com.tave.tavewebsite.domain.member.dto.response.UnauthorizedManagerResponseDto; +import com.tave.tavewebsite.domain.member.dto.request.ResetPasswordReq; +import com.tave.tavewebsite.domain.member.dto.request.ValidateEmailReq; import com.tave.tavewebsite.domain.member.entity.Member; -import com.tave.tavewebsite.domain.member.entity.RoleType; import com.tave.tavewebsite.domain.member.exception.DuplicateEmailException; import com.tave.tavewebsite.domain.member.exception.DuplicateNicknameException; +import com.tave.tavewebsite.domain.member.exception.ExpiredNumberException; +import com.tave.tavewebsite.domain.member.exception.NotFoundMemberException; +import com.tave.tavewebsite.domain.member.exception.NotMatchedNumberException; +import com.tave.tavewebsite.domain.member.exception.NotMatchedPassword; import com.tave.tavewebsite.domain.member.memberRepository.MemberRepository; -import com.tave.tavewebsite.global.mail.service.MailService; import com.tave.tavewebsite.global.mail.dto.MailResponseDto; +import com.tave.tavewebsite.global.mail.service.MailService; +import com.tave.tavewebsite.global.redis.utils.RedisUtil; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - -import static com.tave.tavewebsite.domain.member.entity.RoleType.UNAUTHORIZED_MANAGER; - @Service @Transactional @RequiredArgsConstructor @@ -24,37 +26,68 @@ public class MemberService { private final MemberRepository memberRepository; private final MailService mailService; + private final PasswordEncoder passwordEncoder; + private final RedisUtil redisUtil; - public MailResponseDto saveMember(RegisterManagerRequestDto requestDto){ + public MailResponseDto saveMember(RegisterManagerRequestDto requestDto) { validateNickname(requestDto.nickname()); validateEmail(requestDto.email()); - // PasswordEncoder 추가되면 - // toMember에서 encode(requestDto.getPassword()) 추가하기 - // 추가 후 위 주석은 삭제 - Member saveMember = memberRepository.save(Member.toMember(requestDto)); + Member saveMember = memberRepository.save(Member.toMember(requestDto, passwordEncoder)); return mailService.sendManagerRegisterMessage(saveMember.getEmail()); } - @Transactional(readOnly = true) - public List getUnauthorizedManager(){ - return memberRepository.findByRole(UNAUTHORIZED_MANAGER).stream() - .map(UnauthorizedManagerResponseDto::fromEntity) - .toList(); + public void deleteMember(long id) { + memberRepository.findById(id).orElseThrow(NotFoundMemberException::new); + memberRepository.deleteById(id); } - private void validateEmail(String email){ - memberRepository.findByEmail(email).ifPresent( - member -> {throw new DuplicateEmailException();} - ); + public void sendMessage(ValidateEmailReq req) { + memberRepository.findByNickname(req.nickname()).orElseThrow(NotFoundMemberException::new); + memberRepository.findByEmail(req.email()).orElseThrow(NotFoundMemberException::new); + + mailService.sendAuthenticationCode(req.email()); + } + + public void verityNumber(ValidateEmailReq req) { + memberRepository.findByEmail(req.email()).orElseThrow(NotFoundMemberException::new); + String validatedNumber = (String) redisUtil.get(req.email()); + + if (!req.number().equals(validatedNumber)) { + throw new NotMatchedNumberException(); + } else if (redisUtil.checkExpired(req.email()) <= 0 || redisUtil.checkExpired(req.email()) == null) { + throw new ExpiredNumberException(); + } + + redisUtil.delete(req.email()); } - public void validateNickname(String nickname){ + public void resetPassword(ResetPasswordReq req) { + Member member = memberRepository.findByNickname(req.nickname()).orElseThrow(NotFoundMemberException::new); + + if (!req.password().equals(req.validatedPassword())) { + throw new NotMatchedPassword(); + } + + member.update(req.validatedPassword(), passwordEncoder); + memberRepository.save(member); + } + + public void validateNickname(String nickname) { memberRepository.findByNickname(nickname).ifPresent( - member -> {throw new DuplicateNicknameException();} + member -> { + throw new DuplicateNicknameException(); + } ); } + private void validateEmail(String email) { + memberRepository.findByEmail(email).ifPresent( + member -> { + throw new DuplicateEmailException(); + } + ); + } } diff --git a/src/main/java/com/tave/tavewebsite/domain/project/controller/ProjectController.java b/src/main/java/com/tave/tavewebsite/domain/project/controller/ProjectController.java new file mode 100644 index 00000000..56a32ae8 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/controller/ProjectController.java @@ -0,0 +1,50 @@ +package com.tave.tavewebsite.domain.project.controller; + +import com.tave.tavewebsite.domain.project.dto.request.ProjectRequestDto; +import com.tave.tavewebsite.domain.project.dto.response.ProjectResponseDto; +import com.tave.tavewebsite.domain.project.service.ProjectService; +import com.tave.tavewebsite.global.success.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/v1") +@RequiredArgsConstructor +public class ProjectController { + + private final ProjectService projectService; + + @PostMapping("/manager/project") + public SuccessResponse createProject(@RequestPart @Valid ProjectRequestDto req, @RequestPart MultipartFile imageFile) { + projectService.createProject(req, imageFile); + return SuccessResponse.ok(SuccessMessage.PROJECT_CREATE.getMessage()); + } + + + @GetMapping("/normal/project") + public SuccessResponse> getProjects(@PageableDefault(size = 8) Pageable pageable, + @RequestParam(defaultValue = "ALL", name = "generation") String generation, + @RequestParam(defaultValue = "ALL", name = "field") String field) { + Page projects = projectService.getProjects(generation, field, pageable); + return new SuccessResponse<>(projects); + } + + @PutMapping("/manager/project/{projectId}") + public SuccessResponse updateProject(@PathVariable Long projectId, + @RequestPart @Valid ProjectRequestDto req, + @RequestPart MultipartFile imageFile) { + projectService.updateProject(projectId, req, imageFile); + return SuccessResponse.ok(SuccessMessage.PROJECT_UPDATE.getMessage()); + } + + @DeleteMapping("/manager/project/{projectId}") + public SuccessResponse deleteProject(@PathVariable Long projectId) { + projectService.deleteProject(projectId); + return SuccessResponse.ok(SuccessMessage.PROJECT_DELETE.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/domain/project/controller/SuccessMessage.java b/src/main/java/com/tave/tavewebsite/domain/project/controller/SuccessMessage.java new file mode 100644 index 00000000..8097ed0f --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/controller/SuccessMessage.java @@ -0,0 +1,21 @@ +package com.tave.tavewebsite.domain.project.controller; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum SuccessMessage { + PROJECT_CREATE("프로젝트를 생성했습니다."), + PROJECT_READ("프로젝트를 조회했습니다."), + PROJECT_UPDATE("프로젝트를 수정했습니다."), + PROJECT_DELETE("프로젝트를 삭제했습니다."); + + private final String message; + + public String getMessage(String generation) { + return generation +" " + message; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/project/dto/request/ProjectRequestDto.java b/src/main/java/com/tave/tavewebsite/domain/project/dto/request/ProjectRequestDto.java new file mode 100644 index 00000000..e71db245 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/dto/request/ProjectRequestDto.java @@ -0,0 +1,32 @@ +package com.tave.tavewebsite.domain.project.dto.request; + +import com.tave.tavewebsite.global.common.FieldType; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class ProjectRequestDto { + + @NotNull + @Size(min = 1, max = 30) + private String title; + + @NotNull + @Size(min = 1, max = 500) + private String description; + + @NotNull + @Size(min = 1, max = 5) + private String generation; + + @NotNull + @Size(min = 1, max = 30) + private String teamName; + + @NotNull + private FieldType field; + + @NotNull + private String blogUrl; +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/domain/project/dto/response/ProjectResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/project/dto/response/ProjectResponseDto.java new file mode 100644 index 00000000..2873f2d2 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/dto/response/ProjectResponseDto.java @@ -0,0 +1,26 @@ +package com.tave.tavewebsite.domain.project.dto.response; + +import com.tave.tavewebsite.domain.project.entity.Project; +import com.tave.tavewebsite.global.common.FieldType; +import lombok.Getter; + +@Getter +public class ProjectResponseDto { + private final Long id; + private final String title; + private final String description; + private final String generation; + private final String teamName; + private final FieldType field; + private final String blogUrl; + + public ProjectResponseDto(Project project) { + this.id = project.getId(); + this.title = project.getTitle(); + this.description = project.getDescription(); + this.generation = project.getGeneration(); + this.teamName = project.getTeamName(); + this.field = project.getField(); + this.blogUrl = project.getBlogUrl(); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/project/entity/Project.java b/src/main/java/com/tave/tavewebsite/domain/project/entity/Project.java index 559d3f52..ddbd3b03 100644 --- a/src/main/java/com/tave/tavewebsite/domain/project/entity/Project.java +++ b/src/main/java/com/tave/tavewebsite/domain/project/entity/Project.java @@ -1,5 +1,6 @@ package com.tave.tavewebsite.domain.project.entity; +import com.tave.tavewebsite.domain.project.dto.request.ProjectRequestDto; import com.tave.tavewebsite.global.common.BaseEntity; import com.tave.tavewebsite.global.common.FieldType; import jakarta.persistence.*; @@ -9,9 +10,8 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.validator.constraints.URL; -import java.time.LocalDateTime; +import java.net.URL; @Entity @Getter @@ -49,33 +49,32 @@ public class Project extends BaseEntity { private FieldType field; @NotNull - @Column(nullable = false) - private LocalDateTime startDate; - - @NotNull - @Column(nullable = false) - private LocalDateTime endDate; - - @NotNull - @URL @Column(length = 2083, nullable = false) private String blogUrl; @NotNull - @URL @Column(length = 2083, nullable = false) private String imgUrl; @Builder - public Project(String title, String description, String generation, String teamName, FieldType field, LocalDateTime startDate, LocalDateTime endDate, String blogUrl, String imgUrl) { - this.title = title; - this.description = description; - this.generation = generation; - this.teamName = teamName; - this.field = field; - this.startDate = startDate; - this.endDate = endDate; - this.blogUrl = blogUrl; - this.imgUrl = imgUrl; + public Project(ProjectRequestDto req, URL imageUrl) { + this.title = req.getTitle(); + this.description = req.getDescription(); + this.generation = req.getGeneration(); + this.teamName = req.getTeamName(); + this.field = req.getField(); + this.blogUrl = req.getBlogUrl(); + this.imgUrl = imageUrl.toString(); + } + + public Project updateProject(ProjectRequestDto req, URL imageUrl) { + this.title = req.getTitle(); + this.description = req.getDescription(); + this.generation = req.getGeneration(); + this.teamName = req.getTeamName(); + this.field = req.getField(); + this.blogUrl = req.getBlogUrl(); + this.imgUrl = imageUrl.toString(); + return this; } } diff --git a/src/main/java/com/tave/tavewebsite/domain/project/exception/ErrorMessage.java b/src/main/java/com/tave/tavewebsite/domain/project/exception/ErrorMessage.java new file mode 100644 index 00000000..8c1a151b --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/exception/ErrorMessage.java @@ -0,0 +1,14 @@ +package com.tave.tavewebsite.domain.project.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + + PROJECT_NOT_FOUND(400, "프로젝트를 찾을 수 없습니다."); + + private final int code; + private final String message; +} diff --git a/src/main/java/com/tave/tavewebsite/domain/project/exception/ProjectNotFoundException.java b/src/main/java/com/tave/tavewebsite/domain/project/exception/ProjectNotFoundException.java new file mode 100644 index 00000000..ca0732a4 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/exception/ProjectNotFoundException.java @@ -0,0 +1,11 @@ +package com.tave.tavewebsite.domain.project.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +import static com.tave.tavewebsite.domain.project.exception.ErrorMessage.PROJECT_NOT_FOUND; + +public class ProjectNotFoundException extends BaseErrorException { + public ProjectNotFoundException() { + super(PROJECT_NOT_FOUND.getCode(), PROJECT_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/project/repository/CustomProjectRepository.java b/src/main/java/com/tave/tavewebsite/domain/project/repository/CustomProjectRepository.java new file mode 100644 index 00000000..75829148 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/repository/CustomProjectRepository.java @@ -0,0 +1,15 @@ +package com.tave.tavewebsite.domain.project.repository; + +import com.tave.tavewebsite.domain.project.dto.response.ProjectResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CustomProjectRepository { + Page findProjectByGenerationAndField(String generation, String field, Pageable pageable); + + Page findProjectByGeneration(String generation, Pageable pageable); + + Page findProjectByField(String field, Pageable pageable); + + Page findAllProjects(Pageable pageable); +} diff --git a/src/main/java/com/tave/tavewebsite/domain/project/repository/CustomProjectRepositoryImpl.java b/src/main/java/com/tave/tavewebsite/domain/project/repository/CustomProjectRepositoryImpl.java new file mode 100644 index 00000000..cb1e8560 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/repository/CustomProjectRepositoryImpl.java @@ -0,0 +1,72 @@ +package com.tave.tavewebsite.domain.project.repository; + +import com.tave.tavewebsite.domain.project.dto.response.ProjectResponseDto; +import com.tave.tavewebsite.domain.project.entity.Project; +import com.tave.tavewebsite.global.common.FieldType; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@Slf4j +public class CustomProjectRepositoryImpl implements CustomProjectRepository { + @PersistenceContext + private EntityManager em; + + @Override + public Page findProjectByGenerationAndField(String generation, String field, Pageable pageable) { + List projects = em.createQuery("select p from Project p where p.field=:field and p.generation = :generation", Project.class) + .setParameter("field", FieldType.valueOf(field)) + .setParameter("generation", generation) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + return getResult(projects, pageable); + } + + @Override + public Page findProjectByGeneration(String generation, Pageable pageable) { + List projects = em.createQuery("select p from Project p where p.generation = :generation", Project.class) + .setParameter("generation", generation) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + return getResult(projects, pageable); + } + + @Override + public Page findProjectByField(String field, Pageable pageable) { + List projects = em.createQuery("select p from Project p where p.field = :field", Project.class) + .setParameter("field", FieldType.valueOf(field)) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + return getResult(projects, pageable); + } + + @Override + public Page findAllProjects(Pageable pageable) { + // 전체 프로젝트를 가져오는 쿼리 수정 + List projects = em.createQuery("select p from Project p order by p.createdAt", Project.class) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + log.info("findAllProjects: {}", projects.size()); + + return getResult(projects, pageable); + } + + private Page getResult(List projects, Pageable pageable) { + List projectResponseDtos = projects.stream() + .map(ProjectResponseDto::new) // Project 객체를 매개변수로 받는 생성자를 사용 + .toList(); + + // 페이지네이션을 위한 반환 + return new PageImpl<>(projectResponseDtos, pageable, projects.size()); + } +} + diff --git a/src/main/java/com/tave/tavewebsite/domain/project/repository/ProjectRepository.java b/src/main/java/com/tave/tavewebsite/domain/project/repository/ProjectRepository.java new file mode 100644 index 00000000..3e00eb8d --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/repository/ProjectRepository.java @@ -0,0 +1,8 @@ +package com.tave.tavewebsite.domain.project.repository; + +import com.tave.tavewebsite.domain.project.entity.Project; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProjectRepository extends JpaRepository, CustomProjectRepository { + +} diff --git a/src/main/java/com/tave/tavewebsite/domain/project/service/ProjectService.java b/src/main/java/com/tave/tavewebsite/domain/project/service/ProjectService.java new file mode 100644 index 00000000..182a68f1 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/project/service/ProjectService.java @@ -0,0 +1,91 @@ +package com.tave.tavewebsite.domain.project.service; + +import com.tave.tavewebsite.domain.project.dto.request.ProjectRequestDto; +import com.tave.tavewebsite.domain.project.dto.response.ProjectResponseDto; +import com.tave.tavewebsite.domain.project.entity.Project; +import com.tave.tavewebsite.domain.project.exception.ProjectNotFoundException; +import com.tave.tavewebsite.domain.project.repository.ProjectRepository; +import com.tave.tavewebsite.global.s3.service.S3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URL; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProjectService { + + private final ProjectRepository projectRepository; + private final S3Service s3Service; + + // 프로젝트 생성 + public void createProject(ProjectRequestDto req, MultipartFile file) { + // 파일을 S3에 업로드하고 URL을 받음 + URL url = s3Service.uploadImages(file); + + // ProjectRequestDto에서 값을 추출하여 Project 엔티티 생성 +// Project project = new Project(req.getTitle(), req.getDescription(), req.getGeneration(), req.getFieldType(), url); + Project project = new Project(req, url); + + projectRepository.save(project); + } + + // 프로젝트 조회 + @Transactional(readOnly = true) + public Page getProjects(String generation, String field, Pageable pageable) { + Page projects; + + try { + log.info("field: {}, generation: {}", field, generation); + + // 필드와 세대 조건에 맞는 프로젝트 찾기 + if ("ALL".equals(generation) && "ALL".equals(field)) { + projects = projectRepository.findAllProjects(pageable); + } else if ("ALL".equals(generation)) { + projects = projectRepository.findProjectByField(field, pageable); + } else if ("ALL".equals(field)) { + projects = projectRepository.findProjectByGeneration(generation, pageable); + } else { + projects = projectRepository.findProjectByGenerationAndField(generation, field, pageable); + } + } catch (Exception e) { + // 예외 발생 시 PROJECT_NOT_FOUND 예외 던지기 + throw new ProjectNotFoundException(); + } + + return projects; + } + + // 프로젝트 수정 + @Transactional + public void updateProject(Long projectId, ProjectRequestDto req, MultipartFile file) { + // 프로젝트가 존재하는지 확인 + Project project = projectRepository.findById(projectId) + .orElseThrow(ProjectNotFoundException::new); // ProjectNotFoundException 발생 + + // 파일을 S3에 업로드하고 URL을 받음 + URL url = s3Service.uploadImages(file); + + // 프로젝트 수정 + project.updateProject(req, url); + } + + // 프로젝트 삭제 + public void deleteProject(Long projectId) { + // 프로젝트가 존재하는지 확인 + Project project = projectRepository.findById(projectId) + .orElseThrow(ProjectNotFoundException::new); + + // S3에서 이미지 삭제 + s3Service.deleteImage(project.getImgUrl()); + + // 프로젝트 삭제 + projectRepository.delete(project); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/controller/ReviewController.java b/src/main/java/com/tave/tavewebsite/domain/review/controller/ReviewController.java new file mode 100644 index 00000000..d9f09bb9 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/controller/ReviewController.java @@ -0,0 +1,66 @@ +package com.tave.tavewebsite.domain.review.controller; + + +import com.tave.tavewebsite.domain.review.dto.request.ReviewRequestDto; +import com.tave.tavewebsite.domain.review.dto.response.ReviewResponseDto; +import com.tave.tavewebsite.domain.review.service.ReviewService; +import com.tave.tavewebsite.global.success.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.tave.tavewebsite.domain.review.controller.SuccessMessage.*; + +@Slf4j +@RequestMapping("/v1") +@RestController +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping("/manager/review") + public SuccessResponse registerReview(@RequestBody @Valid ReviewRequestDto requestDto) { + ReviewResponseDto response = reviewService.saveReview(requestDto); + return new SuccessResponse<>( + response, + REVIEW_CREATE.getMessage(response.generation()) + ); + } + + @GetMapping("/manager/review/{generation}") + public SuccessResponse> getAllReviews(@PathVariable String generation) { + List response = reviewService.findAllReviewsByGeneration(generation); + + return new SuccessResponse<>( + response, + REVIEW_GET_PUBLIC.getMessage(generation) + ); + } + + // 비회원이 후기 조회 + @GetMapping("/normal/review") + public SuccessResponse> getPublicReviews() { + List response = reviewService.findPublicReviews(); + return new SuccessResponse<>( + response, + REVIEW_GET_PRIVATE.getMessage() + ); + } + + @PatchMapping("/manager/review/{reviewId}") + public SuccessResponse updateReview(@PathVariable Long reviewId, + @RequestBody ReviewRequestDto requestDto) { + reviewService.updateReview(reviewId, requestDto); + return SuccessResponse.ok(REVIEW_UPDATE.getMessage()); + } + + @DeleteMapping("/manager/review/{reviewId}") + public SuccessResponse deleteReview(@PathVariable Long reviewId) { + reviewService.deleteReivew(reviewId); + return SuccessResponse.ok(REVIEW_DELETE.getMessage()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/controller/SuccessMessage.java b/src/main/java/com/tave/tavewebsite/domain/review/controller/SuccessMessage.java new file mode 100644 index 00000000..ab9899f1 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/controller/SuccessMessage.java @@ -0,0 +1,24 @@ +package com.tave.tavewebsite.domain.review.controller; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +public enum SuccessMessage { + + REVIEW_CREATE("후기를 생성합니다."), + REVIEW_GET_PUBLIC("공개 후기를 반환합니다."), + REVIEW_GET_PRIVATE("비공개 후기를 반환합니다."), + REVIEW_UPDATE("후기를 수정했습니다."), + REVIEW_DELETE("후기를 삭제했습니다."); + + private final String message; + + public String getMessage(String generation) { + return generation +" " + message; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/dto/request/ReviewRequestDto.java b/src/main/java/com/tave/tavewebsite/domain/review/dto/request/ReviewRequestDto.java new file mode 100644 index 00000000..ca53212e --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/dto/request/ReviewRequestDto.java @@ -0,0 +1,16 @@ +package com.tave.tavewebsite.domain.review.dto.request; + +import com.tave.tavewebsite.global.common.FieldType; +import jakarta.validation.constraints.NotNull; + +public record ReviewRequestDto( + + String nickname, + String generation, + FieldType field, + @NotNull(message = "필수로 입력하셔야합니다.") + String content, + boolean isPublic + +) { +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/dto/response/ReviewResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/review/dto/response/ReviewResponseDto.java new file mode 100644 index 00000000..b50c8bde --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/dto/response/ReviewResponseDto.java @@ -0,0 +1,16 @@ +package com.tave.tavewebsite.domain.review.dto.response; + + +import com.tave.tavewebsite.global.common.FieldType; +import lombok.Builder; + +@Builder +public record ReviewResponseDto( + Long id, + String nickname, + String generation, + FieldType field, + String content, + boolean isPublic +) { +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/entity/Review.java b/src/main/java/com/tave/tavewebsite/domain/review/entity/Review.java index d25b3c1a..cefefe8a 100644 --- a/src/main/java/com/tave/tavewebsite/domain/review/entity/Review.java +++ b/src/main/java/com/tave/tavewebsite/domain/review/entity/Review.java @@ -1,5 +1,6 @@ package com.tave.tavewebsite.domain.review.entity; +import com.tave.tavewebsite.domain.review.dto.request.ReviewRequestDto; import com.tave.tavewebsite.global.common.BaseEntity; import com.tave.tavewebsite.global.common.FieldType; import jakarta.persistence.*; @@ -47,4 +48,12 @@ public Review(String nickname, String generation, FieldType field, String conten this.content = content; this.isPublic = isPublic; } + + public void update(ReviewRequestDto requestDto) { + this.nickname = requestDto.nickname(); + this.field = requestDto.field(); + this.generation = requestDto.generation(); + this.content = requestDto.content(); + this.isPublic = requestDto.isPublic(); + } } diff --git a/src/main/java/com/tave/tavewebsite/domain/review/exception/ErrorMessgae.java b/src/main/java/com/tave/tavewebsite/domain/review/exception/ErrorMessgae.java new file mode 100644 index 00000000..3d90391d --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/exception/ErrorMessgae.java @@ -0,0 +1,14 @@ +package com.tave.tavewebsite.domain.review.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessgae { + + REVIEW_NOT_FOUND(400,"후기가 존재하지 않습니다."); + + private final int code; + private final String message; +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/exception/ReviewNotFoundException.java b/src/main/java/com/tave/tavewebsite/domain/review/exception/ReviewNotFoundException.java new file mode 100644 index 00000000..47fe7b1d --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/exception/ReviewNotFoundException.java @@ -0,0 +1,12 @@ +package com.tave.tavewebsite.domain.review.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +import static com.tave.tavewebsite.domain.review.exception.ErrorMessgae.REVIEW_NOT_FOUND; + +public class ReviewNotFoundException extends BaseErrorException { + + public ReviewNotFoundException(){ + super(REVIEW_NOT_FOUND.getCode(), REVIEW_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/mapper/ReviewMapper.java b/src/main/java/com/tave/tavewebsite/domain/review/mapper/ReviewMapper.java new file mode 100644 index 00000000..72cbc1b4 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/mapper/ReviewMapper.java @@ -0,0 +1,34 @@ +package com.tave.tavewebsite.domain.review.mapper; + +import com.tave.tavewebsite.domain.review.dto.request.ReviewRequestDto; +import com.tave.tavewebsite.domain.review.dto.response.ReviewResponseDto; +import com.tave.tavewebsite.domain.review.entity.Review; +import org.springframework.stereotype.Component; + +@Component +public class ReviewMapper { + + public Review toReview(ReviewRequestDto reviewRequestDto) { + return Review.builder() + .nickname(reviewRequestDto.nickname()) + .generation(reviewRequestDto.generation()) + .field(reviewRequestDto.field()) + .content(reviewRequestDto.content()) + .isPublic(reviewRequestDto.isPublic()) + .build(); + } + + public ReviewResponseDto toReviewResponseDto(Review review) { + return ReviewResponseDto.builder() + .id(review.getId()) + .nickname(review.getNickname()) + .generation(review.getGeneration()) + .field(review.getField()) + .content(review.getContent()) + .isPublic(review.isPublic()) + .build(); + } + + + +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/repository/ReviewRepository.java b/src/main/java/com/tave/tavewebsite/domain/review/repository/ReviewRepository.java new file mode 100644 index 00000000..0c3a11f2 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/repository/ReviewRepository.java @@ -0,0 +1,13 @@ +package com.tave.tavewebsite.domain.review.repository; + +import com.tave.tavewebsite.domain.review.entity.Review; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReviewRepository extends JpaRepository { + + List findByGeneration(String generation); + + List findByIsPublic(boolean isPublic); +} diff --git a/src/main/java/com/tave/tavewebsite/domain/review/service/ReviewService.java b/src/main/java/com/tave/tavewebsite/domain/review/service/ReviewService.java new file mode 100644 index 00000000..736bab14 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/review/service/ReviewService.java @@ -0,0 +1,69 @@ +package com.tave.tavewebsite.domain.review.service; + + +import com.tave.tavewebsite.domain.review.exception.ReviewNotFoundException; +import com.tave.tavewebsite.domain.review.mapper.ReviewMapper; +import com.tave.tavewebsite.domain.review.dto.request.ReviewRequestDto; +import com.tave.tavewebsite.domain.review.dto.response.ReviewResponseDto; +import com.tave.tavewebsite.domain.review.entity.Review; +import com.tave.tavewebsite.domain.review.repository.ReviewRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ReviewMapper reviewMapper; + + private static final boolean PUBLIC = true; // true로 값만 쓰임 지양 -> 상수화 + + public ReviewResponseDto saveReview(ReviewRequestDto requestDto) { + // 딱히 중복 검사를 할 필드가 없다 -> (동명이인 가능성 有) + Review saveReview = reviewRepository.save(reviewMapper.toReview(requestDto)); + + return reviewMapper.toReviewResponseDto(saveReview); + } + + public List findPublicReviews() { + List reviews = reviewRepository.findByIsPublic(PUBLIC); + + return reviews.stream() + .map(reviewMapper::toReviewResponseDto) + .toList(); + } + + public List findAllReviewsByGeneration(String generation) { + List reviews = reviewRepository.findByGeneration(generation); + + return reviews.stream() + .map(reviewMapper::toReviewResponseDto) + .toList(); + } + + @Transactional + public void updateReview(Long reviewId,ReviewRequestDto requestDto) { + Review findReview = findReview(reviewId); + findReview.update(requestDto); + } + + public void deleteReivew(Long reviewId) { + Review findReview = findReview(reviewId); + reviewRepository.delete(findReview); + } + + /* + * 리팩토링 + * */ + + private Review findReview(Long reviewId) { + return reviewRepository.findById(reviewId) + .orElseThrow(ReviewNotFoundException::new); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/study/controller/StudyController.java b/src/main/java/com/tave/tavewebsite/domain/study/controller/StudyController.java new file mode 100644 index 00000000..078989bf --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/controller/StudyController.java @@ -0,0 +1,55 @@ +package com.tave.tavewebsite.domain.study.controller; + + +import com.tave.tavewebsite.domain.study.dto.StudyRequestDto; +import com.tave.tavewebsite.domain.study.dto.StudyResponseDto; +import com.tave.tavewebsite.domain.study.service.StudyService; +import com.tave.tavewebsite.global.success.SuccessResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/v1") +@RequiredArgsConstructor +public class StudyController { + + private final StudyService studyService; + + @PostMapping("/manager/study") + public SuccessResponse createStudy(@RequestPart @Valid StudyRequestDto req, @RequestPart MultipartFile imageFile) { + + studyService.createStudy(req, imageFile); + + return SuccessResponse.ok("스터디가 생성되었습니다!"); + } + + @GetMapping("/normal/study") + public SuccessResponse> getStudy(@PageableDefault(size = 8) Pageable pageable, + @RequestParam(defaultValue = "ALL", name = "generation") String generation, + @RequestParam(defaultValue = "ALL", name = "field") String field) { + Page studies = studyService.getStudies(generation, field, pageable); + + return new SuccessResponse<>(studies); + } + + @PutMapping("/manager/study/{studyId}") + public SuccessResponse updateStudy(@PathVariable("studyId") Long studyId, + @RequestPart @Valid StudyRequestDto dto, + @RequestPart MultipartFile imageFile) { + studyService.modifyStudy(studyId, dto, imageFile); + + return SuccessResponse.ok("수정되었습니다."); + } + + @DeleteMapping("/manager/study/{studyId}") + public SuccessResponse deleteStudy(@PathVariable("studyId") Long studyId) { + studyService.deleteStudy(studyId); + + return SuccessResponse.ok("성공적으로 삭제되었습니다!"); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/study/dto/StudyRequestDto.java b/src/main/java/com/tave/tavewebsite/domain/study/dto/StudyRequestDto.java new file mode 100644 index 00000000..b2639c38 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/dto/StudyRequestDto.java @@ -0,0 +1,19 @@ +package com.tave.tavewebsite.domain.study.dto; + + +import jakarta.validation.constraints.NotNull; + +public record StudyRequestDto( + + @NotNull(message = "팀이름은 Null일 수 없습니다.") + String teamName, + + @NotNull(message = "기수는 Null일 수 없습니다.") + String generation, + @NotNull(message = "분야는 Null일 수 없습니다.") + String field, + @NotNull(message = "스터디주제는 Null일 수 없습니다.") + String topic, + @NotNull(message = "블로그 url은 Null일 수 없습니다.") + String blogUrl + ){} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/domain/study/dto/StudyResponseDto.java b/src/main/java/com/tave/tavewebsite/domain/study/dto/StudyResponseDto.java new file mode 100644 index 00000000..0435eb8f --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/dto/StudyResponseDto.java @@ -0,0 +1,13 @@ +package com.tave.tavewebsite.domain.study.dto; + + +public record StudyResponseDto( + Long studyId, + String teamName, + String generation, + String field, + String topic, + String imageUrl, + String blogUrl +) { +} diff --git a/src/main/java/com/tave/tavewebsite/domain/study/entity/Study.java b/src/main/java/com/tave/tavewebsite/domain/study/entity/Study.java index 4f44bec1..b72258e4 100644 --- a/src/main/java/com/tave/tavewebsite/domain/study/entity/Study.java +++ b/src/main/java/com/tave/tavewebsite/domain/study/entity/Study.java @@ -1,6 +1,6 @@ package com.tave.tavewebsite.domain.study.entity; - +import com.tave.tavewebsite.domain.study.dto.StudyRequestDto; import com.tave.tavewebsite.global.common.BaseEntity; import com.tave.tavewebsite.global.common.FieldType; import jakarta.persistence.*; @@ -10,8 +10,8 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.validator.constraints.URL; +import java.net.URL; import java.time.LocalDateTime; @@ -28,12 +28,12 @@ public class Study extends BaseEntity { @NotNull @Size(min = 1, max = 30) @Column(length = 30, nullable = false) - private String title; + private String teamName; // 스터디 팀 이름 @NotNull @Size(min = 1, max = 500) @Column(nullable = false) - private String description; // 스터디 주제 + private String topic; // 스터디 주제 @NotNull @Size(min = 1, max = 5) @@ -46,32 +46,43 @@ public class Study extends BaseEntity { private FieldType field; @NotNull - @Column(nullable = false) - private LocalDateTime startDate; - - @NotNull - @Column(nullable = false) - private LocalDateTime endDate; - - @NotNull - @URL @Column(length = 2083, nullable = false) // DDL varchar(2083) private String blogUrl; @NotNull - @URL @Column(length = 2083, nullable = false) // DDL varchar(2083) private String imgUrl; @Builder - public Study(String title, String description, String generation, FieldType field, LocalDateTime startDate, LocalDateTime endDate, String blogUrl, String imgUrl) { - this.title = title; - this.description = description; + public Study(StudyRequestDto req, URL imageUrl) { + this.topic = req.topic(); + this.teamName = req.teamName(); + this.generation = req.generation(); + this.field = FieldType.valueOf(req.field()); + this.blogUrl = req.blogUrl(); + this.imgUrl = imageUrl.toString(); + } + + // test를 위한 생성 + public Study(Long id, String teamName, String topic, String generation, FieldType field, String blogUrl, String imgUrl) { + this.id = id; + this.teamName = teamName; + this.topic = topic; this.generation = generation; this.field = field; - this.startDate = startDate; - this.endDate = endDate; this.blogUrl = blogUrl; this.imgUrl = imgUrl; } + + public Study updateStudy(StudyRequestDto req, URL imageUrl) { + this.topic = req.topic(); + this.teamName = req.teamName(); + this.generation = req.generation(); + this.field = FieldType.valueOf(req.field()); + this.blogUrl = req.blogUrl(); + this.imgUrl = imageUrl.toString(); + return this; + } + + } diff --git a/src/main/java/com/tave/tavewebsite/domain/study/exception/ErrorMessage.java b/src/main/java/com/tave/tavewebsite/domain/study/exception/ErrorMessage.java new file mode 100644 index 00000000..fcaca784 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/exception/ErrorMessage.java @@ -0,0 +1,14 @@ +package com.tave.tavewebsite.domain.study.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorMessage { + + _NOT_FOUND_STUDY(400, "해당 스터디를 찾을 수 없습니다."); + + private final int code; + private final String message; +} diff --git a/src/main/java/com/tave/tavewebsite/domain/study/exception/NotFoundStudy.java b/src/main/java/com/tave/tavewebsite/domain/study/exception/NotFoundStudy.java new file mode 100644 index 00000000..6df49067 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/exception/NotFoundStudy.java @@ -0,0 +1,13 @@ +package com.tave.tavewebsite.domain.study.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +import static com.tave.tavewebsite.domain.study.exception.ErrorMessage._NOT_FOUND_STUDY; + +public class NotFoundStudy extends BaseErrorException { + + + public NotFoundStudy() { + super(_NOT_FOUND_STUDY.getCode(), _NOT_FOUND_STUDY.getMessage()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/domain/study/repository/CustomStudyRepository.java b/src/main/java/com/tave/tavewebsite/domain/study/repository/CustomStudyRepository.java new file mode 100644 index 00000000..0e34e66d --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/repository/CustomStudyRepository.java @@ -0,0 +1,16 @@ +package com.tave.tavewebsite.domain.study.repository; + +import com.tave.tavewebsite.domain.study.dto.StudyResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CustomStudyRepository { + + Page findStudyByGenerationAndField(String generation, String field, Pageable pageable); + + Page findStudyByField(String field, Pageable pageable); + + Page findStudyByGeneration(String generation, Pageable pageable); + + Page findAllStudy(Pageable pageable); +} diff --git a/src/main/java/com/tave/tavewebsite/domain/study/repository/CustomStudyRepositoryImpl.java b/src/main/java/com/tave/tavewebsite/domain/study/repository/CustomStudyRepositoryImpl.java new file mode 100644 index 00000000..68fcb2f6 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/repository/CustomStudyRepositoryImpl.java @@ -0,0 +1,72 @@ +package com.tave.tavewebsite.domain.study.repository; + +import com.tave.tavewebsite.domain.study.dto.StudyResponseDto; +import com.tave.tavewebsite.domain.study.entity.Study; +import com.tave.tavewebsite.global.common.FieldType; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@Slf4j +public class CustomStudyRepositoryImpl implements CustomStudyRepository { + + @PersistenceContext + private EntityManager em; + + @Override + public Page findStudyByGenerationAndField(String generation, String field, Pageable pageable) { + + List studies = em.createQuery("select s from Study s where s.field=:field and s.generation = :generation", Study.class) + .setParameter("field", FieldType.valueOf(field)) + .setParameter("generation", generation) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + return getResult(studies, pageable); + } + + @Override + public Page findStudyByField(String field, Pageable pageable) { + List studies = em.createQuery("select s from Study s where s.field = :field", Study.class) + .setParameter("field", FieldType.valueOf(field)) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + return getResult(studies, pageable); + } + + @Override + public Page findStudyByGeneration(String generation, Pageable pageable) { + List studies = em.createQuery("select s from Study s where s.generation = :generation", Study.class) + .setParameter("generation", generation) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + return getResult(studies, pageable); + } + + @Override + public Page findAllStudy(Pageable pageable) { + List studies = em.createQuery("select s from Study s order by s.createdAt", Study.class) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + log.info("findAllStudy: {}", studies.size()); + + return getResult(studies, pageable); + } + + private Page getResult(List studies, Pageable pageable) { + List studyResponseDtos = studies.stream().map(study -> new StudyResponseDto(study.getId(), study.getTeamName(), + study.getGeneration(), String.valueOf(study.getField()), study.getTopic(), + study.getImgUrl(), study.getBlogUrl())).toList(); + + return new PageImpl<>(studyResponseDtos, pageable, studies.size()); + } + + +} diff --git a/src/main/java/com/tave/tavewebsite/domain/study/repository/StudyRepository.java b/src/main/java/com/tave/tavewebsite/domain/study/repository/StudyRepository.java new file mode 100644 index 00000000..60c88c68 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/repository/StudyRepository.java @@ -0,0 +1,7 @@ +package com.tave.tavewebsite.domain.study.repository; + +import com.tave.tavewebsite.domain.study.entity.Study; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyRepository extends JpaRepository, CustomStudyRepository { +} diff --git a/src/main/java/com/tave/tavewebsite/domain/study/service/StudyService.java b/src/main/java/com/tave/tavewebsite/domain/study/service/StudyService.java new file mode 100644 index 00000000..323295b9 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/domain/study/service/StudyService.java @@ -0,0 +1,70 @@ +package com.tave.tavewebsite.domain.study.service; + +import com.tave.tavewebsite.domain.study.dto.StudyRequestDto; +import com.tave.tavewebsite.domain.study.dto.StudyResponseDto; +import com.tave.tavewebsite.domain.study.entity.Study; +import com.tave.tavewebsite.domain.study.exception.NotFoundStudy; +import com.tave.tavewebsite.domain.study.repository.StudyRepository; +import com.tave.tavewebsite.global.s3.service.S3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URL; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudyService { + + private final StudyRepository studyRepository; + private final S3Service s3Service; + + public void createStudy(StudyRequestDto req, MultipartFile file) { + URL url = s3Service.uploadImages(file); + + Study study = new Study(req, url); + + studyRepository.save(study); + } + + @Transactional(readOnly = true) + public Page getStudies(String generation, String field, Pageable pageable) { + Page map; + + try { + log.info("field: {}, generation: {}", field, generation); + if (generation.equals("ALL") && field.equals("ALL")) + map = studyRepository.findAllStudy(pageable); + else if (generation.equals("ALL")) + map = studyRepository.findStudyByField(field, pageable); + else if (field.equals("ALL")) + map = studyRepository.findStudyByGeneration(generation, pageable); + else + map = studyRepository.findStudyByGenerationAndField(generation, field, pageable); + } catch (Exception e) { + throw new NotFoundStudy(); + } + + return map; + } + + public void modifyStudy(Long studyId, StudyRequestDto req, MultipartFile file) { + // 존재 유무 & 직책에 대한 유저 자격 확인 + Study study = studyRepository.findById(studyId).orElseThrow(NotFoundStudy::new); // 스터디 존재 유무 확인 + URL url = s3Service.uploadImages(file); + study.updateStudy(req, url); + + //studyRepository.save(study); + } + + public void deleteStudy(Long studyId){ + Study study = studyRepository.findById(studyId).orElseThrow(NotFoundStudy::new); // 스터디 존재 유무 확인 + s3Service.deleteImage(study.getImgUrl()); + studyRepository.delete(study); + } +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/global/common/FieldType.java b/src/main/java/com/tave/tavewebsite/global/common/FieldType.java index 6266b5f5..5b234743 100644 --- a/src/main/java/com/tave/tavewebsite/global/common/FieldType.java +++ b/src/main/java/com/tave/tavewebsite/global/common/FieldType.java @@ -4,5 +4,7 @@ public enum FieldType { DEEP_LEARNING, DATA_ANALYSIS, FRONTEND, - BACKEND + BACKEND, + ADVANCED, // 심화 + COLLABORATIVE // 연합 } diff --git a/src/main/java/com/tave/tavewebsite/global/common/SwaggerConfig.java b/src/main/java/com/tave/tavewebsite/global/common/SwaggerConfig.java new file mode 100644 index 00000000..3faee270 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/common/SwaggerConfig.java @@ -0,0 +1,41 @@ +package com.tave.tavewebsite.global.common; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +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; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI taveWebSiteOpenAPI() { + Info info = new Info() + .title("Tave-WebSite Server API") + .description("Tave Server API 명세서") + .version("v1.0.0"); + + String jwtSchemeName = "JWT TOKEN"; + + // API 요청헤더에 인증정보 포함 + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + // SecuritySchemes 등록 + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) // HTTP 방식 + .scheme("Bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info(info) + .addSecurityItem(securityRequirement) + .components(components); + } +} + diff --git a/src/main/java/com/tave/tavewebsite/global/mail/service/MailService.java b/src/main/java/com/tave/tavewebsite/global/mail/service/MailService.java index 0214572c..f415cfc0 100644 --- a/src/main/java/com/tave/tavewebsite/global/mail/service/MailService.java +++ b/src/main/java/com/tave/tavewebsite/global/mail/service/MailService.java @@ -4,6 +4,7 @@ import com.tave.tavewebsite.global.mail.dto.MailResponseDto; import com.tave.tavewebsite.global.mail.exception.FailCreateMailException; import com.tave.tavewebsite.global.mail.exception.FailMailSendException; +import com.tave.tavewebsite.global.redis.utils.RedisUtil; import jakarta.mail.Message; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; @@ -12,16 +13,22 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; import java.io.UnsupportedEncodingException; +import java.util.Random; @Slf4j @Service @RequiredArgsConstructor public class MailService { + private final RedisUtil redisUtil; + private final TemplateEngine templateEngine; private final JavaMailSender emailSender; private final String SUCCESS_SIGN_UP = "[TAVE 관리자 홈페이지] 가입 신청 완료"; + private final String SEND_AUTHENTICATION_CODE = "[TAVE 관리자 홈페이지] 인증번호 발송 안내입니다."; @Value("${spring.mail.username}") private String from; @@ -47,7 +54,7 @@ private MimeMessage creatManagerRegisterMessage(String to) throws MessagingExcep MimeMessage message = emailSender.createMimeMessage(); message.addRecipients(Message.RecipientType.TO, to); // 이메일 제목 - String msgg = ManagerSingUpHtml(message, SUCCESS_SIGN_UP); + String msgg = ManagerSignUpHtml(message, SUCCESS_SIGN_UP); message.setText(msgg, "utf-8", "html"); // 보내는 사람의 이메일 주소, 보내는 사람 이름 @@ -56,7 +63,7 @@ private MimeMessage creatManagerRegisterMessage(String to) throws MessagingExcep return message; } - private String ManagerSingUpHtml(MimeMessage message, String subject) throws MessagingException { + private String ManagerSignUpHtml(MimeMessage message, String subject) throws MessagingException { message.setSubject(subject); String msgg = ""; @@ -72,4 +79,46 @@ private String ManagerSingUpHtml(MimeMessage message, String subject) throws Mes } + public MailResponseDto sendAuthenticationCode(String to){ + String randomCode = generateCode(); + + MimeMessage message; + try { + message = createAuthenticationMessage(to, randomCode); // "to" 로 관리자 회원가입 신청메일 발송 + emailSender.send(message); + redisUtil.set(to, randomCode, 3); + } catch (MessagingException | UnsupportedEncodingException e) { + throw new FailCreateMailException(); // message를 생성하는 경우에 발생한 예외 (서버 문제) + } catch (FailMailSendException e) { + throw new FailMailSendException(); // Mail을 발송할때 생긴 예외 + } + + return new MailResponseDto(to + " 관리자 회원가입 신청 완료"); + } + + private String generateCode() { + Random random = new Random(); + return String.format("%06d", random.nextInt(1000000)); // 6자리의 랜덤한 코드를 만든다. + } + + // 메일 내용 작성 + private MimeMessage createAuthenticationMessage(String to, String randomCode) + throws MessagingException, UnsupportedEncodingException { + + MimeMessage message = emailSender.createMimeMessage(); + message.addRecipients(Message.RecipientType.TO, to); + message.setSubject(SEND_AUTHENTICATION_CODE); + + + Context context = new Context(); + context.setVariable("randomCode", randomCode); + + String msgg = templateEngine.process("email-verification", context); + + message.setText(msgg, "utf-8", "html"); + message.setFrom(from); + + return message; + } + } diff --git a/src/main/java/com/tave/tavewebsite/global/redis/config/RedisConfig.java b/src/main/java/com/tave/tavewebsite/global/redis/config/RedisConfig.java new file mode 100644 index 00000000..090b0329 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/redis/config/RedisConfig.java @@ -0,0 +1,34 @@ +package com.tave.tavewebsite.global.redis.config; + +import com.tave.tavewebsite.global.redis.entity.RedisProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@RequiredArgsConstructor +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort()); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/src/main/java/com/tave/tavewebsite/global/redis/entity/RedisProperties.java b/src/main/java/com/tave/tavewebsite/global/redis/entity/RedisProperties.java new file mode 100644 index 00000000..8bd43c31 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/redis/entity/RedisProperties.java @@ -0,0 +1,13 @@ +package com.tave.tavewebsite.global.redis.entity; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "spring.data.redis") +@Data +public class RedisProperties { + private int port; + private String host; +} diff --git a/src/main/java/com/tave/tavewebsite/global/redis/utils/RedisUtil.java b/src/main/java/com/tave/tavewebsite/global/redis/utils/RedisUtil.java new file mode 100644 index 00000000..1209a871 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/redis/utils/RedisUtil.java @@ -0,0 +1,51 @@ +package com.tave.tavewebsite.global.redis.utils; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisUtil { + + private final RedisTemplate redisTemplate; + private final RedisTemplate redisBlackListTemplate; + + public void set(String key, String o, int minutes) { + redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES); + } + + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + public boolean delete(String key) { + return Boolean.TRUE.equals(redisTemplate.delete(key)); + } + + public boolean hasKey(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + public void setBlackList(String key, String o, int minutes) { + redisBlackListTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES); + } + + public Object getBlackList(String key) { + return redisBlackListTemplate.opsForValue().get(key); + } + + public boolean deleteBlackList(String key) { + return Boolean.TRUE.equals(redisBlackListTemplate.delete(key)); + } + + public boolean hasKeyBlackList(String key) { + return Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key)); + } + + public Long checkExpired(String key) { + Long ttl = redisTemplate.getExpire(key); + return ttl; + } +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/config/Config.java b/src/main/java/com/tave/tavewebsite/global/security/config/Config.java new file mode 100644 index 00000000..4b71e1f0 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/config/Config.java @@ -0,0 +1,100 @@ +package com.tave.tavewebsite.global.security.config; + +import com.tave.tavewebsite.domain.member.entity.RoleType; +import com.tave.tavewebsite.global.redis.utils.RedisUtil; +import com.tave.tavewebsite.global.security.exception.CustomAuthenticationEntryPoint; +import com.tave.tavewebsite.global.security.filter.JwtAuthenticationFilter; +import com.tave.tavewebsite.global.security.utils.JwtTokenProvider; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableMethodSecurity +@RequiredArgsConstructor +public class Config { + + private final JwtTokenProvider jwtTokenProvider; + private final RedisUtil redisUtil; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + + corsConfiguration.setAllowedOriginPatterns(List.of("http://localhost:3000", "http://localhost:8080")); + corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH")); + corsConfiguration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type")); + corsConfiguration.setExposedHeaders(List.of("Set-Cookie")); + corsConfiguration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfiguration); + + return source; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource( + corsConfigurationSource())); + + http.csrf(AbstractHttpConfigurer::disable); + + http.sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> + authorizationManagerRequestMatcherRegistry + // 비회원 전용 api + .requestMatchers("/v1/normal/**", "/v1/auth/signup", "/v1/auth/signin", "/v1/auth/refresh") + .permitAll() + + // 일반 회원 전용 api + .requestMatchers("/v1/member/**", "/v1/auth/signout", "/v1/auth/delete/**") + .hasAnyRole(RoleType.MEMBER.name(), RoleType.UNAUTHORIZED_MANAGER.name(), + RoleType.MANAGER.name(), RoleType.ADMIN.name()) + + // 미허가 운영진 전용 api + .requestMatchers("/v1/xxxxxxxxx") + .hasAnyRole(RoleType.UNAUTHORIZED_MANAGER.name(), RoleType.MANAGER.name(), + RoleType.ADMIN.name()) + + // 운영진 전용 api + .requestMatchers("/v1/manager/**") + .hasAnyRole(RoleType.MANAGER.name(), RoleType.ADMIN.name()) + + // 회장 전용 api + .requestMatchers("/v1/admin/**").hasRole(RoleType.ADMIN.name()) + .anyRequest().authenticated() + ); + + http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, redisUtil), + UsernamePasswordAuthenticationFilter.class); + + http.formLogin().disable(); + + http.exceptionHandling() + .authenticationEntryPoint(customAuthenticationEntryPoint); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/entity/JwtToken.java b/src/main/java/com/tave/tavewebsite/global/security/entity/JwtToken.java new file mode 100644 index 00000000..2b45e946 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/entity/JwtToken.java @@ -0,0 +1,14 @@ +package com.tave.tavewebsite.global.security.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@AllArgsConstructor +@Builder +public class JwtToken { + private String grantType; + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/global/security/exception/CustomAuthenticationEntryPoint.java b/src/main/java/com/tave/tavewebsite/global/security/exception/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..31231cf7 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/exception/CustomAuthenticationEntryPoint.java @@ -0,0 +1,56 @@ +package com.tave.tavewebsite.global.security.exception; + +import static com.tave.tavewebsite.global.security.exception.JwtErrorMessage.CANNOT_USE_REFRESH_TOKEN; +import static com.tave.tavewebsite.global.security.exception.JwtErrorMessage.INVALID_JWT_TOKEN; +import static com.tave.tavewebsite.global.security.exception.JwtErrorMessage.NEED_ACCESS_TOKEN_REFRESH; +import static com.tave.tavewebsite.global.security.exception.JwtErrorMessage.SIGN_OUT_USER; +import static com.tave.tavewebsite.global.security.exception.JwtErrorMessage.UNSUPPORTED_JWT_TOKEN; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tave.tavewebsite.global.exception.Response.ExceptionResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + + if (request.getAttribute("signOutException") != null) { + setResponse(response, SIGN_OUT_USER.getErrorCode(), SIGN_OUT_USER.getMessage()); + } else if (request.getAttribute("cannotUseRefreshToken") != null) { + setResponse(response, CANNOT_USE_REFRESH_TOKEN.getErrorCode(), CANNOT_USE_REFRESH_TOKEN.getMessage()); + } else if (request.getAttribute("invalidJwtToken") != null) { + setResponse(response, INVALID_JWT_TOKEN.getErrorCode(), INVALID_JWT_TOKEN.getMessage()); + } else if (request.getAttribute("expiredJwtToken") != null) { + setResponse(response, NEED_ACCESS_TOKEN_REFRESH.getErrorCode(), NEED_ACCESS_TOKEN_REFRESH.getMessage()); + } else if (request.getAttribute("notExistAccessToken") != null) { + setResponse(response, NEED_ACCESS_TOKEN_REFRESH.getErrorCode(), NEED_ACCESS_TOKEN_REFRESH.getMessage()); + } else if (request.getAttribute("unsupportedJwtToken") != null) { + setResponse(response, UNSUPPORTED_JWT_TOKEN.getErrorCode(), UNSUPPORTED_JWT_TOKEN.getMessage()); + } else { + // 나머지 잘못된 요청에 대한 기본 예외 처리 + setResponse(response, 401, "잘못된 인증 요청입니다."); // 기본적으로 401 상태 코드와 에러 메시지 반환 + } + + + } + + // 발생한 예외에 맞게 status를 설정하고 message를 반환 + private void setResponse(HttpServletResponse response, int code, String message) throws IOException { + response.setStatus(code); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + String json = new ObjectMapper().writeValueAsString(ExceptionResponse.fail(code, message)); + response.getWriter().write(json); + } + +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/exception/JwtErrorMessage.java b/src/main/java/com/tave/tavewebsite/global/security/exception/JwtErrorMessage.java new file mode 100644 index 00000000..f9bc263e --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/exception/JwtErrorMessage.java @@ -0,0 +1,24 @@ +package com.tave.tavewebsite.global.security.exception; + +import lombok.Getter; + +@Getter +public enum JwtErrorMessage { + + INVALID_JWT_TOKEN(401, "유효하지 않은 JWT 토큰 입니다."), + NEED_ACCESS_TOKEN_REFRESH(401, "토큰 재발급이 필요합니다."), + CANNOT_USE_REFRESH_TOKEN(400, "리프레쉬 토큰은 사용할 수 없습니다."), + UNSUPPORTED_JWT_TOKEN(401, "지원하지 않는 유형의 JWT 토큰입니다."), + NOT_MATCH_REFRESH_TOKEN(400, "로그인이 필요한 서비스입니다."), + SIGN_OUT_USER(400, "이미 로그아웃한 사용자 입니다."), + CLAIMS_IS_EMPTY(401, "Claims 내부에 값이 들어있지 않습니다."); + + private final int errorCode; + private final String message; + + JwtErrorMessage(int errorCode, String message) { + this.errorCode = errorCode; + this.message = message; + } + +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/exception/JwtValidException.java b/src/main/java/com/tave/tavewebsite/global/security/exception/JwtValidException.java new file mode 100644 index 00000000..f9b8686a --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/exception/JwtValidException.java @@ -0,0 +1,53 @@ +package com.tave.tavewebsite.global.security.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +public abstract class JwtValidException { + + public static class InValidTokenException extends BaseErrorException { + public InValidTokenException() { + super(JwtErrorMessage.INVALID_JWT_TOKEN.getErrorCode(), JwtErrorMessage.INVALID_JWT_TOKEN.getMessage()); + } + } + + public static class ExpiredTokenException extends BaseErrorException { + public ExpiredTokenException() { + super(JwtErrorMessage.NEED_ACCESS_TOKEN_REFRESH.getErrorCode(), + JwtErrorMessage.NEED_ACCESS_TOKEN_REFRESH.getMessage()); + } + } + + public static class UnsupportedTokenException extends BaseErrorException { + public UnsupportedTokenException() { + super(JwtErrorMessage.UNSUPPORTED_JWT_TOKEN.getErrorCode(), + JwtErrorMessage.UNSUPPORTED_JWT_TOKEN.getMessage()); + } + } + + public static class EmptyClaimsException extends BaseErrorException { + public EmptyClaimsException() { + super(JwtErrorMessage.CLAIMS_IS_EMPTY.getErrorCode(), JwtErrorMessage.CLAIMS_IS_EMPTY.getMessage()); + } + } + + public static class NotMatchRefreshTokenException extends BaseErrorException { + public NotMatchRefreshTokenException() { + super(JwtErrorMessage.NOT_MATCH_REFRESH_TOKEN.getErrorCode(), + JwtErrorMessage.NOT_MATCH_REFRESH_TOKEN.getMessage()); + } + } + + public static class SignOutUserException extends BaseErrorException { + public SignOutUserException() { + super(JwtErrorMessage.SIGN_OUT_USER.getErrorCode(), + JwtErrorMessage.SIGN_OUT_USER.getMessage()); + } + } + + public static class CannotUseRefreshToken extends BaseErrorException { + public CannotUseRefreshToken() { + super(JwtErrorMessage.CANNOT_USE_REFRESH_TOKEN.getErrorCode(), + JwtErrorMessage.CANNOT_USE_REFRESH_TOKEN.getMessage()); + } + } +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/exception/LoginErrorMessage.java b/src/main/java/com/tave/tavewebsite/global/security/exception/LoginErrorMessage.java new file mode 100644 index 00000000..267ea018 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/exception/LoginErrorMessage.java @@ -0,0 +1,20 @@ +package com.tave.tavewebsite.global.security.exception; + +import lombok.Getter; + +@Getter +public enum LoginErrorMessage { + + EMAIL_NOT_FOUND(400, "아이디 혹은 비밀번호가 일치하지 않습니다."), + AUTHENTICATION_NOT_FOUND(400, "아이디 혹은 비밀번호가 일치하지 않습니다."); + + + private final int errorCode; + private final String message; + + LoginErrorMessage(int errorCode, String message) { + this.errorCode = errorCode; + this.message = message; + } + +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/exception/LoginFailException.java b/src/main/java/com/tave/tavewebsite/global/security/exception/LoginFailException.java new file mode 100644 index 00000000..45d79b65 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/exception/LoginFailException.java @@ -0,0 +1,19 @@ +package com.tave.tavewebsite.global.security.exception; + +import com.tave.tavewebsite.global.exception.BaseErrorException; + +public abstract class LoginFailException { + + public static class EmailNotFoundException extends BaseErrorException { + public EmailNotFoundException() { + super(LoginErrorMessage.EMAIL_NOT_FOUND.getErrorCode(), LoginErrorMessage.EMAIL_NOT_FOUND.getMessage()); + } + } + + public static class AuthenticationNotFoundException extends BaseErrorException { + public AuthenticationNotFoundException() { + super(LoginErrorMessage.AUTHENTICATION_NOT_FOUND.getErrorCode(), + LoginErrorMessage.AUTHENTICATION_NOT_FOUND.getMessage()); + } + } +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/filter/CsrfTokenResponseHeaderBindingFilter.java b/src/main/java/com/tave/tavewebsite/global/security/filter/CsrfTokenResponseHeaderBindingFilter.java new file mode 100644 index 00000000..dadce93d --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/filter/CsrfTokenResponseHeaderBindingFilter.java @@ -0,0 +1,23 @@ +package com.tave.tavewebsite.global.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; + +public class CsrfTokenResponseHeaderBindingFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + + if (csrfToken != null) { + response.setHeader("X-XSRF-TOKEN", csrfToken.getToken()); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/tave/tavewebsite/global/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..d18077ba --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,71 @@ +package com.tave.tavewebsite.global.security.filter; + +import com.tave.tavewebsite.global.redis.utils.RedisUtil; +import com.tave.tavewebsite.global.security.exception.JwtValidException.ExpiredTokenException; +import com.tave.tavewebsite.global.security.exception.JwtValidException.SignOutUserException; +import com.tave.tavewebsite.global.security.utils.JwtTokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final RedisUtil redisUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // 1. Request Header에서 JWT 토큰 추출 + String token = resolveToken(request); + + // 2. redis를 통해 로그아웃한 사용자인지 확인 + if (redisUtil.hasKey("Bearer " + token)) { + request.setAttribute("signOutException", 401); + throw new SignOutUserException(); + } + + // 3. 새로고침 시 메모리에 저장된 액세스 토큰이 사라졌을 시 발생시키는 에러 + if (token == null) { + request.setAttribute("notExistAccessToken", 401); + throw new ExpiredTokenException(); + } + + // 4. validateToken으로 토큰 유효성 검사 + if (jwtTokenProvider.validateToken(request, token)) { + // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장 + Authentication authentication = jwtTokenProvider.getAuthentication(request, token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + // 해당 토큰 검증 필터가 적용되지 않게 하려는 api 경로 + return requestURI.startsWith("/v1/normal") + || requestURI.startsWith("/v1/auth/signup") + || requestURI.startsWith("/v1/auth/signin") + || (requestURI.startsWith("/v1/auth/refresh")); + + } + + // Request Header에서 토큰 정보 추출 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + return bearerToken.substring(7); + } + return null; + } + +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/service/CustomUserDetailsService.java b/src/main/java/com/tave/tavewebsite/global/security/service/CustomUserDetailsService.java new file mode 100644 index 00000000..07f76865 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,41 @@ +package com.tave.tavewebsite.global.security.service; + +import com.tave.tavewebsite.domain.member.entity.Member; +import com.tave.tavewebsite.domain.member.memberRepository.MemberRepository; +import com.tave.tavewebsite.global.security.exception.LoginFailException.EmailNotFoundException; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public UserDetails loadUserByUsername(String email) { + Optional byEmail = memberRepository.findByEmail(email); + + if (byEmail.isPresent()) { + return this.createUserDetails(byEmail.get()); + } else { + throw new EmailNotFoundException(); + } + } + + // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 return + private UserDetails createUserDetails(Member member) { + return User.builder() + .username(member.getEmail()) + .password(passwordEncoder.encode(member.getPassword())) + .roles("ROLE_" + member.getRole().name()) + .build(); + } + +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/utils/CookieUtil.java b/src/main/java/com/tave/tavewebsite/global/security/utils/CookieUtil.java new file mode 100644 index 00000000..0f1ef733 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/utils/CookieUtil.java @@ -0,0 +1,23 @@ +package com.tave.tavewebsite.global.security.utils; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + private static final int MAX_AGE_OF_COOKIE = 3 * 24 * 60 * 60; + + public static void setCookie(HttpServletResponse response, String name, String value) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .maxAge(MAX_AGE_OF_COOKIE) + .path("/") + .secure(false) + .httpOnly(true) + .sameSite("None") + .build(); + + response.setHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/src/main/java/com/tave/tavewebsite/global/security/utils/JwtTokenProvider.java b/src/main/java/com/tave/tavewebsite/global/security/utils/JwtTokenProvider.java new file mode 100644 index 00000000..3039dc23 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/utils/JwtTokenProvider.java @@ -0,0 +1,126 @@ +package com.tave.tavewebsite.global.security.utils; + +import com.tave.tavewebsite.domain.member.entity.Member; +import com.tave.tavewebsite.global.security.entity.JwtToken; +import com.tave.tavewebsite.global.security.exception.JwtValidException.CannotUseRefreshToken; +import com.tave.tavewebsite.global.security.exception.JwtValidException.EmptyClaimsException; +import com.tave.tavewebsite.global.security.exception.JwtValidException.ExpiredTokenException; +import com.tave.tavewebsite.global.security.exception.JwtValidException.InValidTokenException; +import com.tave.tavewebsite.global.security.exception.JwtValidException.UnsupportedTokenException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import java.security.Key; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class JwtTokenProvider { + private final Key key; + + // application.yml에서 secret 값 가져와서 key에 저장 + public JwtTokenProvider(@Value("${JWT_SECRET}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + // Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드 + public JwtToken generateToken(Member member) { + + long now = (new Date()).getTime(); + + // Access Token 생성 + Date accessTokenExpiresIn = new Date(now + 1800000); + String accessToken = Jwts.builder() + .setSubject(member.getNickname()) + .claim("auth", "ROLE_" + member.getRole().name()) + .setExpiration(accessTokenExpiresIn) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // Refresh Token 생성 + String refreshToken = Jwts.builder() + .setExpiration(new Date(now + 86400000)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드 + public Authentication getAuthentication(HttpServletRequest request, String accessToken) { + // Jwt 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get("auth") == null) { + request.setAttribute("cannotUseRefreshToken", 400); + throw new CannotUseRefreshToken(); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority((String) claims.get("auth"))); + + // UserDetails 객체를 만들어서 Authentication return + // UserDetails: interface, User: UserDetails를 구현한 class + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + // 토큰 정보를 검증하는 메서드 + public boolean validateToken(HttpServletRequest request, String token) { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + request.setAttribute("invalidJwtToken", 401); + throw new InValidTokenException(); + } catch (ExpiredJwtException e) { + request.setAttribute("expiredJwtToken", 401); + throw new ExpiredTokenException(); + } catch (UnsupportedJwtException e) { + request.setAttribute("unsupportedJwtToken", 401); + throw new UnsupportedTokenException(); + } catch (IllegalArgumentException e) { + throw new EmptyClaimsException(); + } + } + + + // accessToken + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/tave/tavewebsite/global/security/utils/UsernamePwdAuthenticationProvider.java b/src/main/java/com/tave/tavewebsite/global/security/utils/UsernamePwdAuthenticationProvider.java new file mode 100644 index 00000000..c04d07b9 --- /dev/null +++ b/src/main/java/com/tave/tavewebsite/global/security/utils/UsernamePwdAuthenticationProvider.java @@ -0,0 +1,53 @@ +package com.tave.tavewebsite.global.security.utils; + +import com.tave.tavewebsite.domain.member.entity.Member; +import com.tave.tavewebsite.domain.member.memberRepository.MemberRepository; +import com.tave.tavewebsite.global.security.exception.LoginFailException.AuthenticationNotFoundException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class UsernamePwdAuthenticationProvider implements AuthenticationProvider { + + @Autowired + private MemberRepository customerRepository; + @Autowired + private PasswordEncoder passwordEncoder; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + + String username = authentication.getName(); + String pwd = authentication.getCredentials().toString(); + Optional customer = customerRepository.findByEmail(username); + + if (customer.isPresent()) { + if (passwordEncoder.matches(pwd, customer.get().getPassword())) { + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(customer.get().getRole().name())); + return new UsernamePasswordAuthenticationToken(username, pwd, authorities); + + } else { + throw new AuthenticationNotFoundException(); + } + } else { + throw new AuthenticationNotFoundException(); + } + + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7e5db51c..1f6b5997 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,11 +6,17 @@ spring: password: ${DB_PASSWORD} jpa: hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: # show_sql: true format_sql: true + data: + redis: + port: 6379 + host: redis + #host: 127.0.0.1 + servlet: multipart: max-file-size: 20MB @@ -26,6 +32,9 @@ spring: mail.smtp.ssl.enable: true mail.smtp.ssl.trust: smtp.naver.com + jwt: + secret: ${JWT_SECRET} + cloud: aws: credentials: @@ -38,5 +47,4 @@ cloud: logging.level: org.hibernate.SQL: debug -# org.hibernate.type: trace - +# org.hibernate.type: trace \ No newline at end of file diff --git a/src/main/resources/templates/email-verification.html b/src/main/resources/templates/email-verification.html new file mode 100644 index 00000000..d85d5257 --- /dev/null +++ b/src/main/resources/templates/email-verification.html @@ -0,0 +1,51 @@ + + + + + 이메일 인증 + + + +

안녕하세요

+

이메일 인증을 위해 아래 인증 코드를 입력하세요:

+
[[${randomCode}]]
+ + + \ No newline at end of file diff --git a/src/test/java/com/tave/tavewebsite/domain/study/entity/StudyTest.java b/src/test/java/com/tave/tavewebsite/domain/study/entity/StudyTest.java new file mode 100644 index 00000000..daaa3a95 --- /dev/null +++ b/src/test/java/com/tave/tavewebsite/domain/study/entity/StudyTest.java @@ -0,0 +1,43 @@ +package com.tave.tavewebsite.domain.study.entity; + +import com.tave.tavewebsite.domain.study.repository.StudyRepository; +import com.tave.tavewebsite.global.common.FieldType; +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.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +@DataJpaTest +@ActiveProfiles("test") +class StudyTest { + + @Autowired + private StudyRepository studyRepository; + + @DisplayName("Study 엔티티가 성공적으로 수행되는지 확인합니다.") + @Test + void createStudy(){ + //given + Study study1 = new Study(1L, "teamName1", "topic1", "14", FieldType.BACKEND, "http://blog.url1", "imageUrl"); + Study study2 = new Study(2L, "teamName2", "topic2", "14", FieldType.BACKEND, "http://blog.url2", "imageUrl"); + + //when + studyRepository.save(study1); + studyRepository.save(study2); + + Study result = studyRepository.findById(1L).get(); + + //then + assertThat(studyRepository.count()).isEqualTo(2); + assertThat(result) + .extracting("teamName", "topic", "generation", "blogUrl") + .contains("teamName1", "topic1", "14", "http://blog.url1"); + } + +} \ No newline at end of file diff --git a/src/test/java/com/tave/tavewebsite/domain/study/service/StudyServiceTest.java b/src/test/java/com/tave/tavewebsite/domain/study/service/StudyServiceTest.java new file mode 100644 index 00000000..f117ad8c --- /dev/null +++ b/src/test/java/com/tave/tavewebsite/domain/study/service/StudyServiceTest.java @@ -0,0 +1,71 @@ +package com.tave.tavewebsite.domain.study.service; + +import com.tave.tavewebsite.domain.study.dto.StudyRequestDto; +import com.tave.tavewebsite.domain.study.entity.Study; +import com.tave.tavewebsite.domain.study.repository.StudyRepository; +import com.tave.tavewebsite.global.s3.service.S3Service; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; + +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +class StudyServiceTest { + + @InjectMocks + private StudyService studyService; + + @Mock + private StudyRepository studyRepository; + + @Mock + private S3Service s3Service; + + @Captor + private ArgumentCaptor studyCaptor; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @DisplayName("스터디가 성공적으로 생성된다.") + @Test + void createStudyTest() throws Exception { + // Given + MockMultipartFile mockFile = new MockMultipartFile( + "file", "test-image.jpg", "image/jpeg", "image content".getBytes() + ); + URL mockUrl = new URL("https://example.com/test-image.jpg"); + + StudyRequestDto req = new StudyRequestDto( + "TeamName", + "14", + "BACKEND", + "topic", + "https://example.com/blog" + ); + + when(s3Service.uploadImages(mockFile)).thenReturn(mockUrl); + + // When + studyService.createStudy(req, mockFile); + + // Then + verify(studyRepository, times(1)).save(studyCaptor.capture()); + Study savedStudy = studyCaptor.getValue(); + + assertThat(savedStudy.getTeamName()).isEqualTo(req.teamName()); + } +} \ No newline at end of file