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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.teamEWSN.gitdeun.common.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

import java.util.Arrays;

@Slf4j
@Aspect
@Component
public class GlobalLoggingAspect {

@Pointcut("execution(* com.teamEWSN.gitdeun..*(..))")
private void globalPointcut() {

}

@Before("globalPointcut()")
public void logBeforeMethod(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().toShortString();
Object[] args = joinPoint.getArgs();

log.debug("[์‹คํ–‰ ๋ฉ”์„œ๋“œ]: {} [๋งค๊ฐœ๋ณ€์ˆ˜]: {}", methodName, Arrays.toString(args));
}

@AfterReturning(value = "globalPointcut()", returning = "result")
public void logAfterMethod(JoinPoint joinPoint, Object result) {
String methodName = joinPoint.getSignature().toShortString();

log.debug("[์ข…๋ฃŒ ๋ฉ”์„œ๋“œ]: {} [๋ฐ˜ํ™˜๊ฐ’]: {}", methodName, result);
}

@AfterThrowing(value = "globalPointcut()", throwing = "ex")
public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) {
String methodName = joinPoint.getSignature().toShortString();

log.error("[์˜ˆ์™ธ ๋ฐœ์ƒ ๋ฉ”์„œ๋“œ]: {} [์˜ˆ์™ธ]: {}", methodName, ex.getMessage());
}
}
22 changes: 14 additions & 8 deletions src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,20 @@ public enum ErrorCode {


// S3 ํŒŒ์ผ ๊ด€๋ จ
FILE_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-001", "์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•œ ํŒŒ์ผ ๊ฐœ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."),
FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-002", "ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ํ—ˆ์šฉ๋œ ์šฉ๋Ÿ‰์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."),
INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE-003", "์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค."),
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-004", "์š”์ฒญํ•œ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."),
FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-006", "ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."),
INVALID_FILE_LIST(HttpStatus.BAD_REQUEST, "FILE-006", "ํŒŒ์ผ ๋ชฉ๋ก์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."),
INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "FILE-007", "ํŒŒ์ผ ๊ฒฝ๋กœ๋‚˜ ์ด๋ฆ„์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."),
FILE_MOVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-008", "ํŒŒ์ผ ์ด๋™ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.");
// Client Errors (4xx)
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "์š”์ฒญํ•œ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."),
INVALID_FILE_LIST(HttpStatus.BAD_REQUEST, "FILE-002", "ํŒŒ์ผ ๋ชฉ๋ก์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."),
INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "FILE-003", "ํŒŒ์ผ ๊ฒฝ๋กœ๋‚˜ ์ด๋ฆ„์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."),
INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE-004", "์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค."),
FILE_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-005", "์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•œ ํŒŒ์ผ ๊ฐœ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."),
FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-006", "ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ํ—ˆ์šฉ๋œ ์šฉ๋Ÿ‰์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."),
INVALID_S3_URL(HttpStatus.BAD_REQUEST, "FILE-007", "S3 URL ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."),

// Server Errors (5xx)
FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-501", "ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."),
FILE_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-502", "ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."),
FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-503", "ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."),
FILE_MOVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-504", "ํŒŒ์ผ ์ด๋™ ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.");



Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.teamEWSN.gitdeun.common.s3.controller;

import com.teamEWSN.gitdeun.common.exception.ErrorCode;
import com.teamEWSN.gitdeun.common.exception.GlobalException;
import com.teamEWSN.gitdeun.common.s3.service.S3BucketService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

@RestController
@RequestMapping("/api/s3/bucket")
@RequiredArgsConstructor
public class S3BucketController {

private final S3BucketService s3BucketService;
private static final int MAX_FILE_COUNT = 10;

@PostMapping("/upload")
public ResponseEntity<List<String>> uploadFiles(
@RequestParam("files") List<MultipartFile> files,
@RequestParam("path") String path
) {
// FILE-005: ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•œ ํŒŒ์ผ ๊ฐœ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค.
if (files.size() > MAX_FILE_COUNT) {
throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED);
}

List<String> fileUrls = s3BucketService.upload(files, path);
return ResponseEntity.ok(fileUrls);
}

@DeleteMapping("/delete")
public ResponseEntity<Void> deleteFiles(@RequestBody List<String> urls) {
s3BucketService.remove(urls);
return ResponseEntity.noContent().build();
}

@GetMapping("/download")
public ResponseEntity<Resource> downloadFile(@RequestParam("url") String url) {
Resource resource = s3BucketService.download(url);
String filename = URLEncoder.encode(resource.getFilename(), StandardCharsets.UTF_8);

return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.body(resource);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.teamEWSN.gitdeun.common.s3.service;

import com.teamEWSN.gitdeun.common.exception.ErrorCode;
import com.teamEWSN.gitdeun.common.exception.GlobalException;
import io.awspring.cloud.s3.S3Resource;
import io.awspring.cloud.s3.S3Template;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.exception.SdkException;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class S3BucketService {

private final S3Template s3Template;

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

public List<String> upload(List<MultipartFile> files, String path) {
// FILE-002: ํŒŒ์ผ ๋ชฉ๋ก์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
if (files == null || files.stream().allMatch(MultipartFile::isEmpty)) {
throw new GlobalException(ErrorCode.INVALID_FILE_LIST);
}

List<String> uploadedUrls = new ArrayList<>();
for (MultipartFile file : files) {
if (file.isEmpty()) continue;

if (!isValidFileType(file.getOriginalFilename())) {
// FILE-004: ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค.
throw new GlobalException(ErrorCode.INVALID_FILE_TYPE);
}

String fullPath = generateValidPath(path) + createUniqueFileName(file.getOriginalFilename());

try {
S3Resource s3Resource = s3Template.upload(bucketName, fullPath, file.getInputStream());
uploadedUrls.add(s3Resource.getURL().toString());
} catch (IOException | SdkException e) {
// FILE-501: ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.
throw new GlobalException(ErrorCode.FILE_UPLOAD_FAILED);
}
}
return uploadedUrls;
}

public void remove(List<String> urls) {
// FILE-002: ํŒŒ์ผ ๋ชฉ๋ก์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
if (urls == null || urls.isEmpty()) {
throw new GlobalException(ErrorCode.INVALID_FILE_LIST);
}

for (String url : urls) {
String key = extractKeyFromUrl(url);

try {
if (!s3Template.objectExists(bucketName, key)) {
// FILE-001: ์š”์ฒญํ•œ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
throw new GlobalException(ErrorCode.FILE_NOT_FOUND);
}
s3Template.deleteObject(bucketName, key);
} catch (SdkException e) {
// FILE-503: ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.
throw new GlobalException(ErrorCode.FILE_DELETE_FAILED);
}
}
}

public S3Resource download(String url) {
String key = extractKeyFromUrl(url);

try {
if (!s3Template.objectExists(bucketName, key)) {
// FILE-001: ์š”์ฒญํ•œ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
throw new GlobalException(ErrorCode.FILE_NOT_FOUND);
}
return s3Template.download(bucketName, key);
} catch (SdkException e) {
// FILE-502: ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.
throw new GlobalException(ErrorCode.FILE_DOWNLOAD_FAILED);
}
}

private String extractKeyFromUrl(String url) {
try {
String urlPrefix = "https://" + bucketName + ".s3.";
int startIndex = url.indexOf(urlPrefix);
int keyStartIndex = url.indexOf('/', startIndex + urlPrefix.length());
return url.substring(keyStartIndex + 1);
} catch (Exception e) {
// FILE-007: S3 URL ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
throw new GlobalException(ErrorCode.INVALID_S3_URL);
}
}

private String generateValidPath(String path) {
if (path == null || path.trim().isEmpty()) {
return "";
}
if (path.contains("..")) {
// FILE-003: ํŒŒ์ผ ๊ฒฝ๋กœ๋‚˜ ์ด๋ฆ„์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
throw new GlobalException(ErrorCode.INVALID_FILE_PATH);
}
return path.replaceAll("^/+|/+$", "") + "/";
}

private String createUniqueFileName(String originalFileName) {
String extension = StringUtils.getFilenameExtension(originalFileName);
return UUID.randomUUID() + "." + extension;
}

private boolean isValidFileType(String filename) {
if (filename == null) return false;
String extension = StringUtils.getFilenameExtension(filename.toLowerCase());
List<String> allowedExtensions = List.of("jpg", "jpeg", "png", "gif", "pdf", "docs"); // ํ—ˆ์šฉ ํ™•์žฅ์ž
return allowedExtensions.contains(extension);
}
}
28 changes: 28 additions & 0 deletions src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.teamEWSN.gitdeun.common.util;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
@MappedSuperclass
public class BaseEntity {

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(0)")
private LocalDateTime createdAt;

@LastModifiedDate
@Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(0)")
private LocalDateTime updatedAt;

}
21 changes: 12 additions & 9 deletions src/main/resources/application-s3Bucket.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
aws:
s3:
bucket:
stack.auto: false
name: gitdeun
region: ap-northeast-2
credentials:
accessKey: ${S3_ACCESS_KEY}
secretAccessKey: ${S3_SECRET_KEY}
cloud:
aws:
credentials:
access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY}
region:
static: ap-northeast-2
stack:
auto: false # CloudFormation ์Šคํƒ ์ž๋™ ์ƒ์„ฑ์„ ๋น„ํ™œ์„ฑํ™”
s3:
bucket:
name: gitdeun

spring:
config:
Expand Down