diff --git a/build.gradle b/build.gradle index cf487f5..65928a8 100644 --- a/build.gradle +++ b/build.gradle @@ -37,7 +37,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' //Database - runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.mysql:mysql-connector-j:8.0.33' //Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' diff --git a/src/main/java/com/adoonge/seedzip/auth/controller/AuthController.java b/src/main/java/com/adoonge/seedzip/auth/controller/AuthController.java index eae59b1..51aeb0d 100644 --- a/src/main/java/com/adoonge/seedzip/auth/controller/AuthController.java +++ b/src/main/java/com/adoonge/seedzip/auth/controller/AuthController.java @@ -1,6 +1,9 @@ package com.adoonge.seedzip.auth.controller; +import com.adoonge.seedzip.auth.dto.request.BasicLoginRequest; +import com.adoonge.seedzip.auth.dto.request.BasicSignUpRequest; import com.adoonge.seedzip.auth.dto.request.SignUpRequest; +import com.adoonge.seedzip.auth.dto.response.BasicLoginResponse; import com.adoonge.seedzip.auth.dto.response.LoginResponse; import com.adoonge.seedzip.auth.service.AuthService; import com.adoonge.seedzip.auth.util.CustomUserDetails; @@ -12,6 +15,9 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,6 +32,7 @@ @RequiredArgsConstructor @Tag(name = "AuthController", description = "회원 인증 관련 API") public class AuthController { + private final AuthService authService; /** @@ -50,6 +57,16 @@ ApiResponse loginKakaoForApp(@RequestParam String accessToken, @P return authService.loginForApp(accessToken, socialType.toUpperCase(), response); } + @Operation( + summary = "기본 로그인", + description = "이메일과 비밀번호로 로그인합니다. '@'를 포함한 이메일과 비밀번호를 입력해주세요.") + @PostMapping("/login/basic") + public ApiResponsebasicLogin( + @Valid @RequestBody BasicLoginRequest basicLoginRequest, HttpServletResponse response) { + + return authService.basicLogin(basicLoginRequest, response); + } + @GetMapping("/test") @Operation(summary = "로그인 테스트 API", description = "로그인 여부를 확인할 수 있는 API입니다. 회원의 닉네임을 리턴합니다.") public ApiResponse test(@AuthenticationPrincipal CustomUserDetails customUserDetails) { @@ -64,12 +81,27 @@ public ApiResponse test(@AuthenticationPrincipal CustomUserDetails custo */ @PostMapping("/signup") @Operation(summary = "회원가입 API", description = "회원가입을 진행하는 API입니다. (SocialType : BASIC, GOOGLE, NAVER, KAKAO") - public ApiResponse signUp(@RequestBody @Valid SignUpRequest signUpRequest, HttpServletResponse response) { + public ApiResponse signUp( + @RequestBody @Valid SignUpRequest signUpRequest, HttpServletResponse response) { authService.signUp(signUpRequest, response); return new ApiResponse<>(ErrorCode.REQUEST_OK); } + @PostMapping("/signup/basic") + @Operation( + summary = "기본 회원가입", + description = "이메일과 비밀번호로 회원가입합니다.\n" + + "- 이메일 : @를 포함한 이메일 형식이어야 합니다.\n" + + "- 비밀번호 : 비밀번호는 영문자와 숫자를 포함한 8~20자여야 합니다") + public ApiResponse basicSignUp( + @RequestBody @Valid BasicSignUpRequest basicSignUpRequest, HttpServletResponse response) { + + authService.basicSignUp(basicSignUpRequest); + + return new ApiResponse<>(ErrorCode.REQUEST_OK); + } + } diff --git a/src/main/java/com/adoonge/seedzip/auth/dto/request/BasicLoginRequest.java b/src/main/java/com/adoonge/seedzip/auth/dto/request/BasicLoginRequest.java new file mode 100644 index 0000000..3cf8208 --- /dev/null +++ b/src/main/java/com/adoonge/seedzip/auth/dto/request/BasicLoginRequest.java @@ -0,0 +1,8 @@ +package com.adoonge.seedzip.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record BasicLoginRequest(@Email String email, @NotNull String password) {} diff --git a/src/main/java/com/adoonge/seedzip/auth/dto/request/BasicSignUpRequest.java b/src/main/java/com/adoonge/seedzip/auth/dto/request/BasicSignUpRequest.java new file mode 100644 index 0000000..e8bb4a1 --- /dev/null +++ b/src/main/java/com/adoonge/seedzip/auth/dto/request/BasicSignUpRequest.java @@ -0,0 +1,72 @@ +package com.adoonge.seedzip.auth.dto.request; + +import java.time.LocalDate; + +import com.adoonge.seedzip.member.domain.Gender; +import com.adoonge.seedzip.member.domain.Member; +import com.adoonge.seedzip.member.domain.Role; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class BasicSignUpRequest{ + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이어야 합니다.") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,20}$", message = "비밀번호는 영문자와 숫자를 포함한 8~20자여야 합니다.") + private String password; + + @NotBlank(message = "닉네임은 필수입니다.") + @Pattern(regexp = "^[가-힣a-zA-Z0-9 ]{1,10}$", message = "닉네임은 한글, 영문, 숫자, 공백 포함 10자 이내로 작성해야 합니다.") + private String nickname; + + @NotNull(message = "생년월일은 필수입니다.") + @PastOrPresent(message = "생년월일은 과거 또는 오늘 날짜여야 합니다.") + private LocalDate birthday; + + private Gender gender; + private String occupation; + private String field; + + // 필수 - 서비스 이용 약관 동의 + @AssertTrue(message = "서비스 이용 약관에 동의해야 합니다.") + private Boolean consentToTermsOfService; + + // 필수 - 개인정보 수집 및 이용 동의 + @AssertTrue(message = "개인정보 수집 및 이용에 동의해야 합니다.") + private Boolean consentToPersonalInformation; + + private Boolean consentToMarketingAndAds; // 마케팅 활용 및 광고성 정보 수신 동의 여부 + + public Member toEntity(String email, String encodedPassword, String profileImageUrl) { + return Member.builder() + .loginId(email) + .password(encodedPassword) + .nickname(nickname) + .birthday(birthday) + .gender(gender) + .occupation(occupation) + .field(field) + .role(Role.USER) + .profileImageUrl(profileImageUrl) + .consentToTermsOfService(consentToTermsOfService) + .consentToPersonalInformation(consentToPersonalInformation) + .consentToMarketingAndAds(consentToMarketingAndAds) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/adoonge/seedzip/auth/dto/response/BasicLoginResponse.java b/src/main/java/com/adoonge/seedzip/auth/dto/response/BasicLoginResponse.java new file mode 100644 index 0000000..affb5e8 --- /dev/null +++ b/src/main/java/com/adoonge/seedzip/auth/dto/response/BasicLoginResponse.java @@ -0,0 +1,9 @@ +package com.adoonge.seedzip.auth.dto.response; + +import com.adoonge.seedzip.auth.domain.SocialType; + +import lombok.Builder; + +@Builder +public record BasicLoginResponse(String result, SocialType socialType) { +} diff --git a/src/main/java/com/adoonge/seedzip/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/adoonge/seedzip/auth/filter/JwtAuthenticationFilter.java index 8214709..6e54e7c 100644 --- a/src/main/java/com/adoonge/seedzip/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/adoonge/seedzip/auth/filter/JwtAuthenticationFilter.java @@ -36,7 +36,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha // Request Header에서 토큰 정보 추출 private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; diff --git a/src/main/java/com/adoonge/seedzip/auth/service/AuthService.java b/src/main/java/com/adoonge/seedzip/auth/service/AuthService.java index 417549f..90e9760 100644 --- a/src/main/java/com/adoonge/seedzip/auth/service/AuthService.java +++ b/src/main/java/com/adoonge/seedzip/auth/service/AuthService.java @@ -1,7 +1,10 @@ package com.adoonge.seedzip.auth.service; import com.adoonge.seedzip.auth.domain.SocialType; +import com.adoonge.seedzip.auth.dto.request.BasicLoginRequest; +import com.adoonge.seedzip.auth.dto.request.BasicSignUpRequest; import com.adoonge.seedzip.auth.dto.request.SignUpRequest; +import com.adoonge.seedzip.auth.dto.response.BasicLoginResponse; import com.adoonge.seedzip.auth.dto.response.LoginResponse; import com.adoonge.seedzip.category.domain.Category; import com.adoonge.seedzip.category.repository.CategoryRepository; @@ -14,6 +17,8 @@ import com.adoonge.seedzip.auth.service.oauth.OAuthServiceFactory; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.core.Authentication; @@ -21,6 +26,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class AuthService { @@ -81,6 +87,36 @@ public ApiResponse loginForApp(String accessToken, String input, return new ApiResponse<>(LoginResponse.builder().result("").socialType(socialType).build(), ErrorCode.REQUEST_OK); } + @Transactional(readOnly = true) + public ApiResponse basicLogin(BasicLoginRequest basicLoginRequest, HttpServletResponse response) { + Member member = + memberRepository + .findByLoginId(basicLoginRequest.email()) + .orElseThrow(() -> SeedzipException.from(ErrorCode.MEMBER_NOT_FOUND)); + + if (!passwordEncoder.matches(basicLoginRequest.password(), member.getPassword())) { + throw SeedzipException.from(ErrorCode.INVALID_CREDENTIALS); + } + + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken( + basicLoginRequest.email(), + basicLoginRequest.password() + ); + + Authentication authentication = + authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + jwtTokenService.generateToken(authentication, response); + + return new ApiResponse<>( + BasicLoginResponse.builder() + .result("") + .socialType(SocialType.BASIC) + .build(), + ErrorCode.REQUEST_OK); + } + @Transactional public void signUp(SignUpRequest request, HttpServletResponse response) { @@ -114,6 +150,24 @@ public void signUp(SignUpRequest request, HttpServletResponse response) { generateToken(loginId, response); } + @Transactional + public void basicSignUp(BasicSignUpRequest request) { + String email = request.getEmail(); + if (memberRepository.existsByLoginId(email)) { + throw SeedzipException.from(ErrorCode.ACCOUNT_USERNAME_EXIST); + } + + String encodedPassword = passwordEncoder.encode(request.getPassword()); + + Member member = request.toEntity(email ,encodedPassword, null); // 기본 프로필 이미지 추가해야함 + + memberRepository.save(member); + memberRepository.flush(); + + Category category = Category.builder().name("미분류").member(member).isPublic(true).isDefault(true).build(); + categoryRepository.save(category); + } + private void generateToken(String loginId, HttpServletResponse response) { // 1. username + password 를 기반으로 Authentication 객체 생성 // 이때 authentication 은 인증 여부를 확인하는 authenticated 값이 false diff --git a/src/main/java/com/adoonge/seedzip/category/dto/response/CategoryResponse.java b/src/main/java/com/adoonge/seedzip/category/dto/response/CategoryResponse.java index ab8a2d9..dccde99 100644 --- a/src/main/java/com/adoonge/seedzip/category/dto/response/CategoryResponse.java +++ b/src/main/java/com/adoonge/seedzip/category/dto/response/CategoryResponse.java @@ -7,14 +7,26 @@ public record CategoryResponse( Long categoryId, String name, Boolean isPublic, - Long memberId) { + Long memberId, + Long seedCount) { + + public static CategoryResponse fromEntityWithCount(Category category, Long seedCount) { + return new CategoryResponse( + category.getCategoryId(), + category.getName(), + category.getIsPublic(), + category.getMember().getId(), + seedCount + ); + } public static CategoryResponse fromEntity(Category category) { return new CategoryResponse( category.getCategoryId(), category.getName(), category.getIsPublic(), - category.getMember().getId() + category.getMember().getId(), + 0L ); } } diff --git a/src/main/java/com/adoonge/seedzip/category/service/CategoryService.java b/src/main/java/com/adoonge/seedzip/category/service/CategoryService.java index ea85e0a..6101c5c 100644 --- a/src/main/java/com/adoonge/seedzip/category/service/CategoryService.java +++ b/src/main/java/com/adoonge/seedzip/category/service/CategoryService.java @@ -13,6 +13,7 @@ import com.adoonge.seedzip.global.exception.ErrorCode; import com.adoonge.seedzip.global.exception.SeedzipException; import com.adoonge.seedzip.member.domain.Member; +import com.adoonge.seedzip.seed.repository.CategorySeedRepository; import com.adoonge.seedzip.seed.repository.SeedRepository; import lombok.AccessLevel; @@ -27,6 +28,7 @@ public class CategoryService { private final CategoryRepository categoryRepository; private final SeedRepository seedRepository; + private final CategorySeedRepository categorySeedRepository; private static final String DEFAULT_CATEGORY_NAME = "미분류"; @@ -72,7 +74,10 @@ public List getCategories(Member member) { List categories = categoryRepository.findAllByMemberOrdered(member); return categories.stream() - .map(CategoryResponse::fromEntity) + .map( category -> { + Long seedCount = categorySeedRepository.countByCategoryCategoryId(category.getCategoryId()); + return CategoryResponse.fromEntityWithCount(category, seedCount); + }) .toList(); } diff --git a/src/main/java/com/adoonge/seedzip/global/config/SecurityConfig.java b/src/main/java/com/adoonge/seedzip/global/config/SecurityConfig.java index 20d6446..312fd42 100644 --- a/src/main/java/com/adoonge/seedzip/global/config/SecurityConfig.java +++ b/src/main/java/com/adoonge/seedzip/global/config/SecurityConfig.java @@ -44,7 +44,8 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .requestMatchers("/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v1/api-docs/**","/health/**") .permitAll() // 해당 API에 대해서는 모든 요청을 허가 - .requestMatchers("/api/v1/auth/login/**","/api/v1/auth/signup","/api/v1/simplification/**").permitAll() + .requestMatchers("/api/v1/auth/login/**","/api/v1/auth/signup/**","/api/v1/simplification/**") + .permitAll() //.requestMatchers("/members/test").hasRole("USER") // USER 권한이 있어야 요청할 수 있음 //.requestMatchers("/members/test").hasRole("USER") diff --git a/src/main/java/com/adoonge/seedzip/global/dto/response/ApiResponse.java b/src/main/java/com/adoonge/seedzip/global/dto/response/ApiResponse.java index 98e5891..0f42217 100644 --- a/src/main/java/com/adoonge/seedzip/global/dto/response/ApiResponse.java +++ b/src/main/java/com/adoonge/seedzip/global/dto/response/ApiResponse.java @@ -43,16 +43,23 @@ public ApiResponse(List results) { // Page 타입 생성자 public ApiResponse(Page page) { this.status = new Status(ErrorCode.REQUEST_OK); - this.metadata = new Metadata(page.getContent().size(), page.getPageable(), page.hasNext()); + this.metadata = new Metadata( + page.getTotalElements(), + page.getContent().size(), + page.getPageable(), + page.hasNext()); this.results = page.getContent(); } // Slice 타입 생성자 - public ApiResponse(Slice slice) { - this.status = new Status(ErrorCode.REQUEST_OK); - this.metadata = new Metadata(slice.getContent().size(), slice.getPageable(), slice.hasNext()); - this.results = slice.getContent(); - } + // public ApiResponse(Slice slice) { + // this.status = new Status(ErrorCode.REQUEST_OK); + // this.metadata = new Metadata( + // slice.getContent().size(), + // slice.getPageable(), + // slice.hasNext()); + // this.results = slice.getContent(); + // } // 오류 코드 생성자 public ApiResponse(ErrorCode errorCode) { @@ -66,6 +73,7 @@ public ApiResponse(SeedzipException exception) { @Getter private static class Metadata { + private long totalElementCount = 0; private int resultCount = 0; @JsonInclude(Include.NON_NULL) // 값이 설정되지 않으면 JSON 응답에서 제외 private Pageable pageable; @@ -78,7 +86,8 @@ public Metadata(int resultCount) { } // Page용 메타데이터 생성자 - public Metadata(int resultCount, Pageable pageable, boolean hasNext) { + public Metadata(long totalElementCount, int resultCount, Pageable pageable, boolean hasNext) { + this.totalElementCount = totalElementCount; this.resultCount = resultCount; this.pageable = pageable; this.hasNext = hasNext; diff --git a/src/main/java/com/adoonge/seedzip/global/exception/ErrorCode.java b/src/main/java/com/adoonge/seedzip/global/exception/ErrorCode.java index d232879..98c085c 100644 --- a/src/main/java/com/adoonge/seedzip/global/exception/ErrorCode.java +++ b/src/main/java/com/adoonge/seedzip/global/exception/ErrorCode.java @@ -1,5 +1,7 @@ package com.adoonge.seedzip.global.exception; +import java.util.Arrays; + import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -34,6 +36,7 @@ public enum ErrorCode { OPEN_ID_PROVIDER_NOT_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR, "OpenID 제공자 서버에 문제가 발생했습니다."), OAUTH2_INVALID_CODE(HttpStatus.BAD_REQUEST, "올바르지 않은 인가 코드입니다."), INVALID_SOCIAL_CODE(HttpStatus.BAD_REQUEST, "잘못된 소셜코드입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "잘못된 자격 증명입니다."), GET_KAKAO_ACCESS_TOKEN_FAILED(HttpStatus.BAD_GATEWAY, "카카오 엑세스 토큰 발급에 실패했습니다."), GET_KAKAO_UNIQUE_ID_FAILED(HttpStatus.BAD_GATEWAY, "카카오 유저 정보 흭득에 실패했습니다."), GET_NAVER_ACCESS_TOKEN_FAILED(HttpStatus.BAD_GATEWAY, "네이버 엑세스 토큰 발급에 실패했습니다."), @@ -77,6 +80,10 @@ public enum ErrorCode { //validation + EMAIL_REQUIRED(HttpStatus.BAD_REQUEST, "이메일은 필수입니다."), + EMAIL_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "유효한 이메일 형식이어야 합니다."), + PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "비밀번호는 필수입니다."), + PASSWORD_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "비밀번호는 영문자와 숫자를 포함한 8~20자여야 합니다."), NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "닉네임은 필수입니다."), NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "닉네임은 한글, 영문, 숫자, 공백 포함 10자 이내로 작성해야 합니다."), BIRTHDAY_REQUIRED(HttpStatus.BAD_REQUEST, "생년월일은 필수입니다."), @@ -154,12 +161,19 @@ public enum ErrorCode { private final String message; // 메시지를 기반으로 ErrorCode를 찾는 정적 메서드 + // public static ErrorCode fromMessage(String message) { + // for (ErrorCode errorCode : ErrorCode.values()) { + // if (errorCode.getMessage().equals(message)) { + // return errorCode; + // } + // } + // throw SeedzipException.from(ErrorCode.INTERNAL_SEVER_ERROR); + // } + public static ErrorCode fromMessage(String message) { - for (ErrorCode errorCode : ErrorCode.values()) { - if (errorCode.getMessage().equals(message)) { - return errorCode; - } - } - throw SeedzipException.from(ErrorCode.INTERNAL_SEVER_ERROR); + return Arrays.stream(values()) + .filter(code -> code.getMessage().equals(message)) + .findFirst() + .orElse(ErrorCode.INVALID_REQUEST); // ⭐ default } } diff --git a/src/main/java/com/adoonge/seedzip/seed/repository/CategorySeedRepository.java b/src/main/java/com/adoonge/seedzip/seed/repository/CategorySeedRepository.java index 75dfe1f..36ae1d4 100644 --- a/src/main/java/com/adoonge/seedzip/seed/repository/CategorySeedRepository.java +++ b/src/main/java/com/adoonge/seedzip/seed/repository/CategorySeedRepository.java @@ -28,4 +28,6 @@ public interface CategorySeedRepository extends JpaRepository seedIds); + + long countByCategoryCategoryId(Long categoryId); }