Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ jobs:
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
FCM_CERTIFICATION: ${{ secrets.FCM_CERTIFICATION }}
CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }}
CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }}
CLOUDINARY_API_SECRET: ${{ secrets.CLOUDINARY_API_SECRET }}
run: |
cat > src/main/resources/application.yml <<EOF
spring:
Expand Down Expand Up @@ -78,6 +81,10 @@ jobs:
timeout: 5000
starttls:
enable: true
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
mybatis:
mapper-locations: classpath:com/dodo/backend/**/mapper/*.xml
type-aliases-package: com.dodo.backend
Expand Down Expand Up @@ -108,6 +115,10 @@ jobs:
secret: ${JWT_SECRET}
access-token-validity: 3600000
refresh-token-validity: 1209600000
cloudinary:
cloud-name: ${CLOUDINARY_CLOUD_NAME}
api-key: ${CLOUDINARY_API_KEY}
api-secret: ${CLOUDINARY_API_SECRET}
EOF

- name: 빌드
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ jobs:
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
FCM_CERTIFICATION: ${{ secrets.FCM_CERTIFICATION }}
CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }}
CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }}
CLOUDINARY_API_SECRET: ${{ secrets.CLOUDINARY_API_SECRET }}
run: |
cat > src/main/resources/application.yml <<EOF
spring:
Expand Down Expand Up @@ -86,6 +89,10 @@ jobs:
timeout: 5000
starttls:
enable: true
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
mybatis:
mapper-locations: classpath:com/dodo/backend/**/mapper/*.xml
type-aliases-package: com.dodo.backend
Expand Down Expand Up @@ -116,6 +123,10 @@ jobs:
secret: ${JWT_SECRET:-test_jwt_secret_key_must_be_very_long_to_pass_validation_check}
access-token-validity: 3600000
refresh-token-validity: 1209600000
cloudinary:
cloud-name: ${CLOUDINARY_CLOUD_NAME:-test-cloud}
api-key: ${CLOUDINARY_API_KEY:-test-key}
api-secret: ${CLOUDINARY_API_SECRET:-test-secret}
EOF

- name: Gradle 빌드 및 테스트
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
implementation 'com.cloudinary:cloudinary-http44:1.39.0'
implementation 'org.apache.tika:tika-core:2.9.2'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/com/dodo/backend/common/config/CloudinaryConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.dodo.backend.common.config;

import com.cloudinary.Cloudinary;
import com.cloudinary.utils.ObjectUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Cloudinary 클라이언트 설정 클래스입니다.
*/
@Configuration
public class CloudinaryConfig {

@Bean
public Cloudinary cloudinary(
@Value("${cloudinary.cloud-name}") String cloudName,
@Value("${cloudinary.api-key}") String apiKey,
@Value("${cloudinary.api-secret}") String apiSecret
) {
return new Cloudinary(ObjectUtils.asMap(
"cloud_name", cloudName,
"api_key", apiKey,
"api_secret", apiSecret,
"secure", true
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.dodo.backend.imagefile.controller;

import com.dodo.backend.common.exception.ErrorResponse;
import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse;
import com.dodo.backend.imagefile.service.ImageFileService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

/**
* 이미지 파일 업로드 API 컨트롤러입니다.
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/files")
@Tag(name = "Image File API", description = "이미지 파일 업로드 API")
public class ImageFileController {

private final ImageFileService imageFileService;

/**
* 이미지 파일을 Cloudinary에 업로드하고 URL 목록을 반환합니다.
*/
@Operation(summary = "이미지 업로드", description = "여러 장의 이미지 파일을 업로드하고 URL 목록을 반환합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "이미지 업로드에 성공하였습니다.",
content = @Content(schema = @Schema(implementation = ImageUploadResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청입니다.",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "400 Bad Request", value = "{\"status\": 400, \"message\": \"잘못된 요청입니다.\"}"))),
@ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "401 Unauthorized", value = "{\"status\": 401, \"message\": \"로그인이 필요한 기능입니다.\"}"))),
@ApiResponse(responseCode = "403", description = "접근 권한이 없습니다.",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "403 Forbidden", value = "{\"status\": 403, \"message\": \"접근 권한이 없습니다.\"}"))),
@ApiResponse(responseCode = "404", description = "잘못된 요청입니다.",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "404 Not Found", value = "{\"status\": 404, \"message\": \"잘못된 요청입니다.\"}"))),
@ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(name = "500 Internal Server Error", value = "{\"status\": 500, \"message\": \"서버 내부 오류가 발생했습니다.\"}")))
})
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImageUploadResponse> uploadImages(
@RequestParam("files") List<MultipartFile> files
) {
return ResponseEntity.ok(imageFileService.uploadImages(files));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.dodo.backend.imagefile.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.util.List;

/**
* 이미지 파일 업로드 응답 DTO 모음입니다.
*/
@Schema(description = "이미지 파일 응답 DTO 모음")
public class ImageFileResponse {

@Getter
@Builder
@AllArgsConstructor
@Schema(description = "이미지 업로드 응답 DTO")
public static class ImageUploadResponse {

@Schema(description = "응답 메시지", example = "이미지 업로드에 성공하였습니다.")
private String message;

@Schema(description = "업로드된 이미지 URL 목록")
private List<String> imageUrls;

public static ImageUploadResponse toDto(String message, List<String> imageUrls) {
return ImageUploadResponse.builder()
.message(message)
.imageUrls(imageUrls)
.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.dodo.backend.imagefile.service;

import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;
import java.util.Map;

Expand All @@ -15,4 +18,12 @@ public interface ImageFileService {
* @return 펫 ID(Key)와 이미지 URL(Value)을 매핑한 Map 객체
*/
Map<Long, String> getProfileUrlsByPetIds(List<Long> petIds);
}

/**
* 이미지 파일 목록을 업로드하고 URL 목록을 반환합니다.
*
* @param files 업로드할 이미지 파일 목록
* @return 업로드 응답 DTO
*/
ImageUploadResponse uploadImages(List<MultipartFile> files);
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
package com.dodo.backend.imagefile.service;

import com.cloudinary.Cloudinary;
import com.cloudinary.utils.ObjectUtils;
import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse;
import com.dodo.backend.imagefile.entity.ImageFile;
import com.dodo.backend.imagefile.repository.ImageFileRepository;
import com.dodo.backend.user.exception.UserException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import static com.dodo.backend.user.exception.UserErrorCode.INVALID_REQUEST;

/**
* {@link ImageFileService}의 구현체입니다.
*/
Expand All @@ -20,7 +30,16 @@
@Slf4j
public class ImageFileServiceImpl implements ImageFileService {

private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
"image/jpeg",
"image/png",
"image/webp",
"image/gif"
);

private final ImageFileRepository imageFileRepository;
private final Cloudinary cloudinary;
private final Tika tika = new Tika();

/**
* {@inheritDoc}
Expand Down Expand Up @@ -48,4 +67,64 @@ public Map<Long, String> getProfileUrlsByPetIds(List<Long> petIds) {
ImageFile::getImageFileUrl
));
}
}

/**
* {@inheritDoc}
*/
@Transactional(readOnly = true)
@Override
public ImageUploadResponse uploadImages(List<MultipartFile> files) {
if (files == null || files.isEmpty()) {
throw new UserException(INVALID_REQUEST);
}

List<String> imageUrls = files.stream()
.map(this::uploadSingleImage)
.toList();

return ImageUploadResponse.toDto("이미지 업로드에 성공하였습니다.", imageUrls);
}

private String uploadSingleImage(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new UserException(INVALID_REQUEST);
}

String contentType = file.getContentType();
if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) {
throw new UserException(INVALID_REQUEST);
}

String detectedMimeType = detectMimeType(file);
if (!ALLOWED_MIME_TYPES.contains(detectedMimeType)) {
throw new UserException(INVALID_REQUEST);
}

try {
Map<?, ?> uploadResult = cloudinary.uploader().upload(
file.getBytes(),
ObjectUtils.asMap(
"folder", "images",
"resource_type", "image"
)
);
Object url = uploadResult.get("secure_url");
if (url == null) {
throw new IllegalStateException("Cloudinary 응답에 secure_url이 없습니다.");
}
return Objects.toString(url);
} catch (UserException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("이미지 업로드 중 오류가 발생했습니다.", e);
}
}

private String detectMimeType(MultipartFile file) {
try {
return tika.detect(file.getBytes());
} catch (Exception e) {
throw new UserException(INVALID_REQUEST);
}
}
}
Loading