diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java index 4823cca1..99dddc0c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java @@ -4,22 +4,23 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.openapitools.jackson.nullable.JsonNullable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.datasource.dto.*; -import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; import org.tuna.zoopzoop.backend.domain.datasource.service.PersonalDataSourceService; import org.tuna.zoopzoop.backend.global.rsData.RsData; import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; import java.io.IOException; +import java.util.HashMap; import java.util.Map; @RestController @@ -119,11 +120,12 @@ public ResponseEntity> moveMany( } // ===== 수정 ===== + // JSON만 수정 @Operation(summary = "자료 수정", description = "내 PersonalArchive 안에 자료를 수정합니다.") - @PatchMapping("/{dataSourceId}") - public ResponseEntity>> updateDataSource( + @PatchMapping(path = "/{dataSourceId}", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> updateDataSourceJson( @PathVariable Integer dataSourceId, - @RequestBody reqBodyForUpdateDataSource body, + @RequestBody @Valid reqBodyForUpdateDataSource body, @AuthenticationPrincipal CustomUserDetails user ) { boolean anyPresent = @@ -136,28 +138,17 @@ public ResponseEntity>> updateDataSource( (body.category() != null && body.category().isPresent()); if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다."); - - var catNullable = body.category(); - - // category enum 변환 시도 - JsonNullable enumCat = null; - if (catNullable != null && catNullable.isPresent()) { - String raw = catNullable.get(); - try { - // 필요하면 대소문자 허용 로직 추가 - enumCat = JsonNullable.of(Category.valueOf(raw.toUpperCase())); - } catch (IllegalArgumentException ex) { - throw new IllegalArgumentException("유효하지 않은 카테고리입니다: " + raw); - } - } - int updatedId = personalApp.update( user.getMember().getId(), dataSourceId, DataSourceService.UpdateCmd.builder() - .title(body.title()).summary(body.summary()).sourceUrl(body.sourceUrl()) - .imageUrl(body.imageUrl()).source(body.source()) - .tags(body.tags()).category(enumCat) + .title(body.title()) + .summary(body.summary()) + .source(body.source()) + .sourceUrl(body.sourceUrl()) + .imageUrl(body.imageUrl()) + .category(body.category()) + .tags(body.tags()) .build() ); @@ -166,6 +157,47 @@ public ResponseEntity>> updateDataSource( ); } + // 이미지 포함 수정 + @Operation(summary = "자료 수정(이미지+JSON)", description = "내 PersonalArchive 안에 자료를 수정합니다") + @PatchMapping(path = "/{dataSourceId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity>> updateDataSourceMultipart( + @PathVariable Integer dataSourceId, + @RequestPart("payload") @Valid reqBodyForUpdateDataSource body, // JSON 파트 + @RequestPart(value = "image", required = false) MultipartFile image, // 파일 파트 + @AuthenticationPrincipal CustomUserDetails user + ) { + boolean anyPresent = + (body.title() != null && body.title().isPresent()) || + (body.summary() != null && body.summary().isPresent()) || + (body.sourceUrl() != null && body.sourceUrl().isPresent()) || + (body.imageUrl() != null && body.imageUrl().isPresent()) || + (body.source() != null && body.source().isPresent()) || + (body.tags() != null && body.tags().isPresent()) || + (body.category() != null && body.category().isPresent()) || + (image != null && !image.isEmpty()); + if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다."); + + var baseCmd = DataSourceService.UpdateCmd.builder() + .title(body.title()) + .summary(body.summary()) + .source(body.source()) + .sourceUrl(body.sourceUrl()) + .imageUrl(body.imageUrl()) + .category(body.category()) + .tags(body.tags()) + .build(); + + var outcome = personalApp.updateWithImage( + user.getMember().getId(), dataSourceId, baseCmd, image + ); + + Map data = new HashMap<>(); + data.put("dataSourceId", outcome.dataSourceId()); + data.put("imageUrl", outcome.imageUrl()); + + return ResponseEntity.ok(new RsData<>("200", "자료가 수정됐습니다.", data)); + } + // ===== 검색 ===== @Operation(summary = "자료 검색", description = "내 PersonalArchive 안에 자료들을 검색합니다.") @GetMapping("") diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/UpdateOutcome.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/UpdateOutcome.java new file mode 100644 index 00000000..7a0639a2 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/UpdateOutcome.java @@ -0,0 +1,3 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +public record UpdateOutcome(int dataSourceId, String imageUrl) {} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java index ed5f3ebb..0e643568 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java @@ -1,6 +1,7 @@ package org.tuna.zoopzoop.backend.domain.datasource.dto; import org.openapitools.jackson.nullable.JsonNullable; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import java.util.List; @@ -11,5 +12,5 @@ public record reqBodyForUpdateDataSource( JsonNullable imageUrl, JsonNullable source, JsonNullable> tags, - JsonNullable category + JsonNullable category ) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java index d5b79db5..f88361b9 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -5,9 +5,11 @@ import lombok.Builder; import lombok.RequiredArgsConstructor; import org.openapitools.jackson.nullable.JsonNullable; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition; @@ -17,7 +19,9 @@ import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceQRepository; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.global.aws.S3Service; +import java.net.URI; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -30,6 +34,10 @@ public class DataSourceService { private final DataSourceRepository dataSourceRepository; private final FolderRepository folderRepository; private final DataSourceQRepository dataSourceQRepository; + private final S3Service s3Service; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; // ===== DTOs ===== @@ -55,7 +63,18 @@ public record UpdateCmd ( JsonNullable imageUrl, JsonNullable category, JsonNullable> tags - ) {} + ) { + public static UpdateCmd.UpdateCmdBuilder builderFrom(UpdateCmd base) { + return UpdateCmd.builder() + .title(base.title()) + .summary(base.summary()) + .source(base.source()) + .sourceUrl(base.sourceUrl()) + .imageUrl(base.imageUrl()) + .category(base.category()) + .tags(base.tags()); + } + } @Builder public record MoveResult ( @@ -107,12 +126,8 @@ public int update(int dataSourceId, UpdateCmd cmd) { if (cmd.imageUrl() != null && cmd.imageUrl().isPresent()) ds.setImageUrl(cmd.imageUrl().get()); if (cmd.category() != null && cmd.category().isPresent()) { Category v = cmd.category().get(); - if (v != null) ds.setCategory(v); - else throw new IllegalArgumentException("유효하지 않은 카테고리입니다."); - } - if (cmd.category() != null && cmd.category().isPresent()) { - Category v = cmd.category().get(); - if (v != null) ds.setCategory(v); + if (v == null) throw new IllegalArgumentException("유효하지 않은 카테고리입니다."); + ds.setCategory(v); } if (cmd.tags() != null && cmd.tags().isPresent()) { @@ -166,6 +181,7 @@ public void moveMany(List ids, int targetFolderId) { public void hardDeleteOne(int dataSourceId) { DataSource ds = dataSourceRepository.findById(dataSourceId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); + deleteOwnedImageIfAny(ds); dataSourceRepository.delete(ds); } @@ -174,6 +190,7 @@ public void hardDeleteMany(List ids) { if (ids == null || ids.isEmpty()) return; List list = dataSourceRepository.findAllById(ids); if (list.size() != ids.size()) throw new NoResultException("존재하지 않는 자료 포함"); + for (DataSource ds : list) deleteOwnedImageIfAny(ds); dataSourceRepository.deleteAll(list); } @@ -211,9 +228,106 @@ public int restoreMany(List ids) { return affected; } - // 검색 + // search @Transactional public Page searchInArchive(Integer archiveId, DataSourceSearchCondition cond, Pageable pageable) { return dataSourceQRepository.searchInArchive(archiveId, cond, pageable); } + + // ===== update: 공통 유틸 ===== + // 이미지 유효성 검사 + public void validateImage(MultipartFile image) { + if (image == null || image.isEmpty()) { + throw new IllegalArgumentException("이미지 파일이 비어있습니다."); + } + if (image.getSize() > (5 * 1024 * 1024)) { + throw new IllegalArgumentException("이미지 파일 크기는 5MB를 초과할 수 없습니다."); + } + String ct = image.getContentType(); + if (ct == null || !(ct.equals("image/png") || ct.equals("image/jpeg") || ct.equals("image/webp"))) { + throw new IllegalArgumentException("이미지 형식은 PNG/JPEG/WEBP만 허용합니다."); + } + } + + // 썸네일 S3 키 생성 + public String thumbnailKeyForPersonal(int memberId, int dataSourceId) { + return "datasource-thumbnail/personal_" + memberId + "/ds_" + dataSourceId; + } + public String thumbnailKeyForSpace(int spaceId, int dataSourceId) { + return "datasource-thumbnail/space_" + spaceId + "/ds_" + dataSourceId; + } + + // 썸네일 업로드 + URL 반환 + public String uploadThumbnailAndReturnFinalUrl(MultipartFile image, String key) { + validateImage(image); + try { + String baseUrl = s3Service.upload(image, key); // S3 putObject + return baseUrl + "?v=" + System.currentTimeMillis(); + } catch (Exception e) { + throw new RuntimeException("썸네일 이미지 업로드에 실패했습니다."); + } + } + + // ===== S3 삭제 관련 유틸 ===== + // 소유한 이미지가 있으면 S3에서 삭제 + private void deleteOwnedImageIfAny(DataSource ds) { + String url = ds.getImageUrl(); + if (url == null || url.isBlank()) return; + if (!isOurS3Url(url)) return; + + String key = extractKeyFromUrl(url); + if (key == null || key.isBlank()) return; + + try { + s3Service.delete(key); + } catch (Exception ignore) { + // 파일 삭제 실패로 전체 삭제를 롤백하지 않음 + // 필요하면 warn 로그 추가 + } + } + + // URL이 우리 S3 버킷의 객체를 가리키는지 검사 + private boolean isOurS3Url(String url) { + try { + String noQuery = url.split("\\?")[0]; + URI uri = URI.create(noQuery); + String host = uri.getHost(); + String path = uri.getPath(); + if (host == null || bucket == null || bucket.isBlank()) return false; + + if (host.startsWith(bucket + ".s3")) return true; + + return host.startsWith("s3.") && path != null && path.startsWith("/" + bucket + "/"); + } catch (Exception e) { + return false; + } + } + + // S3 URL에서 key 추출 + private String extractKeyFromUrl(String url) { + try { + String noQuery = url.split("\\?")[0]; + URI uri = URI.create(noQuery); + String host = uri.getHost(); + String path = uri.getPath(); + if (host == null || path == null) return null; + + // virtual-hosted-style: / + if (host.startsWith(bucket + ".s3")) return trimLeadingSlash(path); + + // path-style: /{bucket}/{key} + if (host.startsWith("s3.") && path.startsWith("/" + bucket + "/")) { + return path.substring(("/" + bucket + "/").length()); + } + + return null; + } catch (Exception e) { + return null; + } + } + + // 문자열 앞의 '/' 제거 + private String trimLeadingSlash(String s) { + return (s != null && s.startsWith("/")) ? s.substring(1) : s; + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/PersonalDataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/PersonalDataSourceService.java index 63eef6ef..30652aaf 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/PersonalDataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/PersonalDataSourceService.java @@ -3,9 +3,11 @@ import jakarta.persistence.NoResultException; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.openapitools.jackson.nullable.JsonNullable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; @@ -13,10 +15,11 @@ import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem; -import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.dto.UpdateOutcome; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceQRepository; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.global.aws.S3Service; import java.io.IOException; import java.util.*; @@ -32,6 +35,8 @@ public class PersonalDataSourceService { private final PersonalArchiveRepository personalArchiveRepository; private final DataProcessorService dataProcessorService; + private final S3Service s3Service; + private int getPersonalArchiveId(int memberId) { PersonalArchive pa = personalArchiveRepository.findByMemberId(memberId) .orElseThrow(() -> new NoResultException("개인 아카이브를 찾을 수 없습니다.")); @@ -62,14 +67,6 @@ public int create(int memberId, String sourceUrl, Integer folderIdOrZero, DataSo DataSourceDto dto = dataProcessorService.process(sourceUrl, baseTags); - Category category = null; - if (dto.category() != null && !dto.category().isBlank()) { - try { - category = Category.valueOf(dto.category().toUpperCase()); - } catch (IllegalArgumentException ignore) { - // 모르는 카테고리는 null 저장 - } - } var cmd = DataSourceService.CreateCmd.builder() .title(dto.title()) .summary(dto.summary()) @@ -89,6 +86,7 @@ public int create(int memberId, String sourceUrl, Integer folderIdOrZero, DataSo public int deleteOne(int memberId, int dataSourceId) { dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); + domain.hardDeleteOne(dataSourceId); return dataSourceId; } @@ -136,6 +134,7 @@ public DataSourceService.MoveResult moveOne(int memberId, int dataSourceId, Inte return domain.moveOne(dataSourceId, folderId); } + // move many @Transactional public void moveMany(int memberId, Integer targetFolderIdOrZero, List ids) { if (ids == null || ids.isEmpty()) @@ -160,7 +159,7 @@ public void moveMany(int memberId, Integer targetFolderIdOrZero, List i domain.moveMany(ids, folderId); } - // update + // update (JSON-only) @Transactional public int update(int memberId, int dataSourceId, DataSourceService.UpdateCmd cmd) { dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) @@ -168,6 +167,29 @@ public int update(int memberId, int dataSourceId, DataSourceService.UpdateCmd cm return domain.update(dataSourceId, cmd); } + // update image (S3) + @Transactional + public UpdateOutcome updateWithImage(int memberId, int dataSourceId, + DataSourceService.UpdateCmd baseCmd, + MultipartFile image) { + dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) + .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); + + var cmd = baseCmd; + String finalUrl = null; + + if (image != null && !image.isEmpty()) { + String key = domain.thumbnailKeyForPersonal(memberId, dataSourceId); + finalUrl = domain.uploadThumbnailAndReturnFinalUrl(image, key); + cmd = DataSourceService.UpdateCmd.builderFrom(baseCmd) + .imageUrl(JsonNullable.of(finalUrl)) + .build(); + } + + int updatedId = domain.update(dataSourceId, cmd); + return new UpdateOutcome(updatedId, finalUrl); + } + // search public Page search(int memberId, DataSourceSearchCondition cond, Pageable pageable) { if (cond.getFolderId() != null && cond.getFolderId() == 0) { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/controller/SpaceArchiveDataSourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/controller/SpaceArchiveDataSourceController.java index d8a5cf4d..7e4b9ed3 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/controller/SpaceArchiveDataSourceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/controller/SpaceArchiveDataSourceController.java @@ -4,21 +4,22 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.openapitools.jackson.nullable.JsonNullable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.datasource.dto.*; -import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; import org.tuna.zoopzoop.backend.domain.space.archive.service.SpaceDataSourceService; import org.tuna.zoopzoop.backend.global.rsData.RsData; import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -30,6 +31,7 @@ public class SpaceArchiveDataSourceController { private final SpaceDataSourceService spaceApp; + // ===== 단건 삭제 ===== @Operation(summary = "공유 자료 단건 삭제") @DeleteMapping("/{dataSourceId}") public ResponseEntity>> deleteOne( @@ -43,6 +45,7 @@ public ResponseEntity>> deleteOne( ); } + // ==== 다건 삭제 ===== @Operation(summary = "공유 자료 다건 삭제") @DeleteMapping("/delete") public ResponseEntity> deleteMany( @@ -54,6 +57,7 @@ public ResponseEntity> deleteMany( return ResponseEntity.ok(new RsData<>("200", "복수개의 자료가 삭제됐습니다.", null)); } + // ===== 임시 삭제 ===== @Operation(summary = "공유 자료 다건 임시 삭제") @PatchMapping("/soft-delete") public ResponseEntity> softDelete( @@ -65,6 +69,7 @@ public ResponseEntity> softDelete( return ResponseEntity.ok(new RsData<>("200", "자료들이 임시 삭제됐습니다.", null)); } + // ===== 복원 ===== @Operation(summary = "공유 자료 다건 복원") @PatchMapping("/restore") public ResponseEntity> restore( @@ -76,6 +81,7 @@ public ResponseEntity> restore( return ResponseEntity.ok(new RsData<>("200", "자료들이 복구됐습니다.", null)); } + // ===== 이동 ===== @Operation(summary = "공유 자료 단건 이동") @PatchMapping("/{dataSourceId}/move") public ResponseEntity>> moveOne( @@ -102,44 +108,34 @@ public ResponseEntity> moveMany( return ResponseEntity.ok(new RsData<>("200", "복수 개의 자료를 이동했습니다.", null)); } + // ===== 수정 ===== + // JSON만 수정 @Operation(summary = "공유 자료 수정") - @PatchMapping("/{dataSourceId}") - public ResponseEntity>> update( + @PatchMapping(path = "/{dataSourceId}", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> updateSpaceJson( @PathVariable String spaceId, @PathVariable Integer dataSourceId, - @RequestBody reqBodyForUpdateDataSource body, + @RequestBody @Valid reqBodyForUpdateDataSource body, @AuthenticationPrincipal CustomUserDetails user ) { - // 변경값 유무 체크 (선택) boolean anyPresent = - (body.title() != null && body.title().isPresent()) - || (body.summary() != null && body.summary().isPresent()) - || (body.sourceUrl() != null && body.sourceUrl().isPresent()) - || (body.imageUrl() != null && body.imageUrl().isPresent()) - || (body.source() != null && body.source().isPresent()) - || (body.tags() != null && body.tags().isPresent()) - || (body.category() != null && body.category().isPresent()); + (body.title() != null && body.title().isPresent()) || + (body.summary() != null && body.summary().isPresent()) || + (body.sourceUrl() != null && body.sourceUrl().isPresent()) || + (body.imageUrl() != null && body.imageUrl().isPresent()) || + (body.source() != null && body.source().isPresent()) || + (body.tags() != null && body.tags().isPresent()) || + (body.category() != null && body.category().isPresent()); if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다."); - // category 문자열 → enum 변환 - JsonNullable enumCat = null; - if (body.category() != null && body.category().isPresent()) { - String raw = body.category().get(); - try { - enumCat = JsonNullable.of(Category.valueOf(raw.toUpperCase())); - } catch (IllegalArgumentException ex) { - throw new IllegalArgumentException("유효하지 않은 카테고리입니다: " + raw); - } - } - var cmd = DataSourceService.UpdateCmd.builder() .title(body.title()) .summary(body.summary()) + .source(body.source()) .sourceUrl(body.sourceUrl()) .imageUrl(body.imageUrl()) - .source(body.source()) + .category(body.category()) .tags(body.tags()) - .category(enumCat) .build(); int updatedId = spaceApp.update(user.getMember().getId(), spaceId, dataSourceId, cmd); @@ -149,7 +145,50 @@ public ResponseEntity>> update( ); } + // 이미지 포함 멀티파트 수정 + @Operation(summary = "공유 자료 수정(멀티파트: 이미지+JSON)") + @PatchMapping(path = "/{dataSourceId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity>> updateSpaceMultipart( + @PathVariable String spaceId, + @PathVariable Integer dataSourceId, + @RequestPart("payload") @Valid reqBodyForUpdateDataSource body, // JSON 파트 + @RequestPart(value = "image", required = false) MultipartFile image, // 파일 파트 + @AuthenticationPrincipal CustomUserDetails user + ) { + boolean anyPresent = + (body.title() != null && body.title().isPresent()) || + (body.summary() != null && body.summary().isPresent()) || + (body.sourceUrl() != null && body.sourceUrl().isPresent()) || + (body.imageUrl() != null && body.imageUrl().isPresent()) || + (body.source() != null && body.source().isPresent()) || + (body.tags() != null && body.tags().isPresent()) || + (body.category() != null && body.category().isPresent()) || + (image != null && !image.isEmpty()); + if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다."); + + var baseCmd = DataSourceService.UpdateCmd.builder() + .title(body.title()) + .summary(body.summary()) + .source(body.source()) + .sourceUrl(body.sourceUrl()) + .imageUrl(body.imageUrl()) + .category(body.category()) + .tags(body.tags()) + .build(); + + var outcome = spaceApp.updateWithImage( + user.getMember().getId(), spaceId, dataSourceId, baseCmd, image + ); + + Map data = new HashMap<>(); + data.put("dataSourceId", outcome.dataSourceId()); + data.put("imageUrl", outcome.imageUrl()); + + return ResponseEntity.ok(new RsData<>("200", "자료가 수정됐습니다.", data)); + } + + // ===== 불러오기(개인 → 공유) ===== @Operation(summary = "개인 → 공유: 자료 단건 불러오기") @PostMapping("/{dataSourceId}/import") public ResponseEntity>> importOne( @@ -166,6 +205,7 @@ public ResponseEntity>> importOne( ); } + // ===== 다건 불러오기(개인 → 공유) ===== @Operation(summary = "개인 → 공유: 자료 다건 불러오기") @PostMapping("/import/batch") public ResponseEntity>>> importBatch( @@ -184,6 +224,7 @@ public ResponseEntity>>> importBatch( ); } + // ===== 검색 ===== @Operation(summary = "공유 자료 검색") @GetMapping("") public ResponseEntity>> search( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/service/SpaceArchiveFolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/service/SpaceArchiveFolderService.java index bcc51ee9..c136c4c6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/service/SpaceArchiveFolderService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/service/SpaceArchiveFolderService.java @@ -97,7 +97,7 @@ public List getFolders(Integer spaceId, Member requester) { } @Transactional(readOnly = true) - public FolderFilesDto getFilesInFolder(Integer spaceId, Member requester, Integer folderId) { + public FolderFilesDto getFilesInFolder(Integer spaceId, Member requester, Integer folderId) { Space space = spaceService.findById(spaceId); if (!membershipService.isMemberInSpace(requester, space)) throw new SecurityException("스페이스의 구성원이 아닙니다."); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/service/SpaceDataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/service/SpaceDataSourceService.java index c487636d..83038c12 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/service/SpaceDataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/archive/service/SpaceDataSourceService.java @@ -3,16 +3,18 @@ import jakarta.persistence.NoResultException; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.openapitools.jackson.nullable.JsonNullable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.SharingArchive; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem; +import org.tuna.zoopzoop.backend.domain.datasource.dto.UpdateOutcome; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; -import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceQRepository; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; @@ -20,6 +22,7 @@ import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; +import org.tuna.zoopzoop.backend.global.aws.S3Service; import java.util.*; @@ -29,11 +32,12 @@ public class SpaceDataSourceService { private final DataSourceService domain; private final DataSourceRepository dataSourceRepository; - private final DataSourceQRepository dataSourceQRepository; private final FolderRepository folderRepository; private final SpaceRepository spaceRepository; private final MembershipRepository membershipRepository; + private final S3Service s3Service; + private Space getSpace(String raw) { Integer spaceId; try { @@ -45,6 +49,7 @@ private Space getSpace(String raw) { .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); } + // 권한 검사 private void assertReadable(int requesterMemberId, Space space) { membershipRepository.findByMemberIdAndSpaceId(requesterMemberId, space.getId()) .orElseThrow(() -> new NoResultException("스페이스 멤버가 아닙니다.")); @@ -57,6 +62,7 @@ private void assertWritable(int requesterMemberId, Space space) { throw new SecurityException("쓰기 권한 없음"); } + // 스페이스의 공유 아카이브 ID 조회 private Integer getArchiveId(Space space) { SharingArchive sa = space.getSharingArchive(); if (sa == null || sa.getArchive() == null) @@ -64,6 +70,7 @@ private Integer getArchiveId(Space space) { return sa.getArchive().getId(); } + // 아카이브 내 폴더 ID 결정 (0 또는 null → 기본 폴더) private int resolveTargetFolderIdByArchive(int archiveId, Integer folderIdOrZero) { if (folderIdOrZero == null || Objects.equals(folderIdOrZero, 0)) { return folderRepository.findByArchiveIdAndIsDefaultTrue(archiveId) @@ -77,7 +84,7 @@ private int resolveTargetFolderIdByArchive(int archiveId, Integer folderIdOrZero return f.getId(); } - // ===== 불러오기(개인→공유) ===== + // 불러오기(개인→공유) @Transactional public int importFromPersonal(int requesterMemberId, String spaceIdRaw, int sourceDataSourceId, Integer targetFolderIdOrZero) { Space space = getSpace(spaceIdRaw); @@ -104,6 +111,7 @@ public int importFromPersonal(int requesterMemberId, String spaceIdRaw, int sour return domain.create(targetFolderId, cmd); } + // 불러오기(개인→공유) 다건 @Transactional public List importManyFromPersonal(int requesterMemberId, String spaceIdRaw, List sourceIds, Integer targetFolderIdOrZero) { if (sourceIds == null || sourceIds.isEmpty()) @@ -143,7 +151,7 @@ public List importManyFromPersonal(int requesterMemberId, String spaceI return created; } - // ===== 공유 스코프: 삭제/이동/수정/검색 ===== + // 삭제 @Transactional public int deleteOne(int requesterMemberId, String spaceIdRaw, int dataSourceId) { Space space = getSpace(spaceIdRaw); @@ -157,6 +165,7 @@ public int deleteOne(int requesterMemberId, String spaceIdRaw, int dataSourceId) return dataSourceId; } + // 다건 삭제 @Transactional public void deleteMany(int requesterMemberId, String spaceIdRaw, List ids) { Space space = getSpace(spaceIdRaw); @@ -170,6 +179,7 @@ public void deleteMany(int requesterMemberId, String spaceIdRaw, List i domain.hardDeleteMany(ids); } + // 임시 삭제 @Transactional public int softDelete(int requesterMemberId, String spaceIdRaw, List ids) { Space space = getSpace(spaceIdRaw); @@ -183,6 +193,7 @@ public int softDelete(int requesterMemberId, String spaceIdRaw, List id return domain.softDeleteMany(ids); } + // 복원 @Transactional public int restore(int requesterMemberId, String spaceIdRaw, List ids) { Space space = getSpace(spaceIdRaw); @@ -196,6 +207,7 @@ public int restore(int requesterMemberId, String spaceIdRaw, List ids) return domain.restoreMany(ids); } + // 이동 @Transactional public DataSourceService.MoveResult moveOne(int requesterMemberId, String spaceIdRaw, int dataSourceId, Integer targetFolderIdOrZero) { Space space = getSpace(spaceIdRaw); @@ -210,6 +222,7 @@ public DataSourceService.MoveResult moveOne(int requesterMemberId, String spaceI return domain.moveOne(dataSourceId, folderId); } + // 다건 이동 @Transactional public void moveMany(int requesterMemberId, String spaceIdRaw, Integer targetFolderIdOrZero, List ids) { Space space = getSpace(spaceIdRaw); @@ -225,6 +238,7 @@ public void moveMany(int requesterMemberId, String spaceIdRaw, Integer targetFol domain.moveMany(ids, folderId); } + // 수정 @Transactional public int update(int requesterMemberId, String spaceIdRaw, int dataSourceId, DataSourceService.UpdateCmd cmd) { Space space = getSpace(spaceIdRaw); @@ -237,6 +251,35 @@ public int update(int requesterMemberId, String spaceIdRaw, int dataSourceId, Da return domain.update(dataSourceId, cmd); } + // 이미지 포함 수정 + @Transactional + public UpdateOutcome updateWithImage(int requesterMemberId, String spaceIdRaw, int dataSourceId, + DataSourceService.UpdateCmd baseCmd, + MultipartFile image) { + Space space = getSpace(spaceIdRaw); + assertWritable(requesterMemberId, space); + Integer archiveId = getArchiveId(space); + + dataSourceRepository.findByIdAndArchiveId(dataSourceId, archiveId) + .orElseThrow(() -> new NoResultException("해당 스페이스에 존재하지 않는 자료입니다.")); + + var cmd = baseCmd; + String finalUrl = null; + + if (image != null && !image.isEmpty()) { + String key = domain.thumbnailKeyForSpace(space.getId(), dataSourceId); + finalUrl = domain.uploadThumbnailAndReturnFinalUrl(image, key); + cmd = DataSourceService.UpdateCmd.builderFrom(baseCmd) + .imageUrl(JsonNullable.of(finalUrl)) + .build(); + } + + int updatedId = domain.update(dataSourceId, cmd); + return new UpdateOutcome(updatedId, finalUrl); + } + + + // 검색 public Page search(int requesterMemberId, String spaceIdRaw, DataSourceSearchCondition cond, Pageable pageable) { Space space = getSpace(spaceIdRaw); assertReadable(requesterMemberId, space); @@ -259,4 +302,5 @@ public Page search(int requesterMemberId, String spaceIdRa return domain.searchInArchive(archiveId, cond, pageable); } + } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/config/JacksonConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/config/JacksonConfig.java index 75c95438..b1afcc10 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/config/JacksonConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/config/JacksonConfig.java @@ -1,7 +1,9 @@ package org.tuna.zoopzoop.backend.global.config; +import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.Module; import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,4 +13,9 @@ public class JacksonConfig { public Module jsonNullableModule() { return new JsonNullableModule(); } -} + + @Bean + public Jackson2ObjectMapperBuilderCustomizer enumsCaseInsensitive() { + return builder -> builder.featuresToEnable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); + } +} \ No newline at end of file