Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin' into #53-change-product-status
Browse files Browse the repository at this point in the history
  • Loading branch information
JinTaekLim committed Jan 31, 2025
2 parents 8213ecf + a4f5b5b commit d615835
Show file tree
Hide file tree
Showing 35 changed files with 928 additions and 160 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ dependencies {
implementation 'software.amazon.awssdk:core:2.20.0'
implementation 'software.amazon.awssdk:auth:2.20.0'
implementation 'me.paulschwarz:spring-dotenv:3.0.0'
implementation 'org.springframework:spring-web'
implementation 'org.springframework.boot:spring-boot-starter-security'

}

test {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.market.saessag.domain.email.controller;

import com.market.saessag.domain.email.dto.EmailRequest;
import com.market.saessag.domain.email.dto.PasswordChangeRequest;
import com.market.saessag.domain.email.service.PasswordService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/password")
public class PasswordController {
private final PasswordService passwordService;

@PostMapping("/find")
public ResponseEntity<?> findPassword(@RequestBody @Validated EmailRequest request) {
// 이메일로 임시 비밀번호 발급
passwordService.sendTemporaryPassword(request.getEmail());
return ResponseEntity.ok("임시 비밀번호가 이메일로 발송되었습니다."); // 응답 포맷 통일 필요함
}

@PatchMapping("/change")
public ResponseEntity<?> changePassword(@RequestBody @Validated PasswordChangeRequest request) {
passwordService.changePassword(request);
return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다."); // 응답 포맷 통일 필요함
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class EmailRequest { // 이메일 인증 요청 DTO
public class EmailRequest {
private String email; // 인증을 요청할 이메일 주소

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.market.saessag.domain.email.dto;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PasswordChangeRequest {
private String email;
private String currentPassword;
private String newPassword; // 새로 설정할 비밀번호
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public void sendVerificationEmail(String toEmail) {

} catch (MessagingException e) { // 이메일 발송 실패 시 저장된 인증 정보 제거
verificationStore.remove(toEmail);
throw new CustomException(ErrorCode.INVALID_VERIFICATION_CODE); // 이메일 발송 실패
throw new CustomException(ErrorCode.EMAIL_SEND_FAILED); // 이메일 발송 실패
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.market.saessag.domain.email.service;

import com.market.saessag.domain.email.dto.PasswordChangeRequest;
import com.market.saessag.domain.user.entity.User;
import com.market.saessag.domain.user.repository.UserRepository;
import com.market.saessag.global.exception.CustomException;
import com.market.saessag.global.exception.ErrorCode;
import com.market.saessag.global.util.TemporaryPassword;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;

@Service
@RequiredArgsConstructor
public class PasswordService {
private final UserRepository userRepository;
private final JavaMailSender mailSender; // 이메일 발송을 위한 스프링 제공 인터페이스
private final BCryptPasswordEncoder passwordEncoder;
private final Map<String, TemporaryPassword> temporaryPasswordStore = new ConcurrentHashMap<>(); // 임시 저장소 추가


// 임시 비밀번호 발급
public void sendTemporaryPassword(String email) {
try {
// 1. 사용자 존재 여부 확인
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

// 2. 임시 비밀번호 생성
String temporaryPassword = generateTemporaryPassword();

// 3. 임시 비밀번호 저장 (5분 유효)
LocalDateTime expirationTime = LocalDateTime.now().plusMinutes(5);
TemporaryPassword tempPassword = new TemporaryPassword(temporaryPassword, expirationTime);
temporaryPasswordStore.put(email, tempPassword);

// 4. 이메일 발송
sendPasswordEmail(email, temporaryPassword);

// 5. 이메일 발송 성공 시 DB 업데이트
user.updatePassword(passwordEncoder.encode(temporaryPassword));
userRepository.save(user);

} catch (CustomException e) {
temporaryPasswordStore.remove(email);
throw new CustomException(ErrorCode.EMAIL_SEND_FAILED);
}
}

// 임시 비밀번호 생성 (10자리)
private String generateTemporaryPassword() {
StringBuilder password = new StringBuilder();
Random random = new Random();

// 대문자, 소문자, 숫자 포함
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

for (int i = 0; i < 10; i++) {
password.append(chars.charAt(random.nextInt(chars.length())));
}

return password.toString();
}

private void sendPasswordEmail(String email, String temporaryPassword) {
try {
// 이메일 메시지 생성
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);

helper.setTo(email);
helper.setSubject("[새싹마켓] 임시 비밀번호 발급");
helper.setText(createEmailContent(temporaryPassword), true);

// 이메일 발송
mailSender.send(message);
} catch (MessagingException e) {// 이메일 발송 실패 시 임시 저장소에서 제거
temporaryPasswordStore.remove(email);
throw new CustomException(ErrorCode.EMAIL_SEND_FAILED); // 이메일 발송 실패
}
}

private String createEmailContent(String temporaryPassword) {
return String.format("""
<div style='text-align: center; margin: 30px;'>
<h3> saessagMarket </h3>
<h2>임시 비밀번호 발급</h2>
<p> 본 메일은 saessagMarket 임시 비밀번호 발급을 위한 이메일입니다.</p>
<p>아래의 임시 비밀번호로 로그인해 주세요.</p>
<p>보안을 위해 로그인 후 비밀번호를 변경해 주세요.</p>
<p style='font-size: 24px; font-weight: bold; margin: 20px;'>%s</p>
</div>
""", temporaryPassword);
}

// 5분마다 만료된 임시 비밀번호 정리
@Scheduled(fixedRate = 300000)
public void cleanupExpiredPasswords() {
temporaryPasswordStore.entrySet().removeIf(entry -> entry.getValue().isExpired());
}

// 비밀번호 변경
@Transactional
public void changePassword(PasswordChangeRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

// 현재 비밀번호 확인
if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) {
throw new CustomException(ErrorCode.INVALID_PASSWORD);
}

// 새 비밀번호 암호화 및 업데이트
user.updatePassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);
}

}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package com.market.saessag.domain.photo.controller;

import com.market.saessag.domain.photo.service.S3Service;
import org.springframework.http.ResponseEntity;
import com.market.saessag.global.exception.ErrorCode;
import com.market.saessag.global.response.ApiResponse;
import com.market.saessag.global.response.SuccessCode;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

Expand All @@ -21,22 +22,22 @@ public PhotoController(S3Service s3Service) {
}

@PostMapping("/upload")
public ResponseEntity<List<String>> uploadPhotos(@RequestParam MultipartFile[] files) {
public ApiResponse<List<String>> uploadPhotos(@RequestParam MultipartFile[] files) {
List<String> fileUrls = new ArrayList<>();
try {
for (MultipartFile file : files) {
String fileUrl = s3Service.uploadFile(file);
fileUrls.add(fileUrl);
}
return ResponseEntity.ok(fileUrls); //ApiResponse 사용하기
return ApiResponse.success(SuccessCode.OK, fileUrls);
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Collections.singletonList("파일 업로드에 실패했습니다." + e.getMessage()));
return ApiResponse.error(ErrorCode.FILE_UPLOAD_ERROR);
}
}

@GetMapping()
public ResponseEntity<Map<String, String>> getPresignedUrl(@RequestParam List<String> keys) {
public ApiResponse<Map<String, String>> getPresignedUrl(@RequestParam List<String> keys) {
Map<String, String> urls = s3Service.getPresignedUrl(keys);
return ResponseEntity.ok(urls);
return ApiResponse.success(SuccessCode.OK, urls);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.market.saessag.domain.photo.controller;

import com.market.saessag.domain.photo.service.S3Service;
import com.market.saessag.global.exception.ErrorCode;
import com.market.saessag.global.response.ApiResponse;
import com.market.saessag.global.response.SuccessCode;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.Map;

@RestController
@RequestMapping("/api/profile")
@RequiredArgsConstructor
public class ProfileImageController {
private final S3Service s3Service;

/*
프로필 사진 업로드
사용자 관점에서는 프로필 사진 업로드와 수정이 동일한 방식으로 진행됨.
따라서 하나의 Patch 메서드에서 동작함.
*/
@PatchMapping("/upload-image")
public ApiResponse<String> uploadProfileImage(@RequestParam("file") MultipartFile file, HttpSession session) {
try {
String email = (String) session.getAttribute("email");
if (email == null) {
return ApiResponse.error(ErrorCode.UNAUTHORIZED);
}

String fileUrl = s3Service.uploadProfileImage(file, email);
return ApiResponse.success(SuccessCode.UPLOAD_SUCCESS, fileUrl);
} catch (IOException e) {
return ApiResponse.error(ErrorCode.FILE_UPLOAD_ERROR);
}
}

// 프로필 사진 조회
@GetMapping
public ApiResponse<Map<String, String>> getProfileImageUrl(HttpSession session) {
String email = (String) session.getAttribute("email");
if (email == null) {
return ApiResponse.error(ErrorCode.UNAUTHORIZED);
}

Map<String, String> urls = s3Service.getProfileImageUrl(email);
return ApiResponse.success(SuccessCode.OK, urls);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.market.saessag.domain.photo.dto;

import com.market.saessag.domain.user.entity.User;
import lombok.Getter;

// 프로필 사진 조회
@Getter
public class UserProfileImageResponse {
private String profileUrl;

public static UserProfileImageResponse from(User user) {
UserProfileImageResponse response = new UserProfileImageResponse();
response.profileUrl = user.getProfileUrl();
return response;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package com.market.saessag.domain.photo.service;

import com.market.saessag.domain.user.entity.User;
import com.market.saessag.domain.user.repository.UserRepository;
import com.market.saessag.global.exception.CustomException;
import com.market.saessag.global.exception.ErrorCode;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
Expand All @@ -17,6 +23,7 @@
import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
Expand All @@ -34,6 +41,9 @@ public class S3Service {
private S3Client s3Client;
private S3Presigner s3Presigner;

@Autowired
private UserRepository userRepository;

@PostConstruct
public void init() {
String accessKey = System.getenv("AWS_ACCESS_KEY");
Expand Down Expand Up @@ -98,4 +108,27 @@ public Map<String, String> getPresignedUrl(List<String> keys) {
}
));
}

public String uploadProfileImage(MultipartFile file, String email) throws IOException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

String fileUrl = uploadFile(file);
user.setProfileUrl(fileUrl);
userRepository.save(user);

return fileUrl;
}

public Map<String, String> getProfileImageUrl(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

if (user.getProfileUrl() == null) {
throw new CustomException(ErrorCode.PROFILE_IMAGE_NOT_FOUND);
}

return getPresignedUrl(Collections.singletonList(user.getProfileUrl()));
}

}
Loading

0 comments on commit d615835

Please sign in to comment.