Skip to content

Commit 24e4db1

Browse files
authored
Merge pull request #16 from KONKUK-MAP-Service/feat-image
➕ [Feat] : 장소 도메인 다중 이미지 업로드 기능 구현 #16
2 parents 222add4 + 595709b commit 24e4db1

28 files changed

+788
-50
lines changed

.github/workflows/CD.yml

+3
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ jobs:
4141
spring.data.redis.port: ${{secrets.REDIS_PORT}}
4242
spring.mail.username: ${{secrets.MAIL_USERNAME}}
4343
spring.mail.password: ${{secrets.MAIL_PASSWORD}}
44+
cloud.aws.credentials.accessKey: ${{secrets.S3_ACCESSKEY}}
45+
cloud.aws.credentials.secretKey: ${{secrets.S3_SECRETKEY}}
46+
cloud.aws.s3.bucketName: ${{secrets.S3_BUCKETNAME}}
4447

4548
- name: Grant execute permission for gradlew
4649
run: chmod +x ./gradlew

.github/workflows/CI.yml

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ jobs:
4343
spring.data.redis.port: ${{secrets.REDIS_PORT}}
4444
spring.mail.username: ${{secrets.MAIL_USERNAME}}
4545
spring.mail.password: ${{secrets.MAIL_PASSWORD}}
46+
cloud.aws.credentials.accessKey: ${{secrets.S3_ACCESSKEY}}
47+
cloud.aws.credentials.secretKey: ${{secrets.S3_SECRETKEY}}
48+
cloud.aws.s3.bucketName: ${{secrets.S3_BUCKETNAME}}
4649

4750

4851

build.gradle

+4
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ dependencies {
5353
implementation("org.springframework.boot:spring-boot-starter-data-redis")
5454
//SMTP
5555
implementation 'org.springframework.boot:spring-boot-starter-mail'
56+
//S3
57+
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
58+
// p6spy 로깅 설정
59+
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
5660

5761
}
5862

src/main/java/com/cona/KUsukKusuk/global/exception/HttpExceptionCode.java

+7-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ public enum HttpExceptionCode {
3535
USER_EXIST(HttpStatus.CONFLICT,"이미 존재하는 사용자 입니다."),
3636
EMAIL_ALREADY_EXIST(HttpStatus.CONFLICT,"이미 존재하는 이메일 입니다."),
3737

38-
EMAIL_NOT_SEND(HttpStatus.NOT_FOUND,"이메일이 전송에 실패하였습니다.");
38+
EMAIL_NOT_SEND(HttpStatus.NOT_FOUND,"이메일이 전송에 실패하였습니다."),
39+
IMAGE_UPLOAD_FAILED(HttpStatus.NOT_FOUND, "이미지 업로드에 실패하였습니다."),
40+
SPOT_NOT_FOUND(HttpStatus.NOT_FOUND,"해당 spot ID가 DB에 존재하지 않습니다."),
41+
USER_LOGIN_PERMIT_FAIL(HttpStatus.FORBIDDEN,"로그인한 사용자만 이용할 수 있습니다. "),
42+
USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "해당 사용자가 작성한 글이 아닙니다.");
43+
44+
3945

4046

4147
private final HttpStatus httpStatus;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.cona.KUsukKusuk.global.s3;
2+
3+
import com.amazonaws.auth.AWSCredentials;
4+
import com.amazonaws.auth.AWSStaticCredentialsProvider;
5+
import com.amazonaws.auth.BasicAWSCredentials;
6+
import com.amazonaws.services.s3.AmazonS3;
7+
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
12+
@Configuration
13+
14+
public class S3Config {
15+
@Value("${cloud.aws.credentials.accessKey}")
16+
private String accessKey;
17+
@Value("${cloud.aws.credentials.secretKey}")
18+
private String secretKey;
19+
@Value("${cloud.aws.s3.bucketName}")
20+
private String bucketName;
21+
@Value("${cloud.aws.region.static}")
22+
private String region;
23+
24+
@Bean
25+
public AmazonS3 s3Builder() {
26+
AWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey);
27+
28+
return AmazonS3ClientBuilder.standard()
29+
.withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials))
30+
.withRegion(region).build();
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package com.cona.KUsukKusuk.global.s3;
2+
3+
import com.amazonaws.services.s3.AmazonS3;
4+
import com.amazonaws.services.s3.model.CannedAccessControlList;
5+
import com.amazonaws.services.s3.model.ObjectMetadata;
6+
import com.amazonaws.services.s3.model.PutObjectRequest;
7+
import com.cona.KUsukKusuk.spot.domain.Spot;
8+
import com.cona.KUsukKusuk.user.domain.User;
9+
import java.io.IOException;
10+
import java.util.ArrayList;
11+
import java.util.List;
12+
import java.util.UUID;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.beans.factory.annotation.Value;
15+
import org.springframework.stereotype.Service;
16+
import org.springframework.web.multipart.MultipartFile;
17+
18+
@Service
19+
@RequiredArgsConstructor
20+
public class S3Service {
21+
private final AmazonS3 amazonS3;
22+
23+
@Value("${cloud.aws.s3.bucketName}")
24+
private String bucket;
25+
26+
public String saveFile(MultipartFile multipartFile) throws IOException {
27+
String originalFilename = multipartFile.getOriginalFilename();
28+
29+
ObjectMetadata metadata = new ObjectMetadata();
30+
metadata.setContentLength(multipartFile.getSize());
31+
metadata.setContentType(multipartFile.getContentType());
32+
33+
amazonS3.putObject(bucket, originalFilename, multipartFile.getInputStream(), metadata);
34+
return amazonS3.getUrl(bucket, originalFilename).toString();
35+
}
36+
public String saveProfileImage(MultipartFile profileImage) throws IOException {
37+
String originalFilename = profileImage.getOriginalFilename();
38+
String storedFileName = generateUniqueFileName(originalFilename);
39+
40+
ObjectMetadata metadata = new ObjectMetadata();
41+
metadata.setContentLength(profileImage.getSize());
42+
metadata.setContentType(profileImage.getContentType());
43+
44+
amazonS3.putObject(new PutObjectRequest(bucket, storedFileName, profileImage.getInputStream(), metadata)
45+
.withCannedAcl(CannedAccessControlList.PublicRead));
46+
47+
return amazonS3.getUrl(bucket, storedFileName).toString();
48+
}
49+
public String saveProfileImage(String userId, MultipartFile profileImage) throws IOException {
50+
String folderName = userId + "/"; // 사용자별 폴더 생성
51+
String originalFilename = profileImage.getOriginalFilename();
52+
String storedFileName = folderName + generateUniqueFileName(originalFilename);
53+
54+
ObjectMetadata metadata = new ObjectMetadata();
55+
metadata.setContentLength(profileImage.getSize());
56+
metadata.setContentType(profileImage.getContentType());
57+
58+
amazonS3.putObject(new PutObjectRequest(bucket, storedFileName, profileImage.getInputStream(), metadata)
59+
.withCannedAcl(CannedAccessControlList.PublicRead));
60+
61+
return amazonS3.getUrl(bucket, storedFileName).toString();
62+
63+
}
64+
65+
private String generateUniqueFileName(String originalFilename) {
66+
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
67+
return UUID.randomUUID().toString() + extension;
68+
}
69+
public void deleteProfileImage(User member) {
70+
71+
72+
String profileImage = member.getProfileimage();
73+
if (profileImage != null) {
74+
String key = extractString(profileImage, member.getUserId());
75+
amazonS3.deleteObject(bucket, key);
76+
}
77+
}
78+
private static String extractString(String input, String keyword) {
79+
//사용자명 폴더 포함한 key 값반환하는함수 구현
80+
int keywordIndex = input.indexOf(keyword);
81+
if (keywordIndex != -1) {
82+
return input.substring(keywordIndex);
83+
}
84+
return null;
85+
}
86+
public List<String> uploadImages(List<MultipartFile> files, String userId) throws IOException {
87+
String folderName = userId + "/"; // 사용자별 폴더 생성
88+
List<String> urls = new ArrayList<>();
89+
90+
for (MultipartFile file : files) {
91+
String originalFilename = file.getOriginalFilename();
92+
String storedFileName = folderName + generateUniqueFileName(originalFilename);
93+
94+
ObjectMetadata metadata = new ObjectMetadata();
95+
metadata.setContentLength(file.getSize());
96+
metadata.setContentType(file.getContentType());
97+
98+
amazonS3.putObject(new PutObjectRequest(bucket, storedFileName, file.getInputStream(), metadata)
99+
.withCannedAcl(CannedAccessControlList.PublicRead));
100+
101+
String imageUrl = amazonS3.getUrl(bucket, storedFileName).toString();
102+
urls.add(imageUrl);
103+
}
104+
105+
return urls;
106+
}
107+
108+
public List<String> updateImages(List<MultipartFile> files, String userId) throws IOException {
109+
String folderName = userId + "/"; // 사용자별 폴더 생성
110+
List<String> urls = new ArrayList<>();
111+
112+
for (MultipartFile file : files) {
113+
String originalFilename = file.getOriginalFilename();
114+
String storedFileName = folderName + generateUniqueFileName(originalFilename);
115+
116+
ObjectMetadata metadata = new ObjectMetadata();
117+
metadata.setContentLength(file.getSize());
118+
metadata.setContentType(file.getContentType());
119+
120+
amazonS3.putObject(new PutObjectRequest(bucket, storedFileName, file.getInputStream(), metadata)
121+
.withCannedAcl(CannedAccessControlList.PublicRead));
122+
123+
String imageUrl = amazonS3.getUrl(bucket, storedFileName).toString();
124+
urls.add(imageUrl);
125+
}
126+
127+
return urls;
128+
}
129+
130+
131+
public void deleteSpotImages(Spot spot,User user) {
132+
//현재 url에서 키 값을 추출해서 그대로 해당 키의 버킷 객체를
133+
134+
List<String> imageUrls = spot.getImageUrls();
135+
if(!imageUrls.isEmpty()){
136+
for (int i = 0; i < imageUrls.size(); i++) {
137+
String spoturl = imageUrls.get(i);
138+
String key = extractString(spoturl, user.getUserId());
139+
amazonS3.deleteObject(bucket,key);
140+
}
141+
142+
}
143+
}
144+
145+
}

src/main/java/com/cona/KUsukKusuk/global/security/LoginFilter.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR
8383

8484
String password = customUserDetails.getPassword();
8585
//AT : 6분
86-
String accessToken = jwtUtil.createJwt(username, password, 60*60*100L);
86+
String accessToken = jwtUtil.createJwt(username, password, 60*60*1000L);
8787
//RT : 7일
8888
String refreshToken = jwtUtil.createRefreshToken(username, password, 86400000*7L);
8989

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.cona.KUsukKusuk.profile.controller;
2+
3+
4+
import com.cona.KUsukKusuk.global.response.HttpResponse;
5+
import com.cona.KUsukKusuk.profile.dto.UploadImage;
6+
import com.cona.KUsukKusuk.profile.exception.ImageUploadException;
7+
import com.cona.KUsukKusuk.profile.service.ProfileService;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import java.io.IOException;
11+
import org.springframework.web.bind.annotation.DeleteMapping;
12+
import org.springframework.web.bind.annotation.PutMapping;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RestController;
15+
16+
@RestController
17+
@RequestMapping("/profile-image")
18+
@Tag(name = "프로필 사진 컨트롤러", description = "프로필 이미지 업로드/삭제 컨트롤러 입니다.")
19+
20+
public class ProfileController {
21+
private final ProfileService profileService;
22+
23+
public ProfileController(ProfileService profileService) {
24+
this.profileService = profileService;
25+
}
26+
27+
28+
@DeleteMapping("/delete")
29+
@Operation(summary = "프로필 이미지 삭제", description = "프로필 이미지를 삭제합니다.")
30+
public HttpResponse<String> deleteProfileImage() {
31+
profileService.deleteProfileImage();
32+
return HttpResponse.okBuild("프로필 이미지 삭제가 성공하였습니다.");
33+
}
34+
@PutMapping("/update")
35+
@Operation(summary = "프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드합니다. ")
36+
public HttpResponse<String> updateProfileImage(UploadImage imageDto) {
37+
try {
38+
String imageUrl = profileService.updateProfileImage(imageDto);
39+
return HttpResponse.okBuild(imageUrl);
40+
} catch (IOException e) {
41+
throw new ImageUploadException();
42+
}
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.cona.KUsukKusuk.profile.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.Builder;
5+
import org.springframework.web.multipart.MultipartFile;
6+
7+
@Builder
8+
public record UploadImage(
9+
@Schema(description = "프로필 이미지 변경 요청시에만 'Content-Type': 'multipart/form-data' 으로 요청을 보내야 합니다. ", nullable = false, example = "'Content-Type': 'multipart/form-data'")
10+
MultipartFile profileImage
11+
) {
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.cona.KUsukKusuk.profile.dto;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record UploadImageResponse(
7+
String message,
8+
String s3url
9+
) {
10+
public static UploadImageResponse of(String s3url) {
11+
return UploadImageResponse.builder()
12+
.message("프로필 이미지 등록에 성공하였습니다.")
13+
.s3url(s3url)
14+
.build();
15+
16+
}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.cona.KUsukKusuk.profile.exception;
2+
3+
import com.cona.KUsukKusuk.global.exception.HttpExceptionCode;
4+
import lombok.Getter;
5+
import lombok.Setter;
6+
import org.springframework.http.HttpStatus;
7+
8+
@Getter
9+
@Setter
10+
public class ImageUploadException extends RuntimeException{
11+
private final HttpStatus httpStatus;
12+
13+
public ImageUploadException(HttpExceptionCode exceptionCode) {
14+
super(exceptionCode.getMessage());
15+
this.httpStatus = exceptionCode.getHttpStatus();
16+
}
17+
18+
public ImageUploadException() {
19+
this(HttpExceptionCode.IMAGE_UPLOAD_FAILED);
20+
}
21+
22+
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.cona.KUsukKusuk.profile.exception.handler;
2+
3+
4+
import com.cona.KUsukKusuk.global.response.ErrorResponse;
5+
import com.cona.KUsukKusuk.profile.exception.ImageUploadException;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.core.Ordered;
8+
import org.springframework.core.annotation.Order;
9+
import org.springframework.http.HttpStatus;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.web.bind.annotation.ExceptionHandler;
12+
import org.springframework.web.bind.annotation.ResponseStatus;
13+
import org.springframework.web.bind.annotation.RestControllerAdvice;
14+
15+
@Slf4j
16+
@RestControllerAdvice
17+
@Order(Ordered.HIGHEST_PRECEDENCE)
18+
public class ImageExceptionHandler {
19+
@ExceptionHandler(ImageUploadException.class)
20+
@ResponseStatus(HttpStatus.NOT_FOUND)
21+
public ResponseEntity<ErrorResponse> imageUploadFailedExceptionHandler(ImageUploadException e) {
22+
return ResponseEntity.status(e.getHttpStatus())
23+
.body(ErrorResponse.from(e.getHttpStatus(), e.getMessage()));
24+
}
25+
26+
}

0 commit comments

Comments
 (0)