Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/main/java/com/likelion/danchu/global/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.likelion.danchu.global.config;

import jakarta.annotation.PostConstruct;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSCredentialsProvider;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;

import lombok.Getter;

@Getter
@Configuration
public class S3Config {

private AWSCredentials awsCredentials;

@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;

@Value("${cloud.aws.s3.bucket}")
private String bucket;

@Value("${cloud.aws.s3.path.user}")
private String userFolder;

@Value("${cloud.aws.s3.path.store}")
private String storeFolder;

@Value("${cloud.aws.s3.path.reward}")
private String rewardFolder;

@Value("${cloud.aws.s3.path.menu}")
private String menuFolder;

@PostConstruct
public void init() {
this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
}

@Bean
public AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(awsCredentialsProvider())
.build();
}

@Bean
public AWSCredentialsProvider awsCredentialsProvider() {
return new AWSStaticCredentialsProvider(awsCredentials);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.likelion.danchu.global.s3.Converter;

import java.lang.reflect.Type;

import org.springframework.http.MediaType;
import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

@Component
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {

/** "Content-Type: multipart/form-data" 헤더를 지원하는 HTTP 요청 변환기 */
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
}

@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return false;
}

@Override
public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
return false;
}

@Override
protected boolean canWrite(MediaType mediaType) {
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.likelion.danchu.global.s3.controller;

import java.util.List;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.likelion.danchu.global.response.BaseResponse;
import com.likelion.danchu.global.s3.dto.S3Response;
import com.likelion.danchu.global.s3.entity.PathName;
import com.likelion.danchu.global.s3.service.S3Service;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/s3/images")
@Tag(name = "S3", description = "S3 이미지 파일 관리 API")
public class S3Controller {

private final S3Service s3Service;

@Operation(
summary = "이미지 업로드",
description =
"""
Multipart 형식의 이미지를 S3에 업로드합니다.

- **업로드할 위치는 pathName**으로 지정합니다.
- 응답에는 S3에 저장된 이미지 URL과 파일명이 포함됩니다.
- 파일은 이미지 형식만 허용되며, 5MB 이하만 업로드 가능합니다.
""")
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<BaseResponse<S3Response>> uploadImage(
@RequestParam PathName pathName, @RequestParam MultipartFile file) {

S3Response s3Response = s3Service.uploadImage(pathName, file);
return ResponseEntity.ok(BaseResponse.success("이미지 업로드에 성공했습니다.", s3Response));
}

@Operation(
summary = "경로별 이미지 목록 조회",
description =
"""
지정된 **pathName 경로 하위**에 존재하는 모든 이미지 URL을 조회합니다.

- S3 내부에 저장된 전체 이미지 URL 목록을 반환합니다.
- 삭제나 갤러리 구현 시 유용하게 활용할 수 있습니다.
""")
@GetMapping
public ResponseEntity<BaseResponse<List<S3Response>>> listFiles(@RequestParam PathName pathName) {
List<S3Response> files = s3Service.getAllFiles(pathName);
return ResponseEntity.ok(BaseResponse.success("파일 목록 조회에 성공했습니다.", files));
}

@Operation(
summary = "이미지 URL 기반 이미지 삭제",
description =
"""
S3에 저장된 이미지의 URL을 기반으로 파일을 삭제합니다.

- **https://{bucket}.s3.{region}.amazonaws.com/{path}/{filename}** 형식의 전체 URL이 필요합니다.
- URL에서 keyName을 추출하여 삭제합니다.
""")
@DeleteMapping
public ResponseEntity<BaseResponse<String>> deleteByUrl(@RequestParam String url) {
s3Service.deleteByUrl(url);
return ResponseEntity.ok(BaseResponse.success("파일 삭제에 성공했습니다."));
}

@Operation(
summary = "파일명 기반 이미지 삭제",
description =
"""
파일명을 기준으로 S3에 저장된 이미지를 삭제합니다.

- **삭제할 위치는 pathName, 삭제 대상은 **fileName**으로 지정합니다.
- 실제 삭제 대상은 **{pathName}/{fileName}** 경로에 해당하는 파일입니다.
""")
@DeleteMapping("/{pathName}/{fileName}")
public ResponseEntity<BaseResponse<String>> deleteFile(
@PathVariable PathName pathName, @PathVariable String fileName) {
s3Service.deleteFile(pathName, fileName);
return ResponseEntity.ok(BaseResponse.success("파일 삭제에 성공했습니다."));
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/likelion/danchu/global/s3/dto/S3Response.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.likelion.danchu.global.s3.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
@Schema(title = "S3Response DTO", description = "이미지 업로드에 대한 응답 반환")
public class S3Response {

@Schema(description = "이미지 이름", example = "abc123.png")
private String fileName;

@Schema(description = "이미지 URL", example = "https://~")
private String imageUrl;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.likelion.danchu.global.s3.entity;

public enum PathName {
USER,
STORE,
REWARD,
MENU
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.likelion.danchu.global.s3.exception;

import org.springframework.http.HttpStatus;

import com.likelion.danchu.global.exception.model.BaseErrorCode;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum S3ErrorCode implements BaseErrorCode {
FILE_NOT_FOUND("IMG4001", "존재하지 않는 이미지입니다.", HttpStatus.NOT_FOUND),
FILE_SIZE_INVALID("IMG4002", "파일 크기는 5MB를 초과할 수 없습니다.", HttpStatus.BAD_REQUEST),
FILE_TYPE_INVALID("IMG4003", "이미지 파일만 업로드 가능합니다.", HttpStatus.BAD_REQUEST),
UNSUPPORTED_CONTENT_TYPE("IMG4004", "지원하지 않는 이미지 형식입니다.", HttpStatus.BAD_REQUEST),
FILE_NAME_MISSING("IMG4006", "파일 이름이 누락되었습니다.", HttpStatus.BAD_REQUEST),
INVALID_BASE64("IMG4005", "잘못된 Base64값 입니다.", HttpStatus.BAD_REQUEST),

FILE_SERVER_ERROR("IMG5001", "이미지 처리 중 서버 에러, 관리자에게 문의 바랍니다.", HttpStatus.INTERNAL_SERVER_ERROR),
S3_CONNECTION_FAILED("IMG5002", "S3 연결 실패 또는 인증 오류입니다.", HttpStatus.INTERNAL_SERVER_ERROR),
IO_EXCEPTION("IMG5003", "이미지 업로드 중 입출력 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);

private final String code;
private final String message;
private final HttpStatus status;
}
32 changes: 32 additions & 0 deletions src/main/java/com/likelion/danchu/global/s3/mapper/S3Mapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.likelion.danchu.global.s3.mapper;

import java.util.List;
import java.util.stream.Collectors;

import org.springframework.stereotype.Component;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.S3ObjectSummary;
import com.likelion.danchu.global.config.S3Config;
import com.likelion.danchu.global.s3.dto.S3Response;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class S3Mapper {

private final AmazonS3 amazonS3;
private final S3Config s3Config;

public S3Response toResponse(String keyName) {
String url = amazonS3.getUrl(s3Config.getBucket(), keyName).toString();
String fileName =
keyName.contains("/") ? keyName.substring(keyName.lastIndexOf("/") + 1) : keyName;
return S3Response.builder().fileName(fileName).imageUrl(url).build();
}

public List<S3Response> toResponseList(List<S3ObjectSummary> summaries) {
return summaries.stream().map(obj -> toResponse(obj.getKey())).collect(Collectors.toList());
}
}
Loading