Skip to content

Commit caf6f95

Browse files
제보 기능 릴리즈 (#79)
* chore: 부하테스트를 위해 운영환경과 유사하게 메모리 설정 * chore: submodule update * chore: 개발 환경 ecs 메모리 800으로 변경 * chore: cpu 코어 1코어로 증가 * chore: submodule update * chore: submodule update * chore: submodule update * feat: 유저 엔티티 탈퇴 사유 추가 * feat: 탈퇴 사유 업데이트 로직 추가 * feat: 회원탈퇴 API 수정 * feat: 제보 엔티티 설정 * feat: report 엔티티 db flyway 설정 * feat: 제보 등록 API * feat: multipart 타입을 swagger에서 사용하기 위한 컨버터 구현 * feat: ReportRepository 생성 * [T3-196] Presigned url 발급 및 제보 이미지 등록 (#76) * feat: presignedUrl 발급을 위한 로직 구현 * feat: 이미지 등록 API 구현 * refactor: 제보 등록 API에 이미지 url도 등록 * refactor: API 엔드포인트 복수형으로 변경 * feat: 제보 엔티티에 reportStatus 컬럼 추가 * feat: flyway report 엔티티 업데이트 * feat: 전체 제보 목록 조회 API 구현 * fix: 누락된 reportId 추가 및 builder 매핑 오류 수정 * fix: 누락된 Transactional 옵션 추가 * feat: 제보 기록 상세 조회 API * refactor: 스웨거 스펙 수정 * fix: 제보 상세 조회 Response에 날짜 필드 추가 --------- Co-authored-by: thisishwan2 <feel000617@gmail.com> Co-authored-by: thisishwan2 <112103038+thisishwan2@users.noreply.github.com>
1 parent c2213c0 commit caf6f95

28 files changed

Lines changed: 737 additions & 9 deletions

config

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/files")
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ public class ApiTags {
1313
public static final String EMOTION_MARBLE = "감정구슬 API";
1414
public static final String RECOMMENDED_ROUTINE = "추천 루틴 API";
1515
public static final String APP_VERSION = "앱 버전 API";
16+
public static final String REPORT = "제보 API";
17+
public static final String FILE = "파일 API";
1618
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package bitnagil.bitnagil_backend.global.utils;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import org.springframework.http.MediaType;
5+
import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter;
6+
import org.springframework.stereotype.Component;
7+
8+
import java.lang.reflect.Type;
9+
10+
// Swagger 에서 Multipart/form-data 전송시 Content-Type 'application/octet-stream'으로 타입으로 인식되는 오류를 해결하기 위한 컨버터
11+
@Component
12+
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
13+
14+
/**
15+
* "Content-Type: multipart/form-data" 헤더를 지원하는 HTTP 요청 변환기
16+
* Multipart 요청을 처리하기 위한 특별한 컨버터로, 파일과 JSON 데이터를 동시에 처리할 수 있게 해준다.
17+
*/
18+
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
19+
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
20+
}
21+
22+
@Override
23+
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
24+
return false;
25+
}
26+
27+
@Override
28+
public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
29+
return false;
30+
}
31+
32+
@Override
33+
protected boolean canWrite(MediaType mediaType) {
34+
return false;
35+
}
36+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package bitnagil.bitnagil_backend.report.controller;
2+
3+
import bitnagil.bitnagil_backend.global.annotation.CurrentUser;
4+
import bitnagil.bitnagil_backend.global.response.CustomResponseDto;
5+
import bitnagil.bitnagil_backend.report.controller.spec.ReportSpec;
6+
import bitnagil.bitnagil_backend.report.request.ReportRegisterRequest;
7+
import bitnagil.bitnagil_backend.report.response.ReportDetailInfoResponse;
8+
import bitnagil.bitnagil_backend.report.response.ReportInfoResponse;
9+
import bitnagil.bitnagil_backend.report.service.ReportService;
10+
import bitnagil.bitnagil_backend.user.domain.User;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.web.bind.annotation.*;
13+
14+
@RestController
15+
@RequiredArgsConstructor
16+
@RequestMapping(value = "/api/v2/reports")
17+
public class ReportController implements ReportSpec {
18+
19+
private final ReportService reportService;
20+
21+
// 제보 등록 API
22+
@PostMapping()
23+
public CustomResponseDto<Long> registerReport(@CurrentUser User user,
24+
@RequestBody ReportRegisterRequest request) {
25+
return CustomResponseDto.from(reportService.registerReport(user, request));
26+
}
27+
28+
// 제보 목록 조회 API
29+
@GetMapping()
30+
public CustomResponseDto<ReportInfoResponse> getAllReportInfo(@CurrentUser User user) {
31+
return CustomResponseDto.from(reportService.getAllReportInfo(user));
32+
}
33+
34+
// 제보 상세 조회 API
35+
@GetMapping("/{reportId}")
36+
public CustomResponseDto<ReportDetailInfoResponse> getReportDetailInfo(@CurrentUser User user,
37+
@PathVariable Long reportId) {
38+
return CustomResponseDto.from(reportService.getReportDetailInfo(user, reportId));
39+
}
40+
41+
/* 추후에 변경을 고려해서 소스만 남겨놓음
42+
// 제보 파일 저장 API
43+
@PutMapping(value = "/{reportId}/images")
44+
public CustomResponseDto<Object> updateImages(@PathVariable Long reportId,
45+
@RequestParam List<String> urls) {
46+
reportService.updateImages(reportId, urls);
47+
return CustomResponseDto.from(null);
48+
}
49+
*/
50+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package bitnagil.bitnagil_backend.report.controller.spec;
2+
3+
import bitnagil.bitnagil_backend.global.annotation.CurrentUser;
4+
import bitnagil.bitnagil_backend.global.response.CustomResponseDto;
5+
import bitnagil.bitnagil_backend.global.swagger.ApiTags;
6+
import bitnagil.bitnagil_backend.report.request.ReportRegisterRequest;
7+
import bitnagil.bitnagil_backend.report.response.ReportDetailInfoResponse;
8+
import bitnagil.bitnagil_backend.report.response.ReportInfoResponse;
9+
import bitnagil.bitnagil_backend.user.domain.User;
10+
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.media.Content;
12+
import io.swagger.v3.oas.annotations.media.ExampleObject;
13+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
14+
import io.swagger.v3.oas.annotations.tags.Tag;
15+
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PathVariable;
18+
import org.springframework.web.bind.annotation.RequestBody;
19+
20+
import java.util.List;
21+
22+
@Tag(name = ApiTags.REPORT)
23+
public interface ReportSpec {
24+
25+
@ApiResponse(responseCode = "200", useReturnTypeSchema = true, content =
26+
@Content(mediaType = "application/json", examples = @ExampleObject(name = "성공 예시", value =
27+
"{\n \"code\": \"OK\",\n \"message\": \"등록되었습니다.\",\n \"data\": 1\n}")))
28+
@Operation(summary = "제보등록",
29+
description = "회원이 제보를 등록합니다.\n\n"
30+
+ "발급된 presigned url 전체를 reportImageUrl에 담아서 보내주세요.\n\n"
31+
+ "queryParam은 빼고 담아주세요.")
32+
CustomResponseDto<Long> registerReport(User user, ReportRegisterRequest request);
33+
34+
@Operation(summary = "전체 제보 목록 조회",
35+
description = "전체 제보 목록을 조회합니다.")
36+
CustomResponseDto<ReportInfoResponse> getAllReportInfo(User user);
37+
38+
@Operation(summary = "제보 기록 상세 조회",
39+
description = "제보 기록 상세 정보를 조회합니다.")
40+
CustomResponseDto<ReportDetailInfoResponse> getReportDetailInfo(User user, Long reportId);
41+
42+
43+
/* 추후에 변경을 고려해서 소스만 남겨놓음
44+
@Operation(summary = "제보 이미지 등록",
45+
description = "presigned URL을 통해 업로드된 이미지들의 URL을 제보 이미지로 등록합니다.")
46+
CustomResponseDto<Object> updateImages(Long reportId, List<String> urls);
47+
*/
48+
}

0 commit comments

Comments
 (0)