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
2 changes: 2 additions & 0 deletions infra/docker-compose.stg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ services:
container_name: docsa-mysql-stg
restart: unless-stopped
env_file: .stg.env
ports:
- "127.0.0.1:3307:3306"
volumes:
- stg_mysql_data:/var/lib/mysql
- ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf:ro
Expand Down
2 changes: 2 additions & 0 deletions infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ services:
container_name: docsa-mysql
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:3308:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf:ro
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import io.ejangs.docsa.domain.image.dto.request.ImageUploadUrlRequest;
import io.ejangs.docsa.domain.image.dto.response.ImageUploadCompleteResponse;
import io.ejangs.docsa.domain.image.dto.response.ImageUploadUrlResponse;
import io.ejangs.docsa.domain.image.swagger.CompleteImageUploadDocs;
import io.ejangs.docsa.domain.image.swagger.CreateImageUploadUrlDocs;
import io.ejangs.docsa.domain.user.security.CustomUserDetails;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand All @@ -18,11 +21,13 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/images")
@Tag(name = "Image API", description = "이미지 업로드 API")
public class ImageController {

private final ImageService imageService;

@PostMapping("/upload-url")
@CreateImageUploadUrlDocs
public ResponseEntity<ImageUploadUrlResponse> createUploadUrl(
@AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @RequestBody ImageUploadUrlRequest request) {
Expand All @@ -33,6 +38,7 @@ public ResponseEntity<ImageUploadUrlResponse> createUploadUrl(
}

@PostMapping("/{imageId}/complete")
@CompleteImageUploadDocs
public ResponseEntity<ImageUploadCompleteResponse> complete(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long imageId
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package io.ejangs.docsa.domain.image.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

@Schema(description = "이미지 업로드 URL 발급 요청")
public record ImageUploadUrlRequest(
@Schema(description = "이미지가 연결될 문서 id", example = "1")
@NotNull(message = "DocId bad request")
Long docId,

@Schema(description = "원본 파일명", example = "profile.png")
@NotBlank(message = "OriginalFileName Cannot be BLANK")
String originalFileName,

@Schema(description = "이미지 Content-Type", example = "image/png")
@NotBlank(message = "ContentType Cannot be BLANK")
String contentType,

@Schema(description = "파일 크기(byte)", example = "102400")
@NotNull(message = "Size Cannot be Null")
@Positive(message = "Size Must be Positive Number")
Long size
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
package io.ejangs.docsa.domain.image.dto.response;

import io.ejangs.docsa.domain.image.entity.Image.ImageStatus;
import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "이미지 업로드 완료 응답")
public record ImageUploadCompleteResponse(
@Schema(description = "이미지 id", example = "10")
Long imageId,
@Schema(description = "S3 object key", example = "users/1/docs/1/images/550e8400-e29b-41d4-a716-446655440000.png")
String objectKey,
@Schema(description = "CloudFront 또는 CDN 이미지 URL")
String imageUrl,
@Schema(description = "검증된 이미지 Content-Type", example = "image/png")
String contentType,
@Schema(description = "검증된 파일 크기(byte)", example = "102400")
Long size,
@Schema(description = "이미지 상태", example = "ACTIVE")
ImageStatus status
) {
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package io.ejangs.docsa.domain.image.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "이미지 업로드 URL 발급 응답")
public record ImageUploadUrlResponse(
@Schema(description = "생성된 이미지 메타데이터 id", example = "10")
Long imageId,
@Schema(description = "S3 object key", example = "users/1/docs/1/images/550e8400-e29b-41d4-a716-446655440000.png")
String objectKey,
@Schema(description = "클라이언트가 PUT 요청을 보낼 presigned URL")
String uploadUrl,
@Schema(description = "업로드에 사용할 HTTP method", example = "PUT")
String method,
@Schema(description = "URL 만료 시간(초)", example = "300")
Long expiresInSeconds
) {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package io.ejangs.docsa.domain.image.swagger;

import io.ejangs.docsa.domain.image.dto.response.ImageUploadCompleteResponse;
import io.ejangs.docsa.global.exception.ErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
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 java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.http.MediaType;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Operation(
summary = "이미지 업로드 완료 처리",
description = """
presigned URL로 S3 업로드가 끝난 뒤 호출합니다.
서버는 S3 `HeadObject`로 실제 업로드된 객체를 검증한 뒤 이미지를 `ACTIVE` 상태로 전환합니다.
아직 업로드되지 않았거나 업로드가 실패한 경우 `IMAGE_UPLOAD_NOT_COMPLETED`를 반환합니다.
🔐 이 API는 세션 로그인 상태에서 호출되어야 하며,
클라이언트는 쿠키(`JSESSIONID`)를 통해 인증 정보를 전송해야 합니다.
""",
parameters = {
@Parameter(
name = "imageId",
description = "업로드 URL 발급 시 반환된 이미지 id",
example = "10",
required = true,
in = ParameterIn.PATH
)
},
responses = {
@ApiResponse(
responseCode = "200",
description = "업로드 완료 처리 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ImageUploadCompleteResponse.class),
examples = @ExampleObject(
value = """
{
"imageId": 10,
"objectKey": "users/1/docs/1/images/550e8400-e29b-41d4-a716-446655440000.png",
"imageUrl": "https://cdn.example.com/users/1/docs/1/images/550e8400-e29b-41d4-a716-446655440000.png",
"contentType": "image/png",
"size": 102400,
"status": "ACTIVE"
}
"""
)
)
),
@ApiResponse(
responseCode = "401",
description = "인증 실패 - 로그인 세션이 없음",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(
value = """
{
"status": 401,
"message": "로그인이 필요합니다.",
"error": "LOGIN_REQUIRED"
}
"""
)
)
),
@ApiResponse(
responseCode = "404",
description = "존재하지 않는 이미지 메타데이터",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(
value = """
{
"status": 404,
"message": "이미지를 찾을 수 없습니다.",
"error": "IMAGE_NOT_FOUND"
}
"""
)
)
),
@ApiResponse(
responseCode = "409",
description = "S3 업로드 미완료 또는 업로드된 파일 검증 실패",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(
name = "업로드 미완료",
value = """
{
"status": 409,
"message": "이미지 업로드가 아직 완료되지 않았습니다.",
"error": "IMAGE_UPLOAD_NOT_COMPLETED"
}
"""
),
@ExampleObject(
name = "콘텐츠 타입 불일치",
value = """
{
"status": 400,
"message": "지원하지 않는 이미지 형식입니다.",
"error": "INVALID_IMAGE_CONTENT_TYPE"
}
"""
),
@ExampleObject(
name = "파일 크기 불일치",
value = """
{
"status": 400,
"message": "업로드 가능한 최대 용량을 초과했습니다.",
"error": "INVALID_IMAGE_SIZE"
}
"""
)
}
)
)
}
)
public @interface CompleteImageUploadDocs {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package io.ejangs.docsa.domain.image.swagger;

import io.ejangs.docsa.domain.image.dto.request.ImageUploadUrlRequest;
import io.ejangs.docsa.domain.image.dto.response.ImageUploadUrlResponse;
import io.ejangs.docsa.global.exception.ErrorResponse;
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.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.http.MediaType;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Operation(
summary = "이미지 업로드용 Presigned URL 발급",
description = """
문서에 업로드할 이미지의 presigned PUT URL을 발급합니다.
응답으로 받은 `uploadUrl`에 클라이언트가 직접 PUT 업로드를 수행한 뒤
`POST /api/images/{imageId}/complete` API를 호출해야 업로드가 완료됩니다.
🔐 이 API는 세션 로그인 상태에서 호출되어야 하며,
클라이언트는 쿠키(`JSESSIONID`)를 통해 인증 정보를 전송해야 합니다.
""",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ImageUploadUrlRequest.class),
examples = @ExampleObject(
value = """
{
"docId": 1,
"originalFileName": "profile.png",
"contentType": "image/png",
"size": 102400
}
"""
)
)
),
responses = {
@ApiResponse(
responseCode = "200",
description = "Presigned URL 발급 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ImageUploadUrlResponse.class),
examples = @ExampleObject(
value = """
{
"imageId": 10,
"objectKey": "users/1/docs/1/images/550e8400-e29b-41d4-a716-446655440000.png",
"uploadUrl": "https://bucket.s3.ap-northeast-2.amazonaws.com/...",
"method": "PUT",
"expiresInSeconds": 300
}
"""
)
)
),
@ApiResponse(
responseCode = "400",
description = "잘못된 이미지 타입 또는 크기",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(
name = "지원하지 않는 이미지 형식",
value = """
{
"status": 400,
"message": "지원하지 않는 이미지 형식입니다.",
"error": "INVALID_IMAGE_CONTENT_TYPE"
}
"""
),
@ExampleObject(
name = "허용 용량 초과",
value = """
{
"status": 400,
"message": "업로드 가능한 최대 용량을 초과했습니다.",
"error": "INVALID_IMAGE_SIZE"
}
"""
)
}
)
),
@ApiResponse(
responseCode = "401",
description = "인증 실패 - 로그인 세션이 없음",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(
value = """
{
"status": 401,
"message": "로그인이 필요합니다.",
"error": "LOGIN_REQUIRED"
}
"""
)
)
),
@ApiResponse(
responseCode = "404",
description = "존재하지 않는 문서",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class),
examples = @ExampleObject(
value = """
{
"status": 404,
"message": "해당 문서를 찾을 수 없습니다.",
"error": "DOCUMENT_NOT_FOUND"
}
"""
)
)
)
}
)
public @interface CreateImageUploadUrlDocs {
}
Loading