diff --git a/build.gradle b/build.gradle index d9f41b13..8617b10a 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,8 @@ dependencies { // thymeleaf implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' + // AWS S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { diff --git a/src/main/java/com/pawwithu/connectdog/common/s3/FileService.java b/src/main/java/com/pawwithu/connectdog/common/s3/FileService.java new file mode 100644 index 00000000..e868dc23 --- /dev/null +++ b/src/main/java/com/pawwithu/connectdog/common/s3/FileService.java @@ -0,0 +1,69 @@ +package com.pawwithu.connectdog.common.s3; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.pawwithu.connectdog.error.exception.custom.BadRequestException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +import static com.pawwithu.connectdog.error.ErrorCode.INVALID_FILE_UPLOAD; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileService { + + private final AmazonS3Client amazonS3Client; + + @Value("${cloud.aws.s3.bucket.name}") + private String bucketName; + + @Value("${cloud.aws.s3.bucket.url}") + private String defaultUrl; + + public String uploadFile(MultipartFile multipartFile, String imageType) { + if (multipartFile == null || multipartFile.isEmpty()) return null; + + String savedFileName = getSavedFileName(multipartFile, imageType); + ObjectMetadata metadata = new ObjectMetadata(); + + try (InputStream inputStream = multipartFile.getInputStream()) { + amazonS3Client.putObject(bucketName, savedFileName, inputStream, metadata); + } catch (IOException e) { + log.error("Failed to upload image", e); + throw new BadRequestException(INVALID_FILE_UPLOAD); + } + return getResourceUrl(savedFileName); + } + + public void deleteFile(String fileUrl) { + String fileName = getFileNameFromResourceUrl(fileUrl); + amazonS3Client.deleteObject(new DeleteObjectRequest(bucketName, fileName)); + } + + // imageType: volunteer -> "volunteer" + private String getSavedFileName(MultipartFile multipartFile, String imageType) { + return String.format("%s/%s-%s", + imageType, getRandomUUID(), multipartFile.getOriginalFilename()); + } + + private String getRandomUUID() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private String getResourceUrl(String savedFileName) { + return amazonS3Client.getResourceUrl(bucketName, savedFileName); + } + + private String getFileNameFromResourceUrl(String fileUrl) { + return fileUrl.replace(defaultUrl + "/", ""); + } +} \ No newline at end of file diff --git a/src/main/java/com/pawwithu/connectdog/config/S3Config.java b/src/main/java/com/pawwithu/connectdog/config/S3Config.java new file mode 100644 index 00000000..aa9dd197 --- /dev/null +++ b/src/main/java/com/pawwithu/connectdog/config/S3Config.java @@ -0,0 +1,33 @@ +package com.pawwithu.connectdog.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return (AmazonS3Client) AmazonS3ClientBuilder + .standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/pawwithu/connectdog/domain/auth/controller/SignUpController.java b/src/main/java/com/pawwithu/connectdog/domain/auth/controller/SignUpController.java index 6d4533bc..4b5d7acc 100644 --- a/src/main/java/com/pawwithu/connectdog/domain/auth/controller/SignUpController.java +++ b/src/main/java/com/pawwithu/connectdog/domain/auth/controller/SignUpController.java @@ -1,6 +1,7 @@ package com.pawwithu.connectdog.domain.auth.controller; import com.pawwithu.connectdog.domain.auth.dto.request.EmailRequest; +import com.pawwithu.connectdog.domain.auth.dto.request.IntermediarySignUpRequest; import com.pawwithu.connectdog.domain.auth.dto.request.VolunteerSignUpRequest; import com.pawwithu.connectdog.domain.auth.dto.response.EmailResponse; import com.pawwithu.connectdog.domain.auth.service.AuthService; @@ -13,10 +14,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "Sign-Up", description = "Sign-Up API") @RestController @@ -26,15 +30,31 @@ public class SignUpController { private final AuthService authService; private final EmailService emailService; - @Operation(summary = "자체 회원가입", description = "이메일을 사용해 회원가입을 합니다.", - responses = {@ApiResponse(responseCode = "204", description = "자체 회원가입 성공") + @Operation(summary = "이동봉사자 자체 회원가입", description = "이동봉사자 자체 회원가입을 합니다.", + responses = {@ApiResponse(responseCode = "204", description = "이동봉사자 자체 회원가입 성공") , @ApiResponse(responseCode = "400" - , description = "1. 이미 존재하는 이메일입니다. \t\n 2. 이미 존재하는 사용자 닉네임입니다." + , description = "V1, 이메일 형식에 맞지 않습니다. \t\n V1, 이메일은 필수 입력 값입니다. \t\n V1, 영문+숫자 10자 이상 또는 영문+숫자+특수기호 8자 이상을 입력해 주세요. \t\n " + + "V1, 닉네임은 한글, 숫자만 사용가능합니다. \t\n V1, 닉네임은 필수 입력 값입니다. \t\n V1, 2~10자의 닉네임이어야 합니다 \t\n A1, 이미 존재하는 이메일입니다. \t\n A2, 이미 존재하는 사용자 닉네임입니다." , content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - @PostMapping("volunteers/sign-up") - public ResponseEntity signUp(@RequestBody VolunteerSignUpRequest volunteerSignUpRequest) { - authService.signUp(volunteerSignUpRequest); + @PostMapping("/volunteers/sign-up") + public ResponseEntity volunteerSignUp(@RequestBody @Valid VolunteerSignUpRequest request) { + authService.volunteerSignUp(request); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "이동봉사 중개 자체 회원가입", description = "이동봉사 중개 자체 회원가입을 합니다.", + responses = {@ApiResponse(responseCode = "204", description = "이동봉사 중개 자체 회원가입 성공") + , @ApiResponse(responseCode = "400" + , description = "V1, 이메일 형식에 맞지 않습니다. \t\n V1, 이메일은 필수 입력 값입니다. \t\n " + + "V1, 영문+숫자 10자 이상 또는 영문+숫자+특수기호 8자 이상을 입력해 주세요. \t\n V1, url 형식을 입력해 주세요. \t\n " + + "A1, 이미 존재하는 이메일입니다. \t\n F1, 파일이 존재하지 않습니다. \t\n F2, 파일 업로드에 실패했습니다." + , content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping(value = "/intermediaries/sign-up", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) + public ResponseEntity intermediarySignUp(@RequestPart @Valid IntermediarySignUpRequest request, + @RequestPart(name = "file", required = false) MultipartFile file) { + authService.intermediarySignUp(request, file); return ResponseEntity.noContent().build(); } @@ -45,8 +65,8 @@ public ResponseEntity signUp(@RequestBody VolunteerSignUpRequest volunteer , content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @PostMapping(value = {"/volunteers/sign-up/email", "/intermediaries/sign-up/email"}) - public ResponseEntity mailConfirm(@RequestBody @Valid EmailRequest emailRequest){ - EmailResponse emailResponse = emailService.sendEmail(emailRequest); + public ResponseEntity mailConfirm(@RequestBody @Valid EmailRequest request){ + EmailResponse emailResponse = emailService.sendEmail(request); return ResponseEntity.ok(emailResponse); } diff --git a/src/main/java/com/pawwithu/connectdog/domain/auth/dto/request/IntermediarySignUpRequest.java b/src/main/java/com/pawwithu/connectdog/domain/auth/dto/request/IntermediarySignUpRequest.java new file mode 100644 index 00000000..b6948b2e --- /dev/null +++ b/src/main/java/com/pawwithu/connectdog/domain/auth/dto/request/IntermediarySignUpRequest.java @@ -0,0 +1,29 @@ +package com.pawwithu.connectdog.domain.auth.dto.request; + +import com.pawwithu.connectdog.domain.intermediary.entity.Intermediary; +import com.pawwithu.connectdog.domain.intermediary.entity.IntermediaryRole; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record IntermediarySignUpRequest(@Email(message="이메일 형식에 맞지 않습니다.") + @NotBlank(message = "이메일은 필수 입력 값입니다.") String email, + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d).{10,}$|^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[~!@#$%^&*()+|=?]).{8,}$", + message = "영문+숫자 10자 이상 또는 영문+숫자+특수기호 8자 이상을 입력해 주세요.") String password, + String name, + @Pattern(regexp = "^(http|https)://[a-zA-Z0-9-.]+\\.[a-zA-Z]{2,}(/\\S*)?$", + message = "url 형식을 입력해 주세요.") String url, + Boolean isOptionAgr) { + + public static Intermediary toEntity(IntermediarySignUpRequest request, String authImage) { + return Intermediary.builder() + .email(request.email) + .password(request.password) + .name(request.name) + .url(request.url) + .authImage(authImage) + .isOptionAgr(request.isOptionAgr) + .role(IntermediaryRole.INTERMEDIARY) + .build(); + } +} diff --git a/src/main/java/com/pawwithu/connectdog/domain/auth/dto/request/VolunteerSignUpRequest.java b/src/main/java/com/pawwithu/connectdog/domain/auth/dto/request/VolunteerSignUpRequest.java index 5087115a..19915e5d 100644 --- a/src/main/java/com/pawwithu/connectdog/domain/auth/dto/request/VolunteerSignUpRequest.java +++ b/src/main/java/com/pawwithu/connectdog/domain/auth/dto/request/VolunteerSignUpRequest.java @@ -2,8 +2,18 @@ import com.pawwithu.connectdog.domain.volunteer.entity.Volunteer; import com.pawwithu.connectdog.domain.volunteer.entity.VolunteerRole; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; -public record VolunteerSignUpRequest(String email, String password, String nickname, Boolean isOptionAgr) { +public record VolunteerSignUpRequest(@Email(message="이메일 형식에 맞지 않습니다.") + @NotBlank(message = "이메일은 필수 입력 값입니다.") String email, + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d).{10,}$|^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[~!@#$%^&*()+|=?]).{8,}$", + message = "영문+숫자 10자 이상 또는 영문+숫자+특수기호 8자 이상을 입력해 주세요.") String password, + @Pattern(regexp = "^[가-힣0-9]*$", message = "닉네임은 한글, 숫자만 사용가능합니다.") + @NotBlank(message = "닉네임은 필수 입력 값입니다.") + @Size(min=2, max=10, message = "2~10자의 닉네임이어야 합니다.") String nickname, Boolean isOptionAgr) { public Volunteer toEntity() { return Volunteer.builder() diff --git a/src/main/java/com/pawwithu/connectdog/domain/auth/service/AuthService.java b/src/main/java/com/pawwithu/connectdog/domain/auth/service/AuthService.java index c9a7c197..fcd3d07c 100644 --- a/src/main/java/com/pawwithu/connectdog/domain/auth/service/AuthService.java +++ b/src/main/java/com/pawwithu/connectdog/domain/auth/service/AuthService.java @@ -1,6 +1,10 @@ package com.pawwithu.connectdog.domain.auth.service; +import com.pawwithu.connectdog.common.s3.FileService; +import com.pawwithu.connectdog.domain.auth.dto.request.IntermediarySignUpRequest; import com.pawwithu.connectdog.domain.auth.dto.request.VolunteerSignUpRequest; +import com.pawwithu.connectdog.domain.intermediary.entity.Intermediary; +import com.pawwithu.connectdog.domain.intermediary.repository.IntermediaryRepository; import com.pawwithu.connectdog.domain.volunteer.entity.Volunteer; import com.pawwithu.connectdog.domain.volunteer.repository.VolunteerRepository; import com.pawwithu.connectdog.error.exception.custom.BadRequestException; @@ -9,9 +13,9 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; -import static com.pawwithu.connectdog.error.ErrorCode.ALREADY_EXIST_EMAIL; -import static com.pawwithu.connectdog.error.ErrorCode.ALREADY_EXIST_NICKNAME; +import static com.pawwithu.connectdog.error.ErrorCode.*; @Slf4j @Service @@ -20,20 +24,34 @@ public class AuthService { private final VolunteerRepository volunteerRepository; + private final IntermediaryRepository intermediaryRepository; private final PasswordEncoder passwordEncoder; + private final FileService fileService; - public void signUp(VolunteerSignUpRequest volunteerSignUpRequest) { + public void volunteerSignUp(VolunteerSignUpRequest request) { - if (volunteerRepository.existsByEmail(volunteerSignUpRequest.email())) { + if (volunteerRepository.existsByEmail(request.email())) { throw new BadRequestException(ALREADY_EXIST_EMAIL); } - if (volunteerRepository.existsByNickname(volunteerSignUpRequest.nickname())) { + if (volunteerRepository.existsByNickname(request.nickname())) { throw new BadRequestException(ALREADY_EXIST_NICKNAME); } - Volunteer volunteer = volunteerSignUpRequest.toEntity(); + Volunteer volunteer = request.toEntity(); volunteer.passwordEncode(passwordEncoder); volunteerRepository.save(volunteer); } + public void intermediarySignUp(IntermediarySignUpRequest request, MultipartFile file) { + if (intermediaryRepository.existsByEmail(request.email())) { + throw new BadRequestException(ALREADY_EXIST_EMAIL); + } + String authImage = fileService.uploadFile(file, "intermediary"); + if (authImage == null) { + throw new BadRequestException(FILE_NOT_FOUND); + } + Intermediary intermediary = IntermediarySignUpRequest.toEntity(request, authImage); + intermediary.passwordEncode(passwordEncoder); + intermediaryRepository.save(intermediary); + } } diff --git a/src/main/java/com/pawwithu/connectdog/domain/auth/service/EmailService.java b/src/main/java/com/pawwithu/connectdog/domain/auth/service/EmailService.java index 96b50736..296bba09 100644 --- a/src/main/java/com/pawwithu/connectdog/domain/auth/service/EmailService.java +++ b/src/main/java/com/pawwithu/connectdog/domain/auth/service/EmailService.java @@ -76,17 +76,17 @@ private MimeMessage createEmailForm(String email) throws MessagingException, Uns /** * 메일 전송 */ - public EmailResponse sendEmail(EmailRequest emailRequest) throws BadRequestException { + public EmailResponse sendEmail(EmailRequest request) throws BadRequestException { // 이메일 중복 검사 - if (volunteerRepository.existsByEmail(emailRequest.email())) { + if (volunteerRepository.existsByEmail(request.email())) { throw new BadRequestException(ALREADY_EXIST_EMAIL); } - if (intermediaryRepository.existsByEmail(emailRequest.email())) { + if (intermediaryRepository.existsByEmail(request.email())) { throw new BadRequestException(ALREADY_EXIST_EMAIL); } try{ // 메일전송에 필요한 정보 설정 - MimeMessage emailForm = createEmailForm(emailRequest.email()); + MimeMessage emailForm = createEmailForm(request.email()); emailSender.send(emailForm); return new EmailResponse(authNum); }catch (UnsupportedEncodingException | MessagingException e){ diff --git a/src/main/java/com/pawwithu/connectdog/error/ErrorCode.java b/src/main/java/com/pawwithu/connectdog/error/ErrorCode.java index 434838de..e1ac1a5d 100644 --- a/src/main/java/com/pawwithu/connectdog/error/ErrorCode.java +++ b/src/main/java/com/pawwithu/connectdog/error/ErrorCode.java @@ -14,7 +14,10 @@ public enum ErrorCode { TOKEN_NOT_EXIST("T1", "토큰이 존재하지 않습니다."), TOKEN_IS_EXPIRED("T2", "만료된 토큰입니다."), - INVALID_TOKEN("T3", "잘못된 토큰입니다."); + INVALID_TOKEN("T3", "잘못된 토큰입니다."), + + FILE_NOT_FOUND("F1", "파일이 존재하지 않습니다."), + INVALID_FILE_UPLOAD("F2", "파일 업로드에 실패했습니다."); private final String code; diff --git a/src/test/java/com/pawwithu/connectdog/domain/auth/controller/SignUpControllerTest.java b/src/test/java/com/pawwithu/connectdog/domain/auth/controller/SignUpControllerTest.java new file mode 100644 index 00000000..f643f606 --- /dev/null +++ b/src/test/java/com/pawwithu/connectdog/domain/auth/controller/SignUpControllerTest.java @@ -0,0 +1,113 @@ +package com.pawwithu.connectdog.domain.auth.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.pawwithu.connectdog.domain.auth.dto.request.EmailRequest; +import com.pawwithu.connectdog.domain.auth.dto.request.IntermediarySignUpRequest; +import com.pawwithu.connectdog.domain.auth.dto.request.VolunteerSignUpRequest; +import com.pawwithu.connectdog.domain.auth.dto.response.EmailResponse; +import com.pawwithu.connectdog.domain.auth.service.AuthService; +import com.pawwithu.connectdog.domain.auth.service.EmailService; +import com.pawwithu.connectdog.utils.TestUserArgumentResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.filter.CharacterEncodingFilter; + +import java.nio.charset.StandardCharsets; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class SignUpControllerTest { + + @InjectMocks + private SignUpController signUpController; + @Mock + private AuthService authService; + @Mock + private EmailService emailService; + private ObjectMapper objectMapper = new ObjectMapper(); + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(signUpController) + .setCustomArgumentResolvers(new TestUserArgumentResolver()) + .addFilter(new CharacterEncodingFilter("UTF-8", true)) + .build(); + } + @Test + void 이메일_인증번호_전송() throws Exception{ + //given + EmailRequest request = new EmailRequest("email@naver.com"); + EmailResponse response = new EmailResponse("authCode123"); + + //when + when(emailService.sendEmail(any())).thenReturn(response); + ResultActions result = mockMvc.perform( + post("/volunteers/sign-up/email") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + //then + result.andExpect(status().isOk()); + verify(emailService, times(1)).sendEmail(any()); + } + + + @Test + void 이동봉사자_자체_회원가입() throws Exception{ + //given + VolunteerSignUpRequest request = new VolunteerSignUpRequest("email@naver.com", + "pasword12345", + "코넥독", + false); + //when + ResultActions result = mockMvc.perform( + post("/volunteers/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + //then + result.andExpect(status().isNoContent()); + verify(authService, times(1)).volunteerSignUp(any()); + } + + @Test + void 이동봉사_중개_자체_회원가입() throws Exception{ + //given + IntermediarySignUpRequest request = new IntermediarySignUpRequest("email@naver.com", + "pasword12345", + "이동봉사 단체", + "https://connectdog.site", + false); + + MockMultipartFile file = new MockMultipartFile("file", "authImage.png", "multipart/form-data", "uploadFile".getBytes(StandardCharsets.UTF_8)); + MockMultipartFile intermediarySignUpRequest = new MockMultipartFile("request", null, "application/json", objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)); + + //when + ResultActions result = mockMvc.perform(MockMvcRequestBuilders + .multipart(HttpMethod.POST, "/intermediaries/sign-up") + .file(file) + .file(intermediarySignUpRequest) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.MULTIPART_FORM_DATA)); + + //then + result.andExpect(status().isNoContent()); + verify(authService, times(1)).intermediarySignUp(any(), any()); + } +} \ No newline at end of file