Skip to content

Commit 80d39f6

Browse files
tomchaccomtomchccom
andauthored
feat: 모임 생성시 S3 로 이미지 저장하기 (#17)
* chore : AWS S3 의존성 추가 * feat : S3 환경변수 추가 * feat : S3 설정 파일 추가 * feat: S3 서비스, 컨트롤러 스켈레톤 코드 추가 * feat: 파일 업로드 로직, API 추가 * feat: 모임 이미지 업로드 로직 추가 * feat: imageUrl 필드 및 set메소드 추가 * feat: 이미지 여부를 선택할 수 있도록 다른 타입의 API 추가 * feat: S3 업로드 예외처리 추가 * feat: 이미지 여부는 클라이언트에서 선택하도록 수정 * docs: 스웨거 명세 정리 --------- Co-authored-by: tomchccom <dreamkms2014@gmail.com>
1 parent da7496d commit 80d39f6

10 files changed

Lines changed: 407 additions & 5 deletions

File tree

backend/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ dependencies {
3939
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 파싱용
4040
implementation 'org.springframework.boot:spring-boot-starter-validation'
4141
implementation 'io.github.cdimascio:dotenv-java:3.2.0'
42-
42+
implementation 'software.amazon.awssdk:s3:2.25.19'
4343

4444

4545

backend/src/main/java/com/example/tomo/Moim/Moim.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public class Moim {
3434
@Lob
3535
private String description;
3636

37+
private String imageUrl;
38+
3739
public Moim() {
3840
}
3941

@@ -64,4 +66,8 @@ public void addPromise(Promise promise) {
6466
promises.add(promise);
6567
promise.setMoimBasedPromise(this);
6668
}
69+
70+
public void updateUrl(String imageUrl){
71+
this.imageUrl = imageUrl;
72+
}
6773
}

backend/src/main/java/com/example/tomo/Moim/MoimController.java

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@
44
import com.example.tomo.Moim.dtos.MoimResponseDto;
55
import com.example.tomo.Moim.dtos.AddMoimRequestDto;
66
import com.example.tomo.global.ReponseType.ApiResponse;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
78
import io.swagger.v3.oas.annotations.Operation;
89
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.media.Content;
11+
import io.swagger.v3.oas.annotations.media.ExampleObject;
12+
import io.swagger.v3.oas.annotations.media.Schema;
13+
14+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
915
import io.swagger.v3.oas.annotations.tags.Tag;
1016
import jakarta.validation.Valid;
1117
import lombok.RequiredArgsConstructor;
1218
import org.springframework.http.HttpStatus;
19+
import org.springframework.http.MediaType;
1320
import org.springframework.http.ResponseEntity;
1421
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1522
import org.springframework.web.bind.annotation.*;
23+
import org.springframework.web.multipart.MultipartFile;
1624

25+
import java.io.IOException;
1726
import java.util.List;
1827

1928
@Tag(name = "Moim API", description = "모임 생성, 조회, 삭제 API")
@@ -24,22 +33,74 @@ public class MoimController {
2433

2534
private final MoimService moimService;
2635
private final MoimResponseAssembler moimResponseAssembler;
36+
private final ObjectMapper objectMapper;
37+
2738

2839
/* =====================
2940
모임 생성
3041
===================== */
31-
@PostMapping
42+
/* =====================
43+
모임 생성
44+
===================== */
45+
46+
@Operation(
47+
summary = "모임 생성 (이미지 없음)",
48+
description = """
49+
대표 이미지 없이 모임을 생성합니다.
50+
51+
Content-Type: application/json
52+
"""
53+
)
54+
@ApiResponses({
55+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
56+
responseCode = "201",
57+
description = "모임 생성 성공",
58+
content = @Content(
59+
mediaType = MediaType.APPLICATION_JSON_VALUE,
60+
schema = @Schema(implementation = MoimResponseDto.class)
61+
)
62+
),
63+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"),
64+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패"),
65+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류")
66+
})
67+
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
3268
public ResponseEntity<ApiResponse<MoimResponseDto>> createMoim(
33-
@Valid @RequestBody AddMoimRequestDto dto,
69+
@Valid
70+
@io.swagger.v3.oas.annotations.parameters.RequestBody(
71+
description = "모임 생성 요청 정보",
72+
required = true,
73+
content = @Content(
74+
mediaType = MediaType.APPLICATION_JSON_VALUE,
75+
schema = @Schema(implementation = AddMoimRequestDto.class),
76+
examples = @ExampleObject(
77+
value = """
78+
{
79+
"title": "개발 스터디",
80+
"description": "Spring 백엔드 스터디",
81+
"isPublic": true,
82+
"emails": ["test@test.com"],
83+
"location": {
84+
"latitude": 37.5,
85+
"longitude": 127.0
86+
}
87+
}
88+
"""
89+
)
90+
)
91+
)
92+
@RequestBody AddMoimRequestDto dto,
3493
@AuthenticationPrincipal String uid
3594
) {
95+
3696
Moim moim = moimService.createMoim(
3797
uid,
3898
dto.getTitle(),
3999
dto.getDescription(),
40100
dto.getIsPublic(),
41101
dto.getLocation(),
42-
dto.getEmails()
102+
dto.getEmails(),
103+
null
43104
);
44105

45106
MoimQueryResult result = moimService.getMoim(moim.getId(), uid);
@@ -56,6 +117,102 @@ public ResponseEntity<ApiResponse<MoimResponseDto>> createMoim(
56117
));
57118
}
58119

120+
121+
@Operation(
122+
summary = "모임 생성 (대표 이미지 선택 가능)",
123+
description = """
124+
모임을 생성하면서 대표 이미지를 함께 업로드합니다.
125+
126+
Content-Type: multipart/form-data
127+
128+
- request: 모임 생성 정보(JSON 문자열)
129+
- image: JPG 이미지 파일 (선택)
130+
"""
131+
)
132+
@ApiResponses({
133+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
134+
responseCode = "201",
135+
description = "모임 생성 성공",
136+
content = @Content(
137+
mediaType = MediaType.APPLICATION_JSON_VALUE,
138+
schema = @Schema(implementation = MoimResponseDto.class)
139+
)
140+
),
141+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"),
142+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패"),
143+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류")
144+
})
145+
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
146+
public ResponseEntity<ApiResponse<MoimResponseDto>> createMoimWithImage(
147+
148+
@Parameter(
149+
description = "모임 생성 정보 (JSON 문자열)",
150+
required = true,
151+
content = @Content(
152+
mediaType = MediaType.APPLICATION_JSON_VALUE,
153+
schema = @Schema(implementation = AddMoimRequestDto.class),
154+
examples = @ExampleObject(
155+
value = """
156+
{
157+
"title": "개발 스터디",
158+
"description": "Spring 백엔드 스터디",
159+
"isPublic": true,
160+
"emails": ["test@test.com"],
161+
"location": {
162+
"latitude": 37.5,
163+
"longitude": 127.0
164+
}
165+
}
166+
"""
167+
)
168+
)
169+
)
170+
@RequestPart("request") String requestJson,
171+
172+
@Parameter(
173+
description = "모임 대표 이미지 (JPG)",
174+
required = false,
175+
content = @Content(
176+
mediaType = MediaType.IMAGE_JPEG_VALUE
177+
)
178+
)
179+
@RequestPart(value = "image", required = false) MultipartFile image,
180+
181+
@AuthenticationPrincipal String uid
182+
) throws IOException {
183+
184+
// 1️⃣ JSON 문자열 → DTO 변환
185+
AddMoimRequestDto dto =
186+
objectMapper.readValue(requestJson, AddMoimRequestDto.class);
187+
188+
// 2️⃣ 모임 생성
189+
Moim moim = moimService.createMoim(
190+
uid,
191+
dto.getTitle(),
192+
dto.getDescription(),
193+
dto.getIsPublic(),
194+
dto.getLocation(),
195+
dto.getEmails(),
196+
image
197+
);
198+
199+
// 3️⃣ 응답 조립
200+
MoimQueryResult result = moimService.getMoim(moim.getId(), uid);
201+
202+
return ResponseEntity
203+
.status(HttpStatus.CREATED)
204+
.body(ApiResponse.success(
205+
moimResponseAssembler.toDto(
206+
result.getMoim(),
207+
result.getEmails(),
208+
result.isLeader()
209+
),
210+
"모임이 생성되었습니다."
211+
));
212+
}
213+
214+
215+
59216
/* =====================
60217
모임 단일 조회
61218
===================== */

backend/src/main/java/com/example/tomo/Moim/MoimService.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
import com.example.tomo.Users.UserException;
1010
import com.example.tomo.Users.UserRepository;
1111
import com.example.tomo.global.Embedded.Location;
12+
import com.example.tomo.global.S3.S3UploadService;
1213
import lombok.RequiredArgsConstructor;
1314
import lombok.extern.slf4j.Slf4j;
1415
import org.springframework.stereotype.Service;
1516
import org.springframework.transaction.annotation.Transactional;
17+
import org.springframework.web.multipart.MultipartFile;
1618

1719
import java.util.List;
1820

@@ -25,6 +27,7 @@ public class MoimService {
2527
private final MoimRepository moimRepository;
2628
private final UserRepository userRepository;
2729
private final MoimPeopleRepository moimPeopleRepository;
30+
private final S3UploadService s3UploadService;
2831

2932
/* =====================
3033
모임 생성
@@ -36,7 +39,8 @@ public Moim createMoim(
3639
String description,
3740
Boolean isPublic,
3841
Location location,
39-
List<String> emails
42+
List<String> emails,
43+
MultipartFile image
4044
) {
4145

4246
User leader = userRepository.findByFirebaseId(uid)
@@ -61,6 +65,14 @@ public Moim createMoim(
6165
moim.addMoimPeople(new Moim_people(moim, user, false));
6266
}
6367

68+
if (image != null && !image.isEmpty()) {
69+
String imageUrl = s3UploadService.uploadMoimImage(
70+
moim.getId(),
71+
image
72+
);
73+
moim.updateUrl(imageUrl);
74+
}
75+
6476
return moim;
6577
}
6678

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.example.tomo.global.Config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
7+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
8+
import software.amazon.awssdk.regions.Region;
9+
import software.amazon.awssdk.services.s3.S3Client;
10+
11+
@Configuration
12+
public class S3Config {
13+
14+
@Value("${cloud.aws.credentials.accessKey}")
15+
private String accessKey;
16+
17+
@Value("${cloud.aws.credentials.secretKey}")
18+
private String secretKey;
19+
20+
@Value("${cloud.aws.region.static}")
21+
private String region;
22+
23+
@Bean
24+
public S3Client s3Client() {
25+
AwsBasicCredentials credentials =
26+
AwsBasicCredentials.create(accessKey, secretKey);
27+
28+
return S3Client.builder()
29+
.region(Region.of(region))
30+
.credentialsProvider(
31+
StaticCredentialsProvider.create(credentials)
32+
)
33+
.build();
34+
}
35+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.example.tomo.global.S3;
2+
3+
import com.example.tomo.global.ReponseType.ApiResponse;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
6+
import org.springframework.web.bind.annotation.PostMapping;
7+
import org.springframework.web.bind.annotation.RequestMapping;
8+
import org.springframework.web.bind.annotation.RequestPart;
9+
import org.springframework.web.bind.annotation.RestController;
10+
import org.springframework.web.multipart.MultipartFile;
11+
12+
import java.io.IOException;
13+
14+
@RestController
15+
@RequestMapping("/images")
16+
@RequiredArgsConstructor
17+
public class ImageController {
18+
19+
private final S3UploadService s3UploadService;
20+
21+
@PostMapping
22+
public ApiResponse<String> upload(
23+
@RequestPart("image") MultipartFile image,
24+
@AuthenticationPrincipal String uid
25+
) throws IOException {
26+
27+
String imageUrl = s3UploadService.upload(image, uid);
28+
29+
return ApiResponse.success(imageUrl, "이미지 업로드 성공");
30+
}
31+
32+
33+
}
34+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.example.tomo.global.S3;
2+
3+
import lombok.Getter;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.http.HttpStatus;
6+
7+
@Getter
8+
@RequiredArgsConstructor
9+
public enum S3ErrorCode {
10+
11+
S3_UPLOAD_FAILED(
12+
HttpStatus.INTERNAL_SERVER_ERROR,
13+
"이미지 업로드에 실패했습니다."
14+
),
15+
16+
INVALID_FILE(
17+
HttpStatus.BAD_REQUEST,
18+
"유효하지 않은 파일입니다."
19+
);
20+
21+
private final HttpStatus status;
22+
private final String message;
23+
}
24+
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.example.tomo.global.S3;
2+
3+
import com.example.tomo.global.Exception.BusinessException;
4+
import lombok.Getter;
5+
6+
@Getter
7+
public class S3Exception extends BusinessException {
8+
9+
private final S3ErrorCode errorCode;
10+
11+
public S3Exception(S3ErrorCode errorCode) {
12+
super(
13+
errorCode.getMessage(),
14+
errorCode.getStatus()
15+
);
16+
this.errorCode = errorCode;
17+
}
18+
19+
}

0 commit comments

Comments
 (0)