Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.s3")
public record S3Properties(String region) {
public record S3Properties(String region, String bucket, String publicBaseUrl) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.bcsdlab.bcsdinternalapiv2.media.controller;

import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse;
import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose;
import io.swagger.v3.oas.annotations.Operation;
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 jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

@Tag(name = "관리자 미디어 API")
public interface AdminImageApi {

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "400", description = "5MB 초과(AC-4.1) 또는 허용 외 확장자(AC-4.2)"),
})
@Operation(summary = "presigned URL 발급", description = "브라우저가 반환된 uploadUrl로 S3에 직접 PUT한다.")
PresignedUrlResponse issuePresignedUrl(@Valid PresignedUrlRequest request);

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 이미지"),
})
@Operation(summary = "업로드 완료 등록", description = "S3 PUT이 끝난 뒤 호출해야 라이브러리 목록에 노출된다(AC-4.3, AC-4.4).")
ImageCompleteResponse complete(Long id);

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
})
@Operation(summary = "미디어 라이브러리 목록", description = "complete된 이미지만 반환한다(AC-4.4).")
Page<ImageAssetResponse> getImages(ImagePurpose purpose, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.bcsdlab.bcsdinternalapiv2.media.controller;

import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse;
import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose;
import com.bcsdlab.bcsdinternalapiv2.media.service.AdminImageService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/admin/images")
@RequiredArgsConstructor
public class AdminImageController implements AdminImageApi {

private final AdminImageService adminImageService;

@Override
@PostMapping("/presigned-url")
public PresignedUrlResponse issuePresignedUrl(@Valid @RequestBody PresignedUrlRequest request) {
return adminImageService.issuePresignedUrl(request);
}

@Override
@PostMapping("/{id}/complete")
public ImageCompleteResponse complete(@PathVariable Long id) {
return adminImageService.complete(id);
}

@Override
@GetMapping
public Page<ImageAssetResponse> getImages(@RequestParam ImagePurpose purpose, Pageable pageable) {
return adminImageService.getImages(purpose, pageable);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request;

import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;

/**
* 확장자·용량 검증은 여기서 끝낸다(AC-4.1, AC-4.2). 서비스는 값을 그대로 신뢰한다.
*/
public record PresignedUrlRequest(
@NotBlank
@Pattern(regexp = "(?i).+\\.(png|jpe?g|webp|svg)$", message = "허용되지 않는 확장자입니다.")
String fileName,

@NotBlank
String contentType,

@NotNull
@Max(5L * 1024 * 1024)
Long byteSize,

@NotNull
ImagePurpose purpose
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response;

import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset;
import java.time.Instant;

public record ImageAssetResponse(
Long id,
String url,
String originalName,
Instant createdAt
) {
public static ImageAssetResponse from(ImageAsset imageAsset) {
return new ImageAssetResponse(
imageAsset.getId(), imageAsset.getUrl(), imageAsset.getOriginalName(), imageAsset.getCreatedAt());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response;

public record ImageCompleteResponse(
String url
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response;

public record PresignedUrlResponse(
Long imageId,
String uploadUrl,
String publicUrl
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.bcsdlab.bcsdinternalapiv2.media.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

/**
* 미디어 라이브러리 한 항목(ADR-009). 콘텐츠 테이블과 FK로 묶지 않는다 — 이미지 행 삭제가
* 콘텐츠를 깨뜨리지 않게 하기 위해서다. {@code is_confirmed}가 true인(=업로드가
* {@code complete}로 끝난) 행만 라이브러리 목록에 보인다(AC-4.3, AC-4.4).
*
* <p>{@code image_asset} 테이블에는 {@code updated_at}이 없다 — 업로드 후 수정 없이
* 생성/확정만 하므로 {@link com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity}를 쓰지
* 않고 직접 매핑한다. soft delete도 쓰지 않는다(라이브러리 항목은 물리 삭제 대상이 아니다).
*/
@Getter
@Entity
@Table(name = "image_asset")
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ImageAsset {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "s3_key", nullable = false, updatable = false, unique = true)
private String s3Key;

@Column(name = "url", nullable = false, updatable = false)
private String url;

@Column(name = "original_name", nullable = false, updatable = false)
private String originalName;

@Column(name = "content_type", nullable = false, updatable = false)
private String contentType;

@Column(name = "byte_size", nullable = false, updatable = false)
private long byteSize;

@Enumerated(EnumType.STRING)
@Column(name = "purpose", nullable = false, updatable = false)
private ImagePurpose purpose;

@Column(name = "is_confirmed", nullable = false)
private boolean confirmed;

@Column(name = "uploaded_by", updatable = false)
private Long uploadedBy;

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

@Builder
private ImageAsset(String s3Key, String url, String originalName, String contentType, long byteSize,
ImagePurpose purpose, Long uploadedBy) {
this.s3Key = s3Key;
this.url = url;
this.originalName = originalName;
this.contentType = contentType;
this.byteSize = byteSize;
this.purpose = purpose;
this.confirmed = false;
this.uploadedBy = uploadedBy;
}

public void confirm() {
this.confirmed = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.bcsdlab.bcsdinternalapiv2.media.model;

public enum ImagePurpose {
TRACK_HERO,
STUDY_ICON,
TECH_ICON,
ACTIVITY,
ACTIVITY_CONTENT,
ETC
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.bcsdlab.bcsdinternalapiv2.media.repository;

import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset;
import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ImageAssetRepository extends JpaRepository<ImageAsset, Long> {

Page<ImageAsset> findAllByPurposeAndConfirmedTrueOrderByCreatedAtDesc(ImagePurpose purpose, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.bcsdlab.bcsdinternalapiv2.media.service;

import com.bcsdlab.bcsdinternalapiv2.global.config.S3Properties;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse;
import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse;
import com.bcsdlab.bcsdinternalapiv2.media.exception.MediaException;
import com.bcsdlab.bcsdinternalapiv2.media.exception.MediaExceptionType;
import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset;
import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose;
import com.bcsdlab.bcsdinternalapiv2.media.repository.ImageAssetRepository;
import java.time.Duration;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

/**
* presigned URL 발급 → 브라우저가 S3에 직접 PUT → complete 등록(ADR-009). 서버는 이미지
* 바이트를 경유하지 않는다 — API 컨테이너 메모리 한도(256MiB) 때문에 서버 측 이미지
* 변환·버퍼링을 도입하지 않는다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdminImageService {

private static final Duration UPLOAD_URL_VALIDITY = Duration.ofMinutes(10);

private final ImageAssetRepository imageAssetRepository;
private final S3Presigner s3Presigner;
private final S3Properties s3Properties;

@Transactional
public PresignedUrlResponse issuePresignedUrl(PresignedUrlRequest request) {
String s3Key = "%s/%s.%s".formatted(
request.purpose().name().toLowerCase(), UUID.randomUUID(), extensionOf(request.fileName()));
String publicUrl = s3Properties.publicBaseUrl() + "/" + s3Key;

ImageAsset image = imageAssetRepository.save(ImageAsset.builder()
.s3Key(s3Key)
.url(publicUrl)
.originalName(request.fileName())
.contentType(request.contentType())
.byteSize(request.byteSize())
.purpose(request.purpose())
.build());

String uploadUrl = presignPutUrl(s3Key, request.contentType());
return new PresignedUrlResponse(image.getId(), uploadUrl, publicUrl);
}

@Transactional
public ImageCompleteResponse complete(Long id) {
ImageAsset image = findOrThrow(id);
image.confirm();
return new ImageCompleteResponse(image.getUrl());
}

public Page<ImageAssetResponse> getImages(ImagePurpose purpose, Pageable pageable) {
return imageAssetRepository.findAllByPurposeAndConfirmedTrueOrderByCreatedAtDesc(purpose, pageable)
.map(ImageAssetResponse::from);
}

private String presignPutUrl(String s3Key, String contentType) {
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(s3Properties.bucket())
.key(s3Key)
.contentType(contentType)
.build();
PresignedPutObjectRequest presigned = s3Presigner.presignPutObject(PutObjectPresignRequest.builder()
.signatureDuration(UPLOAD_URL_VALIDITY)
.putObjectRequest(putObjectRequest)
.build());
return presigned.url().toString();
}

private String extensionOf(String fileName) {
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
}

private ImageAsset findOrThrow(Long id) {
return imageAssetRepository.findById(id)
.orElseThrow(() -> new MediaException(MediaExceptionType.IMAGE_NOT_FOUND));
}
}
2 changes: 2 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,5 @@ app:
ses-region: ${AWS_SES_REGION:us-west-2}
s3:
region: ${AWS_S3_REGION:us-west-2}
bucket: ${AWS_S3_BUCKET:bcsd-internal}
public-base-url: ${AWS_S3_PUBLIC_BASE_URL:https://image.bcsdlab.com}
Loading