Skip to content

Commit 19d8874

Browse files
authored
[Feat/OPS-391] 아카이브 자료 img 자료 형식 수정 (#141)
* feat/OPS-391 : 이미지 수정 multi part 구현 * feat/OPS-391 : 이미지 수정 multi part 구현 * refactor/OPS-391 : enum 대소문자 무시
1 parent 84d5a59 commit 19d8874

File tree

9 files changed

+338
-74
lines changed

9 files changed

+338
-74
lines changed

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,23 @@
44
import io.swagger.v3.oas.annotations.tags.Tag;
55
import jakarta.validation.Valid;
66
import lombok.RequiredArgsConstructor;
7-
import org.openapitools.jackson.nullable.JsonNullable;
87
import org.springframework.data.domain.Page;
98
import org.springframework.data.domain.Pageable;
109
import org.springframework.data.domain.Sort;
1110
import org.springframework.data.web.PageableDefault;
11+
import org.springframework.http.MediaType;
1212
import org.springframework.http.ResponseEntity;
1313
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1414
import org.springframework.web.bind.annotation.*;
15+
import org.springframework.web.multipart.MultipartFile;
1516
import org.tuna.zoopzoop.backend.domain.datasource.dto.*;
16-
import org.tuna.zoopzoop.backend.domain.datasource.entity.Category;
1717
import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService;
1818
import org.tuna.zoopzoop.backend.domain.datasource.service.PersonalDataSourceService;
1919
import org.tuna.zoopzoop.backend.global.rsData.RsData;
2020
import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails;
2121

2222
import java.io.IOException;
23+
import java.util.HashMap;
2324
import java.util.Map;
2425

2526
@RestController
@@ -119,11 +120,12 @@ public ResponseEntity<RsData<Void>> moveMany(
119120
}
120121

121122
// ===== 수정 =====
123+
// JSON만 수정
122124
@Operation(summary = "자료 수정", description = "내 PersonalArchive 안에 자료를 수정합니다.")
123-
@PatchMapping("/{dataSourceId}")
124-
public ResponseEntity<RsData<Map<String, Integer>>> updateDataSource(
125+
@PatchMapping(path = "/{dataSourceId}", consumes = MediaType.APPLICATION_JSON_VALUE)
126+
public ResponseEntity<RsData<Map<String, Integer>>> updateDataSourceJson(
125127
@PathVariable Integer dataSourceId,
126-
@RequestBody reqBodyForUpdateDataSource body,
128+
@RequestBody @Valid reqBodyForUpdateDataSource body,
127129
@AuthenticationPrincipal CustomUserDetails user
128130
) {
129131
boolean anyPresent =
@@ -136,28 +138,17 @@ public ResponseEntity<RsData<Map<String, Integer>>> updateDataSource(
136138
(body.category() != null && body.category().isPresent());
137139
if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다.");
138140

139-
140-
var catNullable = body.category();
141-
142-
// category enum 변환 시도
143-
JsonNullable<Category> enumCat = null;
144-
if (catNullable != null && catNullable.isPresent()) {
145-
String raw = catNullable.get();
146-
try {
147-
// 필요하면 대소문자 허용 로직 추가
148-
enumCat = JsonNullable.of(Category.valueOf(raw.toUpperCase()));
149-
} catch (IllegalArgumentException ex) {
150-
throw new IllegalArgumentException("유효하지 않은 카테고리입니다: " + raw);
151-
}
152-
}
153-
154141
int updatedId = personalApp.update(
155142
user.getMember().getId(),
156143
dataSourceId,
157144
DataSourceService.UpdateCmd.builder()
158-
.title(body.title()).summary(body.summary()).sourceUrl(body.sourceUrl())
159-
.imageUrl(body.imageUrl()).source(body.source())
160-
.tags(body.tags()).category(enumCat)
145+
.title(body.title())
146+
.summary(body.summary())
147+
.source(body.source())
148+
.sourceUrl(body.sourceUrl())
149+
.imageUrl(body.imageUrl())
150+
.category(body.category())
151+
.tags(body.tags())
161152
.build()
162153
);
163154

@@ -166,6 +157,47 @@ public ResponseEntity<RsData<Map<String, Integer>>> updateDataSource(
166157
);
167158
}
168159

160+
// 이미지 포함 수정
161+
@Operation(summary = "자료 수정(이미지+JSON)", description = "내 PersonalArchive 안에 자료를 수정합니다")
162+
@PatchMapping(path = "/{dataSourceId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
163+
public ResponseEntity<RsData<Map<String, Object>>> updateDataSourceMultipart(
164+
@PathVariable Integer dataSourceId,
165+
@RequestPart("payload") @Valid reqBodyForUpdateDataSource body, // JSON 파트
166+
@RequestPart(value = "image", required = false) MultipartFile image, // 파일 파트
167+
@AuthenticationPrincipal CustomUserDetails user
168+
) {
169+
boolean anyPresent =
170+
(body.title() != null && body.title().isPresent()) ||
171+
(body.summary() != null && body.summary().isPresent()) ||
172+
(body.sourceUrl() != null && body.sourceUrl().isPresent()) ||
173+
(body.imageUrl() != null && body.imageUrl().isPresent()) ||
174+
(body.source() != null && body.source().isPresent()) ||
175+
(body.tags() != null && body.tags().isPresent()) ||
176+
(body.category() != null && body.category().isPresent()) ||
177+
(image != null && !image.isEmpty());
178+
if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다.");
179+
180+
var baseCmd = DataSourceService.UpdateCmd.builder()
181+
.title(body.title())
182+
.summary(body.summary())
183+
.source(body.source())
184+
.sourceUrl(body.sourceUrl())
185+
.imageUrl(body.imageUrl())
186+
.category(body.category())
187+
.tags(body.tags())
188+
.build();
189+
190+
var outcome = personalApp.updateWithImage(
191+
user.getMember().getId(), dataSourceId, baseCmd, image
192+
);
193+
194+
Map<String, Object> data = new HashMap<>();
195+
data.put("dataSourceId", outcome.dataSourceId());
196+
data.put("imageUrl", outcome.imageUrl());
197+
198+
return ResponseEntity.ok(new RsData<>("200", "자료가 수정됐습니다.", data));
199+
}
200+
169201
// ===== 검색 =====
170202
@Operation(summary = "자료 검색", description = "내 PersonalArchive 안에 자료들을 검색합니다.")
171203
@GetMapping("")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package org.tuna.zoopzoop.backend.domain.datasource.dto;
2+
3+
public record UpdateOutcome(int dataSourceId, String imageUrl) {}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.tuna.zoopzoop.backend.domain.datasource.dto;
22

33
import org.openapitools.jackson.nullable.JsonNullable;
4+
import org.tuna.zoopzoop.backend.domain.datasource.entity.Category;
45

56
import java.util.List;
67

@@ -11,5 +12,5 @@ public record reqBodyForUpdateDataSource(
1112
JsonNullable<String> imageUrl,
1213
JsonNullable<String> source,
1314
JsonNullable<List<String>> tags,
14-
JsonNullable<String> category
15+
JsonNullable<Category> category
1516
) {}

src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import lombok.Builder;
66
import lombok.RequiredArgsConstructor;
77
import org.openapitools.jackson.nullable.JsonNullable;
8+
import org.springframework.beans.factory.annotation.Value;
89
import org.springframework.data.domain.Page;
910
import org.springframework.data.domain.Pageable;
1011
import org.springframework.stereotype.Service;
12+
import org.springframework.web.multipart.MultipartFile;
1113
import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder;
1214
import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository;
1315
import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition;
@@ -17,7 +19,9 @@
1719
import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag;
1820
import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceQRepository;
1921
import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository;
22+
import org.tuna.zoopzoop.backend.global.aws.S3Service;
2023

24+
import java.net.URI;
2125
import java.time.LocalDate;
2226
import java.util.ArrayList;
2327
import java.util.List;
@@ -30,6 +34,10 @@ public class DataSourceService {
3034
private final DataSourceRepository dataSourceRepository;
3135
private final FolderRepository folderRepository;
3236
private final DataSourceQRepository dataSourceQRepository;
37+
private final S3Service s3Service;
38+
39+
@Value("${spring.cloud.aws.s3.bucket}")
40+
private String bucket;
3341

3442
// ===== DTOs =====
3543

@@ -55,7 +63,18 @@ public record UpdateCmd (
5563
JsonNullable<String> imageUrl,
5664
JsonNullable<Category> category,
5765
JsonNullable<List<String>> tags
58-
) {}
66+
) {
67+
public static UpdateCmd.UpdateCmdBuilder builderFrom(UpdateCmd base) {
68+
return UpdateCmd.builder()
69+
.title(base.title())
70+
.summary(base.summary())
71+
.source(base.source())
72+
.sourceUrl(base.sourceUrl())
73+
.imageUrl(base.imageUrl())
74+
.category(base.category())
75+
.tags(base.tags());
76+
}
77+
}
5978

6079
@Builder
6180
public record MoveResult (
@@ -107,12 +126,8 @@ public int update(int dataSourceId, UpdateCmd cmd) {
107126
if (cmd.imageUrl() != null && cmd.imageUrl().isPresent()) ds.setImageUrl(cmd.imageUrl().get());
108127
if (cmd.category() != null && cmd.category().isPresent()) {
109128
Category v = cmd.category().get();
110-
if (v != null) ds.setCategory(v);
111-
else throw new IllegalArgumentException("유효하지 않은 카테고리입니다.");
112-
}
113-
if (cmd.category() != null && cmd.category().isPresent()) {
114-
Category v = cmd.category().get();
115-
if (v != null) ds.setCategory(v);
129+
if (v == null) throw new IllegalArgumentException("유효하지 않은 카테고리입니다.");
130+
ds.setCategory(v);
116131
}
117132

118133
if (cmd.tags() != null && cmd.tags().isPresent()) {
@@ -166,6 +181,7 @@ public void moveMany(List<Integer> ids, int targetFolderId) {
166181
public void hardDeleteOne(int dataSourceId) {
167182
DataSource ds = dataSourceRepository.findById(dataSourceId)
168183
.orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다."));
184+
deleteOwnedImageIfAny(ds);
169185
dataSourceRepository.delete(ds);
170186
}
171187

@@ -174,6 +190,7 @@ public void hardDeleteMany(List<Integer> ids) {
174190
if (ids == null || ids.isEmpty()) return;
175191
List<DataSource> list = dataSourceRepository.findAllById(ids);
176192
if (list.size() != ids.size()) throw new NoResultException("존재하지 않는 자료 포함");
193+
for (DataSource ds : list) deleteOwnedImageIfAny(ds);
177194
dataSourceRepository.deleteAll(list);
178195
}
179196

@@ -211,9 +228,106 @@ public int restoreMany(List<Integer> ids) {
211228
return affected;
212229
}
213230

214-
// 검색
231+
// search
215232
@Transactional
216233
public Page<DataSourceSearchItem> searchInArchive(Integer archiveId, DataSourceSearchCondition cond, Pageable pageable) {
217234
return dataSourceQRepository.searchInArchive(archiveId, cond, pageable);
218235
}
236+
237+
// ===== update: 공통 유틸 =====
238+
// 이미지 유효성 검사
239+
public void validateImage(MultipartFile image) {
240+
if (image == null || image.isEmpty()) {
241+
throw new IllegalArgumentException("이미지 파일이 비어있습니다.");
242+
}
243+
if (image.getSize() > (5 * 1024 * 1024)) {
244+
throw new IllegalArgumentException("이미지 파일 크기는 5MB를 초과할 수 없습니다.");
245+
}
246+
String ct = image.getContentType();
247+
if (ct == null || !(ct.equals("image/png") || ct.equals("image/jpeg") || ct.equals("image/webp"))) {
248+
throw new IllegalArgumentException("이미지 형식은 PNG/JPEG/WEBP만 허용합니다.");
249+
}
250+
}
251+
252+
// 썸네일 S3 키 생성
253+
public String thumbnailKeyForPersonal(int memberId, int dataSourceId) {
254+
return "datasource-thumbnail/personal_" + memberId + "/ds_" + dataSourceId;
255+
}
256+
public String thumbnailKeyForSpace(int spaceId, int dataSourceId) {
257+
return "datasource-thumbnail/space_" + spaceId + "/ds_" + dataSourceId;
258+
}
259+
260+
// 썸네일 업로드 + URL 반환
261+
public String uploadThumbnailAndReturnFinalUrl(MultipartFile image, String key) {
262+
validateImage(image);
263+
try {
264+
String baseUrl = s3Service.upload(image, key); // S3 putObject
265+
return baseUrl + "?v=" + System.currentTimeMillis();
266+
} catch (Exception e) {
267+
throw new RuntimeException("썸네일 이미지 업로드에 실패했습니다.");
268+
}
269+
}
270+
271+
// ===== S3 삭제 관련 유틸 =====
272+
// 소유한 이미지가 있으면 S3에서 삭제
273+
private void deleteOwnedImageIfAny(DataSource ds) {
274+
String url = ds.getImageUrl();
275+
if (url == null || url.isBlank()) return;
276+
if (!isOurS3Url(url)) return;
277+
278+
String key = extractKeyFromUrl(url);
279+
if (key == null || key.isBlank()) return;
280+
281+
try {
282+
s3Service.delete(key);
283+
} catch (Exception ignore) {
284+
// 파일 삭제 실패로 전체 삭제를 롤백하지 않음
285+
// 필요하면 warn 로그 추가
286+
}
287+
}
288+
289+
// URL이 우리 S3 버킷의 객체를 가리키는지 검사
290+
private boolean isOurS3Url(String url) {
291+
try {
292+
String noQuery = url.split("\\?")[0];
293+
URI uri = URI.create(noQuery);
294+
String host = uri.getHost();
295+
String path = uri.getPath();
296+
if (host == null || bucket == null || bucket.isBlank()) return false;
297+
298+
if (host.startsWith(bucket + ".s3")) return true;
299+
300+
return host.startsWith("s3.") && path != null && path.startsWith("/" + bucket + "/");
301+
} catch (Exception e) {
302+
return false;
303+
}
304+
}
305+
306+
// S3 URL에서 key 추출
307+
private String extractKeyFromUrl(String url) {
308+
try {
309+
String noQuery = url.split("\\?")[0];
310+
URI uri = URI.create(noQuery);
311+
String host = uri.getHost();
312+
String path = uri.getPath();
313+
if (host == null || path == null) return null;
314+
315+
// virtual-hosted-style: /<key>
316+
if (host.startsWith(bucket + ".s3")) return trimLeadingSlash(path);
317+
318+
// path-style: /{bucket}/{key}
319+
if (host.startsWith("s3.") && path.startsWith("/" + bucket + "/")) {
320+
return path.substring(("/" + bucket + "/").length());
321+
}
322+
323+
return null;
324+
} catch (Exception e) {
325+
return null;
326+
}
327+
}
328+
329+
// 문자열 앞의 '/' 제거
330+
private String trimLeadingSlash(String s) {
331+
return (s != null && s.startsWith("/")) ? s.substring(1) : s;
332+
}
219333
}

0 commit comments

Comments
 (0)