55import lombok .Builder ;
66import lombok .RequiredArgsConstructor ;
77import org .openapitools .jackson .nullable .JsonNullable ;
8+ import org .springframework .beans .factory .annotation .Value ;
89import org .springframework .data .domain .Page ;
910import org .springframework .data .domain .Pageable ;
1011import org .springframework .stereotype .Service ;
12+ import org .springframework .web .multipart .MultipartFile ;
1113import org .tuna .zoopzoop .backend .domain .archive .folder .entity .Folder ;
1214import org .tuna .zoopzoop .backend .domain .archive .folder .repository .FolderRepository ;
1315import org .tuna .zoopzoop .backend .domain .datasource .dto .DataSourceSearchCondition ;
1719import org .tuna .zoopzoop .backend .domain .datasource .entity .Tag ;
1820import org .tuna .zoopzoop .backend .domain .datasource .repository .DataSourceQRepository ;
1921import org .tuna .zoopzoop .backend .domain .datasource .repository .DataSourceRepository ;
22+ import org .tuna .zoopzoop .backend .global .aws .S3Service ;
2023
24+ import java .net .URI ;
2125import java .time .LocalDate ;
2226import java .util .ArrayList ;
2327import 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