Skip to content

Commit 3923922

Browse files
authored
[T3-196] Presigned url 발급 및 제보 이미지 등록 (#76)
* feat: presignedUrl 발급을 위한 로직 구현 * feat: 이미지 등록 API 구현 * refactor: 제보 등록 API에 이미지 url도 등록
1 parent 6d471de commit 3923922

11 files changed

Lines changed: 197 additions & 13 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package bitnagil.bitnagil_backend.file.controller;
2+
3+
import bitnagil.bitnagil_backend.file.controller.spec.FileSpec;
4+
import bitnagil.bitnagil_backend.file.request.PresignedUrlRequest;
5+
import bitnagil.bitnagil_backend.file.service.FileService;
6+
import bitnagil.bitnagil_backend.global.response.CustomResponseDto;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.web.bind.annotation.*;
9+
10+
import java.util.List;
11+
import java.util.Map;
12+
13+
@RestController
14+
@RequiredArgsConstructor
15+
@RequestMapping(value = "/api/v2/file")
16+
public class FileController implements FileSpec {
17+
private final FileService fileService;
18+
19+
// S3 Presigned URL 발급 API
20+
@PostMapping("/presigned-urls")
21+
public CustomResponseDto<Map<String, String>> getPresignedUrls(@RequestBody List<PresignedUrlRequest> requests) {
22+
return CustomResponseDto.from(fileService.getPresignedUrls(requests));
23+
}
24+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package bitnagil.bitnagil_backend.file.controller.spec;
2+
3+
import bitnagil.bitnagil_backend.file.request.PresignedUrlRequest;
4+
import bitnagil.bitnagil_backend.global.response.CustomResponseDto;
5+
import bitnagil.bitnagil_backend.global.swagger.ApiTags;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
9+
import java.util.List;
10+
import java.util.Map;
11+
12+
@Tag(name = ApiTags.FILE)
13+
public interface FileSpec {
14+
15+
@Operation(summary = "S3 Presigned URL 발급")
16+
CustomResponseDto<Map<String, String>> getPresignedUrls(List<PresignedUrlRequest> requests);
17+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package bitnagil.bitnagil_backend.file.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotNull;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
@Getter
11+
@Builder
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
@Schema(description = "Presigned URL 발급 요청 DTO")
15+
public class PresignedUrlRequest {
16+
17+
@Schema(description = "파일명을 제외한 접두사",
18+
example = "report를 prefix로 사용할 경우 실제 S3에 저장되는 경로는 report/image.png가 됩니다.")
19+
private String prefix;
20+
21+
@Schema(description = "파일명",
22+
example = "image.png",
23+
required = true)
24+
@NotNull
25+
private String fileName;
26+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package bitnagil.bitnagil_backend.file.service;
2+
3+
4+
import bitnagil.bitnagil_backend.file.request.PresignedUrlRequest;
5+
import com.amazonaws.HttpMethod;
6+
import com.amazonaws.services.s3.AmazonS3;
7+
import com.amazonaws.services.s3.Headers;
8+
import com.amazonaws.services.s3.model.CannedAccessControlList;
9+
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.beans.factory.annotation.Value;
12+
import org.springframework.stereotype.Service;
13+
14+
import java.net.URL;
15+
import java.util.*;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class FileService {
20+
@Value("${cloud.aws.s3.bucket}")
21+
private String bucket;
22+
23+
private final AmazonS3 amazonS3;
24+
25+
public Map<String, String> getPresignedUrls(List<PresignedUrlRequest> requests) {
26+
Map<String, String> responseMap = new HashMap<>();
27+
28+
for (PresignedUrlRequest request : requests) {
29+
String prefix = request.getPrefix() != null ? request.getPrefix() : "";
30+
String fileName = request.getFileName();
31+
32+
if (!prefix.isEmpty()) {
33+
fileName = createPath(prefix, fileName);
34+
}
35+
36+
GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePresignedUrlRequest(bucket, fileName);
37+
URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest);
38+
39+
responseMap.put(fileName, url.toString());
40+
}
41+
return responseMap;
42+
}
43+
44+
// S3 Presigned URL 생성
45+
private GeneratePresignedUrlRequest getGeneratePresignedUrlRequest(String bucket, String fileName) {
46+
GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, fileName)
47+
.withMethod(HttpMethod.PUT)
48+
.withExpiration(getPresignedUrlExpiration());
49+
50+
generatePresignedUrlRequest.addRequestParameter(
51+
Headers.S3_CANNED_ACL,
52+
CannedAccessControlList.PublicRead.toString()
53+
);
54+
55+
return generatePresignedUrlRequest;
56+
}
57+
58+
// Presigned URL 만료 시간 설정 (2분)
59+
private Date getPresignedUrlExpiration() {
60+
Date expiration = new Date();
61+
long expTimeMillis = expiration.getTime();
62+
expTimeMillis += 1000 * 60 * 2;
63+
expiration.setTime(expTimeMillis);
64+
65+
return expiration;
66+
}
67+
68+
private String createFileId() {
69+
return UUID.randomUUID().toString();
70+
}
71+
72+
private String createPath(String prefix, String fileName) {
73+
String fileId = createFileId();
74+
return String.format("%s/%s", prefix, fileId + "-" + fileName);
75+
}
76+
}
77+

src/main/java/bitnagil/bitnagil_backend/global/errorcode/ErrorCode.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,10 @@ public enum ErrorCode {
8383

8484
// 기타 에러 코드
8585
JSON_CONVERT_ERROR("ETC001", HttpStatus.INTERNAL_SERVER_ERROR, "List를 JSON 문자열로 변환하는 데 실패했습니다."),
86-
JSON_PARSE_ERROR("ETC001", HttpStatus.INTERNAL_SERVER_ERROR, "JSON 문자열을 List<String> 으로 변환하는 데 실패했습니다.");
87-
86+
JSON_PARSE_ERROR("ETC001", HttpStatus.INTERNAL_SERVER_ERROR, "JSON 문자열을 List<String> 으로 변환하는 데 실패했습니다."),
8887

88+
// 제보 관련 에러코드
89+
NOT_FOUND_REPORT("RP001", HttpStatus.NOT_FOUND, "존재하지 않는 제보입니다.");
8990

9091

9192

src/main/java/bitnagil/bitnagil_backend/global/swagger/ApiTags.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ public class ApiTags {
1414
public static final String RECOMMENDED_ROUTINE = "추천 루틴 API";
1515
public static final String APP_VERSION = "앱 버전 API";
1616
public static final String REPORT = "제보 API";
17+
public static final String FILE = "파일 API";
1718
}

src/main/java/bitnagil/bitnagil_backend/report/controller/ReportController.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,19 @@ public class ReportController implements ReportSpec {
2121
private final ReportService reportService;
2222

2323
// 제보 등록 API
24-
@PostMapping(path = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
25-
public CustomResponseDto<Object> registerReport(@CurrentUser User user,
26-
@RequestPart("request") ReportRegisterRequest request,
27-
@RequestPart(value = "images", required = false) List<MultipartFile> images) {
28-
reportService.registerReport(user, request, images);
24+
@PostMapping()
25+
public CustomResponseDto<Long> registerReport(@CurrentUser User user,
26+
@RequestBody ReportRegisterRequest request) {
27+
return CustomResponseDto.from(reportService.registerReport(user, request));
28+
}
29+
30+
/* 추후에 변경을 고려해서 소스만 남겨놓음
31+
// 제보 파일 저장 API
32+
@PutMapping(value = "/{reportId}/images")
33+
public CustomResponseDto<Object> updateImages(@PathVariable Long reportId,
34+
@RequestParam List<String> urls) {
35+
reportService.updateImages(reportId, urls);
2936
return CustomResponseDto.from(null);
3037
}
38+
*/
3139
}

src/main/java/bitnagil/bitnagil_backend/report/controller/spec/ReportSpec.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
import bitnagil.bitnagil_backend.user.domain.User;
77
import io.swagger.v3.oas.annotations.Operation;
88
import io.swagger.v3.oas.annotations.tags.Tag;
9-
import org.springframework.web.bind.annotation.RequestPart;
10-
import org.springframework.web.multipart.MultipartFile;
9+
import org.springframework.web.bind.annotation.RequestBody;
1110

1211
import java.util.List;
1312

@@ -16,5 +15,11 @@ public interface ReportSpec {
1615

1716
@Operation(summary = "제보등록",
1817
description = "회원이 제보를 등록합니다.")
19-
CustomResponseDto<Object> registerReport(User user, @RequestPart ReportRegisterRequest request, @RequestPart List<MultipartFile> images);
18+
CustomResponseDto<Long> registerReport(User user, @RequestBody ReportRegisterRequest request);
19+
20+
/* 추후에 변경을 고려해서 소스만 남겨놓음
21+
@Operation(summary = "제보 이미지 등록",
22+
description = "presigned URL을 통해 업로드된 이미지들의 URL을 제보 이미지로 등록합니다.")
23+
CustomResponseDto<Object> updateImages(Long reportId, List<String> urls);
24+
*/
2025
}

src/main/java/bitnagil/bitnagil_backend/report/domain/Report.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
*/
2424
@NoArgsConstructor(access = AccessLevel.PROTECTED)
2525
@Entity
26+
@Getter
2627
@SQLDelete(sql = "UPDATE report SET deleted_at = NOW() WHERE report_id = ?")
2728
@Where(clause = "deleted_at IS NULL")
2829
public class Report extends BaseTimeEntity {
2930
@Id
3031
@GeneratedValue(strategy = GenerationType.IDENTITY)
3132
private Long reportId; // 제보ID
3233

34+
@Enumerated(EnumType.STRING)
35+
@Column(columnDefinition = "varchar(40)")
3336
private ReportCategory reportCategory; // 제보카테고리
3437

3538
@Convert(converter = StringListConverter.class)
@@ -63,4 +66,8 @@ public Report(ReportCategory reportCategory, List<String> reportImageUrls, Strin
6366
this.longitude = longitude;
6467
this.user = user;
6568
}
69+
70+
public void updateReportImageUrls(List<String> urls) {
71+
this.reportImageUrls = urls;
72+
}
6673
}

src/main/java/bitnagil/bitnagil_backend/report/request/ReportRegisterRequest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import lombok.NoArgsConstructor;
1010

1111
import java.math.BigDecimal;
12+
import java.util.List;
1213

1314
@Getter
1415
@Builder
@@ -33,6 +34,11 @@ public class ReportRegisterRequest {
3334
@NotNull
3435
private ReportCategory reportCategory;
3536

37+
@Schema(description = "제보 이미지 URL 리스트",
38+
example = "[\"https://example.com/report/image1.jpg\", \"https://example.com/report/image2.jpg\"]" + "\n"
39+
+"S3에 업로드 시 url에 https://example-bucket.s3.amazonaws.com/report/2d84%A4%EC%9D%BC.jpg?x-amz-acl=... 와 같은 쿼리파아미터가 붙어있는데, 이 쿼리파라미터는 모두 잘라서 앞부분에 해당하는 url만 요청해주세요.")
40+
private List<String> reportImageUrls;
41+
3642
@Schema(description = "제보 위치",
3743
example = "서울시 강남구 삼성동",
3844
required = true)

0 commit comments

Comments
 (0)