Skip to content

Commit 7109f69

Browse files
committed
[FEAT] 이미지 저장·조회·교체 API 구현
1 parent ca08e9e commit 7109f69

File tree

9 files changed

+468
-1
lines changed

9 files changed

+468
-1
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.sumte.apiPayload.code.error;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import lombok.Getter;
6+
import lombok.RequiredArgsConstructor;
7+
8+
@Getter
9+
@RequiredArgsConstructor
10+
public enum ImageErrorCode implements ErrorCode {
11+
IMAGE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "IMAGE400", "해당 이미지가 이미 등록되어 있습니다."),
12+
13+
GUESTHOUSE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당 게스트하우스를 찾을 수 없습니다."),
14+
15+
ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당 룸을 찾을 수 없습니다."),
16+
17+
REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당 리뷰를 찾을 수 없습니다."),
18+
19+
INVALID_OWNER_TYPE(HttpStatus.BAD_REQUEST, "IMAGE400", "지원하지 않는 이미지 소유자 타입입니다.");
20+
21+
private final HttpStatus httpStatus;
22+
private final String code;
23+
private final String message;
24+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package com.sumte.image.controller;
2+
3+
import java.util.List;
4+
import java.util.stream.Collectors;
5+
6+
import org.springframework.http.HttpStatus;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.PathVariable;
10+
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.PutMapping;
12+
import org.springframework.web.bind.annotation.RequestBody;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RequestParam;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
import com.sumte.image.dto.ImageRequestDTO;
18+
import com.sumte.image.dto.ImageResponseDTO;
19+
import com.sumte.image.dto.ReplaceImageRequestDTO;
20+
import com.sumte.image.entity.OwnerType;
21+
import com.sumte.image.service.ImageService;
22+
23+
import io.swagger.v3.oas.annotations.Operation;
24+
import io.swagger.v3.oas.annotations.Parameter;
25+
import io.swagger.v3.oas.annotations.enums.ParameterIn;
26+
import io.swagger.v3.oas.annotations.media.ArraySchema;
27+
import io.swagger.v3.oas.annotations.media.Content;
28+
import io.swagger.v3.oas.annotations.media.ExampleObject;
29+
import io.swagger.v3.oas.annotations.media.Schema;
30+
import io.swagger.v3.oas.annotations.tags.Tag;
31+
import jakarta.validation.Valid;
32+
import lombok.RequiredArgsConstructor;
33+
34+
@RestController
35+
@RequestMapping("/images")
36+
@RequiredArgsConstructor
37+
@Tag(name = "이미지 API", description = "이미지 메타데이터 저장·조회·교체 API 및 S3 PresignedUrl 발급 API")
38+
public class ImageController {
39+
private final ImageService imageService;
40+
41+
@Operation(summary = "이미지 일괄 저장",
42+
description = """
43+
- 여러 이미지 메타데이터를 한 번에 저장합니다.
44+
- 이미지가 등록되는 파트에 대한 정보 OwnerType(GUESTHOUSE, ROOM 등)과
45+
OwnerId(해당 파트의 ID)를 함께 전달해야 합니다.
46+
- 요청 리스트 순서대로 서버에서 sortOrder(이미지 순서)가 1부터 자동 부여됩니다.
47+
""")
48+
@io.swagger.v3.oas.annotations.parameters.RequestBody(
49+
description = "등록할 이미지 리스트를 전달합니다. 서버가 순서를 1부터 자동 부여합니다.",
50+
required = true,
51+
content = @Content(
52+
mediaType = "application/json",
53+
array = @ArraySchema(
54+
schema = @Schema(implementation = ImageRequestDTO.class)
55+
),
56+
examples = {
57+
@ExampleObject(
58+
name = "Image Batch Upload Example",
59+
value = """
60+
[
61+
{
62+
"ownerType": "GUESTHOUSE",
63+
"ownerId": 1,
64+
"url": "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte1.png"
65+
},
66+
{
67+
"ownerType": "GUESTHOUSE",
68+
"ownerId": 1,
69+
"url": "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte2.png"
70+
},
71+
{
72+
"ownerType": "GUESTHOUSE",
73+
"ownerId": 1,
74+
"url": "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte3.png"
75+
}
76+
]
77+
"""
78+
)
79+
}
80+
)
81+
)
82+
@PostMapping
83+
public ResponseEntity<List<ImageResponseDTO>> saveImagesBatch(
84+
85+
@Valid @RequestBody List<ImageRequestDTO> dtos) {
86+
87+
var savedList = imageService.saveAllImages(dtos);
88+
var respList = savedList.stream()
89+
.map(img -> new ImageResponseDTO(
90+
img.getId(), img.getUrl(), img.getSortOrder(), img.getOwnerType(), img.getOwnerId()))
91+
.collect(Collectors.toList());
92+
93+
return ResponseEntity
94+
.status(HttpStatus.CREATED)
95+
.body(respList);
96+
}
97+
98+
@Operation(
99+
summary = "이미지 전체 교체",
100+
description = """
101+
주어진 ownerType/ownerId 에 등록된 모든 이미지를 삭제하고,
102+
요청 리스트 순서대로 새 이미지를 1부터 순차 저장합니다.
103+
- S3 객체도 함께 삭제됩니다.
104+
""")
105+
@PutMapping("/{ownerType}/{ownerId}")
106+
public ResponseEntity<List<ImageResponseDTO>> replaceImages(
107+
@PathVariable OwnerType ownerType,
108+
@PathVariable Long ownerId,
109+
@io.swagger.v3.oas.annotations.parameters.RequestBody(
110+
description = "전체 교체할 이미지 리스트를 전달합니다.",
111+
required = true,
112+
content = @Content(
113+
mediaType = "application/json",
114+
array = @ArraySchema(schema = @Schema(implementation = ReplaceImageRequestDTO.class)),
115+
examples = {
116+
@ExampleObject(
117+
name = "Replace Example",
118+
value = """
119+
[
120+
{ "url":"https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte1.png" },
121+
{ "url":"https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte2.png" },
122+
{ "url":"https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte3.png" }
123+
]
124+
"""
125+
)
126+
}
127+
)
128+
)
129+
@Valid @RequestBody List<ReplaceImageRequestDTO> replaceImageDtos
130+
) {
131+
List<ImageRequestDTO> requests = replaceImageDtos.stream()
132+
.map(r -> new ImageRequestDTO(ownerType, ownerId, r.getUrl()))
133+
.collect(Collectors.toList());
134+
135+
var saved = imageService.replaceImages(ownerType, ownerId, requests);
136+
var resp = saved.stream()
137+
.map(img -> new ImageResponseDTO(
138+
img.getId(),
139+
img.getUrl(),
140+
img.getSortOrder(),
141+
img.getOwnerType(),
142+
img.getOwnerId()
143+
))
144+
.collect(Collectors.toList());
145+
146+
return ResponseEntity.ok(resp);
147+
}
148+
149+
@Operation(summary = "이미지 목록 조회", description = "주어진 ownerType, ownerId 에 매핑된 이미지 리스트를 정렬순으로 조회합니다.")
150+
@GetMapping
151+
public ResponseEntity<List<ImageResponseDTO>> getImages(
152+
@Parameter(
153+
name = "ownerType",
154+
description = "이미지 소유자 타입",
155+
example = "ROOM",
156+
required = true,
157+
in = ParameterIn.QUERY
158+
) @RequestParam OwnerType ownerType,
159+
@Parameter(
160+
name = "ownerId",
161+
description = "소유자 ID",
162+
example = "1",
163+
required = true,
164+
in = ParameterIn.QUERY
165+
)
166+
@RequestParam Long ownerId) {
167+
var list = imageService.getImagesByOwner(ownerType, ownerId);
168+
var dtos = list.stream()
169+
.map(img -> new ImageResponseDTO(
170+
img.getId(), img.getUrl(), img.getSortOrder(), img.getOwnerType(), img.getOwnerId()))
171+
.collect(Collectors.toList());
172+
return ResponseEntity.ok(dtos);
173+
}
174+
}

src/main/java/com/sumte/image/controller/S3FileUploadController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
@RestController
1616
@RequestMapping("/s3")
1717
@RequiredArgsConstructor
18-
@Tag(name = "S3 PresignedUrl API", description = "S3 PresignedUrl 발급 API")
18+
@Tag(name = "이미지 API", description = "이미지 메타데이터 저장·조회·교체 API 및 S3 PresignedUrl 발급 API")
1919
public class S3FileUploadController {
2020

2121
private final S3FileUploadService s3FileUploadService;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.sumte.image.dto;
2+
3+
import com.sumte.image.entity.OwnerType;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
import jakarta.validation.constraints.NotBlank;
7+
import jakarta.validation.constraints.NotNull;
8+
import lombok.AllArgsConstructor;
9+
import lombok.Getter;
10+
import lombok.NoArgsConstructor;
11+
import lombok.Setter;
12+
13+
@Getter
14+
@Setter
15+
@NoArgsConstructor
16+
@AllArgsConstructor
17+
@Schema(name = "ImageRequestDTO", description = "이미지 메타데이터 등록 요청 DTO")
18+
public class ImageRequestDTO {
19+
20+
@Schema(description = "이미지 소유자 타입", example = "GUESTHOUSE")
21+
@NotNull
22+
private OwnerType ownerType;
23+
24+
@Schema(description = "소유자 ID (예: 게스트하우스 ID, 룸 ID, 리뷰 ID 등)", example = "1")
25+
@NotNull
26+
private Long ownerId;
27+
28+
@Schema(description = "S3에 업로드된 이미지 URL", example = "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte.png")
29+
@NotBlank
30+
private String url;
31+
32+
// @Schema(description = "정렬 순서 (0부터 시작)", example = "1")
33+
// private Integer sortOrder;
34+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.sumte.image.dto;
2+
3+
import com.sumte.image.entity.OwnerType;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
import lombok.Setter;
10+
11+
@Getter
12+
@Setter
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
@Schema(name = "ImageResponseDTO", description = "이미지 메타데이터 조회/등록 응답 DTO")
16+
public class ImageResponseDTO {
17+
18+
@Schema(description = "이미지 고유 ID", example = "1")
19+
private Long id;
20+
21+
@Schema(description = "이미지 URL 또는 object key", example = "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte.png")
22+
private String url;
23+
24+
@Schema(description = "정렬 순서 (1부터 시작)", example = "1")
25+
private Integer sortOrder;
26+
27+
@Schema(description = "이미지 소유자 타입", example = "GUESTHOUSE")
28+
private OwnerType ownerType;
29+
30+
@Schema(description = "소유자 ID (게스트하우스/룸/리뷰 ID)", example = "1")
31+
private Long ownerId;
32+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.sumte.image.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
import lombok.Setter;
9+
10+
@Getter
11+
@Setter
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
@Schema(name = "ReplaceImageRequestDTO",
15+
description = "이미지 전체 교체 요청 DTO (URL만 포함)")
16+
public class ReplaceImageRequestDTO {
17+
18+
@Schema(description = "S3 URL", example = "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte.png")
19+
@NotBlank
20+
private String url;
21+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.sumte.image.repository;
2+
3+
import java.util.List;
4+
import java.util.Optional;
5+
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
9+
10+
import com.sumte.image.entity.Image;
11+
import com.sumte.image.entity.OwnerType;
12+
13+
public interface ImageRepository extends JpaRepository<Image, Long> {
14+
15+
boolean existsByOwnerTypeAndOwnerId(OwnerType ownerType, Long ownerId);
16+
17+
@Query("SELECT MAX(i.sortOrder) FROM Image i WHERE i.ownerType = :ownerType AND i.ownerId = :ownerId")
18+
Optional<Integer> findMaxSortOrder(
19+
@Param("ownerType") OwnerType ownerType,
20+
@Param("ownerId") Long ownerId
21+
);
22+
23+
List<Image> findByOwnerTypeAndOwnerIdOrderBySortOrderAsc(OwnerType ownerType, Long ownerId);
24+
}

0 commit comments

Comments
 (0)