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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,16 @@ JWT_ACCESS_TOKEN_EXPIRY=3600000
DB_URL=jdbc:mysql://localhost:3306/stream?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
DB_USERNAME=stream
DB_PASSWORD=replace-with-db-password

### infrastructure:client — 파일 스토리지 (local | s3) ###
FILE_STORAGE_TYPE=local

# type=local일 때
LOCAL_STORAGE_BASE_PATH=./local-storage
LOCAL_STORAGE_BASE_URL=http://localhost:8080
LOCAL_STORAGE_UPLOAD_URL_EXPIRY_SECONDS=600

# type=s3일 때. 자격증명은 앱에 넣지 않는다 — EC2 배포 시 인스턴스 프로필(IAM 역할)에서 자동으로 가져온다
AWS_S3_BUCKET=replace-with-bucket-name
AWS_REGION=ap-northeast-2
S3_UPLOAD_URL_EXPIRY_SECONDS=600
1 change: 1 addition & 0 deletions api/admin-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**"
dependencies {
implementation(project(":api:common-api"))
implementation(project(":core:common"))
implementation(project(":core:domain:internal"))
implementation(project(":gateway:auth"))
implementation(project(":gateway:logging"))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package kr.ac.kookmin.stream.internal;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.io.IOException;
import kr.ac.kookmin.stream.ApiResponse;
import kr.ac.kookmin.stream.common.PrincipalProvider;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult;
import kr.ac.kookmin.stream.internal.domain.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/admin/files")
@RequiredArgsConstructor
public class AdminFileController {

private static final String LOCAL_UPLOAD_PATH = "/local-upload/";

private final FileService fileService;
private final PrincipalProvider principalProvider;

@PostMapping("/presigned-url")
public ApiResponse<FileUploadUrlIssueResponse> issuePresignedUrl(@Valid @RequestBody FileUploadUrlIssueRequest request) {
FileUploadUrlIssueResult result = fileService.issuePresignedUrl(request.toCommand(principalProvider.userId()));
return ApiResponse.success(FileUploadUrlIssueResponse.from(result));
}

@DeleteMapping("/{fileId}")
public ApiResponse<Void> delete(@PathVariable Long fileId) {
fileService.delete(fileId);
return ApiResponse.success();
}

/**
* S3 연동 전 임시 엔드포인트. presigned-url 발급 응답의 uploadUrl이 이 경로를 가리킨다.
* S3로 전환하면 이 메서드와 LocalFileStorageClient의 로컬 구현을 함께 제거한다.
*/
@PutMapping("/local-upload/**")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

임시 엔드포인트는 추후 삭제 편의를 위해 별도 컨트롤러 클래스를 만들어서 분리하는 건 어떤가요? AdminLocalFileUploadController 정도가 적당하겠네요!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추천 감사합니다! 원래는 한 메서드에 넣은 후 주석으로 명시해서 추후에 수정하는 방식으로 진행하려고했는데, 말씀해주신 방식이 더 좋을 것 같네요, 수정하도록 하겠습니다!

public ApiResponse<Void> receiveLocalUpload(HttpServletRequest request) throws IOException {
String fileKey = extractFileKey(request);
fileService.receiveUpload(fileKey, request.getInputStream());
return ApiResponse.success();
}

private String extractFileKey(HttpServletRequest request) {
String uri = request.getRequestURI();
int index = uri.indexOf(LOCAL_UPLOAD_PATH);
return uri.substring(index + LOCAL_UPLOAD_PATH.length());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.ac.kookmin.stream.internal;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand;

public record FileUploadUrlIssueRequest(
@NotBlank(message = "파일명은 필수 입력값입니다.")
String originalName,

@NotBlank(message = "MIME 타입은 필수 입력값입니다.")
String contentType,

@Positive(message = "파일 크기는 0보다 커야 합니다.")
long fileSize,

@NotNull(message = "파일 카테고리는 필수 입력값입니다.")
FileCategory category
) {
public FileUploadUrlIssueCommand toCommand(Long uploaderId) {
return new FileUploadUrlIssueCommand(originalName, contentType, fileSize, category, uploaderId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package kr.ac.kookmin.stream.internal;

import java.time.LocalDateTime;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult;

public record FileUploadUrlIssueResponse(
Long fileId,
String uploadUrl,
String fileKey,
LocalDateTime expiresAt
) {
public static FileUploadUrlIssueResponse from(FileUploadUrlIssueResult result) {
return new FileUploadUrlIssueResponse(result.fileId(), result.uploadUrl(), result.fileKey(), result.expiresAt());
}
}
1 change: 1 addition & 0 deletions bootstrap/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ spring:
import:
- classpath:application-gateway-auth.yml
- classpath:application-infrastructure-db.yml
- classpath:application-infrastructure-client.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.ac.kookmin.stream.internal.domain.file.client;

import java.io.InputStream;
import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl;

public interface FileStorageClient {
UploadUrl issuePresignedUrl(String fileKey, String contentType);
void write(String fileKey, InputStream content);
void deleteObject(String fileKey);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.ac.kookmin.stream.internal.domain.file.domain;

import kr.ac.kookmin.stream.common.ErrorCode;
import kr.ac.kookmin.stream.common.ErrorStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.experimental.Accessors;

@Getter
@Accessors(fluent = true)
@AllArgsConstructor
public enum FileErrorCode implements ErrorCode {

FILE_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 파일입니다."),
UNSUPPORTED_FILE_EXTENSION(ErrorStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."),
FILE_SIZE_EXCEEDED(ErrorStatus.BAD_REQUEST, "파일의 최대 업로드 용량을 초과했습니다.");

private final int status;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.ac.kookmin.stream.internal.domain.file.domain;

public record FileUploadUrlIssueCommand(
String originalName,
String contentType,
long fileSize,
FileCategory category,
Long uploaderId
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.ac.kookmin.stream.internal.domain.file.domain;

import java.time.LocalDateTime;

public record FileUploadUrlIssueResult(
Long fileId,
String uploadUrl,
String fileKey,
LocalDateTime expiresAt
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package kr.ac.kookmin.stream.internal.domain.file.domain;

import java.time.LocalDateTime;

public record UploadUrl(String url, LocalDateTime expiresAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.ac.kookmin.stream.internal.domain.file.repository;

import java.util.Optional;
import kr.ac.kookmin.stream.internal.domain.file.domain.File;

public interface FileRepository {
Optional<File> findById(Long id);
Optional<File> findByFileKey(String fileKey);
File save(File file);
void deleteById(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.ac.kookmin.stream.internal.domain.file.service;

import java.io.InputStream;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult;

public interface FileService {
FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command);
void receiveUpload(String fileKey, InputStream content);
void delete(Long fileId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package kr.ac.kookmin.stream.internal.domain.file.service.impl;

import java.io.InputStream;
import java.util.UUID;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.internal.domain.file.domain.File;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueCommand;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileUploadUrlIssueResult;
import kr.ac.kookmin.stream.internal.domain.file.domain.UploadUrl;
import kr.ac.kookmin.stream.internal.domain.file.client.FileStorageClient;
import kr.ac.kookmin.stream.internal.domain.file.repository.FileRepository;
import kr.ac.kookmin.stream.internal.domain.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
class FileServiceImpl implements FileService {

private static final String FILE_KEY_PREFIX = "files/";

private final FileRepository fileRepository;
private final FileStorageClient fileStorageClient;

@Override
@Transactional
public FileUploadUrlIssueResult issuePresignedUrl(FileUploadUrlIssueCommand command) {
FileUploadPolicy.validate(command.category(), command.originalName(), command.fileSize());

String fileKey = generateFileKey(command.originalName());
UploadUrl uploadUrl = fileStorageClient.issuePresignedUrl(fileKey, command.contentType());

File file = File.of(
null,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

객체를 생성할 때 id를 null로 집어넣어서 생성한 후에 Repository에 save하는 것보다는 File.create()를 새로 만들어서 id를 받지 않는 정적 팩토리 메서드를 만드는 게 어떨까요?
id에 null을 집어넣는 맥락이 불필요한 코드같습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 추천해주신 대로 create() 와 of() 구분하여 정적 팩토리 메서드 추가했습니다!

fileKey,
command.category(),
command.originalName(),
command.fileSize(),
command.contentType(),
command.uploaderId()
);
File saved = fileRepository.save(file);

return new FileUploadUrlIssueResult(saved.getId(), uploadUrl.url(), fileKey, uploadUrl.expiresAt());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정적 팩토리 메서드로 객체 생성 통일하면 좋을 거 같아요~

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컨벤션 문서 상 "record(도메인 객체·Command·Request/Response): 표준 생성자를 쓴다." 라고 나와있어 위와 같이 짰는데, "단 타입 변환이 끼면 from(...)/toCommand()를 둔다" - 이 부분 확인했습니다!
혹시 coding-style.md에는 값 여러개를 받아 조합하는 경우 of()를 쓴다고 나와있고, 현재 이 로직은 File과 UploadUrl 두 개를 합쳐서 만드는 것인데 이 경우 from()과 of() 중에 어떤 걸 사용하면 될까요?

}

@Override
@Transactional(readOnly = true)
public void receiveUpload(String fileKey, InputStream content) {
fileRepository.findByFileKey(fileKey)
.orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND));
fileStorageClient.write(fileKey, content);
}

@Override
@Transactional
public void delete(Long fileId) {
File file = fileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(FileErrorCode.FILE_NOT_FOUND));
fileStorageClient.deleteObject(file.getFileKey());
fileRepository.deleteById(fileId);
}

private String generateFileKey(String originalName) {
String extension = FileUploadPolicy.extractExtension(originalName);
String key = UUID.randomUUID().toString();
return extension.isEmpty() ? FILE_KEY_PREFIX + key : FILE_KEY_PREFIX + key + "." + extension;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package kr.ac.kookmin.stream.internal.domain.file.service.impl;

import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileCategory;
import kr.ac.kookmin.stream.internal.domain.file.domain.FileErrorCode;

final class FileUploadPolicy {

private static final Set<String> IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp");
private static final Set<String> ATTACHMENT_EXTENSIONS = Set.of("pdf", "doc", "docx", "hwp", "zip");

private static final long IMAGE_MAX_FILE_SIZE = 10 * 1024 * 1024;
private static final long ATTACHMENT_MAX_FILE_SIZE = 20 * 1024 * 1024;

private static final Map<FileCategory, Set<String>> ALLOWED_EXTENSIONS = Map.of(
FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS),
FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_EXTENSIONS,
FileCategory.NOTICE_IMAGE, IMAGE_EXTENSIONS,
FileCategory.EVENT_IMAGE, IMAGE_EXTENSIONS,
FileCategory.ARCHIVE_IMAGE, IMAGE_EXTENSIONS
);

private static final Map<FileCategory, Long> MAX_FILE_SIZE = Map.of(
FileCategory.TEMP, ATTACHMENT_MAX_FILE_SIZE,
FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_MAX_FILE_SIZE,
FileCategory.NOTICE_IMAGE, IMAGE_MAX_FILE_SIZE,
FileCategory.EVENT_IMAGE, IMAGE_MAX_FILE_SIZE,
FileCategory.ARCHIVE_IMAGE, IMAGE_MAX_FILE_SIZE
);

private FileUploadPolicy() {}

static void validate(FileCategory category, String originalName, long fileSize) {
String extension = extractExtension(originalName);
if (!ALLOWED_EXTENSIONS.get(category).contains(extension)) {
throw new BusinessException(FileErrorCode.UNSUPPORTED_FILE_EXTENSION);
}
if (fileSize <= 0 || fileSize > MAX_FILE_SIZE.get(category)) {
throw new BusinessException(FileErrorCode.FILE_SIZE_EXCEEDED);
}
}

static String extractExtension(String originalName) {
int dotIndex = originalName.lastIndexOf('.');
if (dotIndex == -1 || dotIndex == originalName.length() - 1) {
return "";
}
return originalName.substring(dotIndex + 1).toLowerCase();
}

private static Set<String> union(Set<String> first, Set<String> second) {
return Stream.concat(first.stream(), second.stream())
.collect(Collectors.toUnmodifiableSet());
}
}
3 changes: 2 additions & 1 deletion docs/conventions/00-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼
| 문서 | 다루는 내용 | 언제 참조하는가 |
| --- | --- | --- |
| [`architecture.md`](./architecture.md) | 모듈 구조, 의존 방향, Modulith 경계 규칙, 레이어, 도메인 간 통신(UseCase·이벤트·아웃박스) | 새 모듈/도메인 설계, 의존성 리뷰 |
| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 |
| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Client/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 |
| [`error-handling.md`](./error-handling.md) | `ErrorCode`/`BusinessException`, `GlobalExceptionHandler`, `@ApiErrorCode` Swagger 문서화 | 에러 코드 추가, 예외 처리 |
| [`config-and-auth.md`](./config-and-auth.md) | 설정 바인딩, 2계층 권한 모델(role + 부서), `PrincipalProvider`, `DepartmentAccessChecker` | 설정값 추가, 인증·인가 작업 |
| [`logging.md`](./logging.md) | MDC 요청 추적, `MdcFilter`/`LoggingFilter`, 로그 레벨, JSON 로깅 | 로깅 코드, MDC 필드 추가 |
Expand All @@ -29,6 +29,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼
- "부서 권한으로 승인 제한" → `config-and-auth.md` 4-4절 (`DepartmentAccessChecker`)
- "soft delete 컬럼 인덱스/유니크" → `flyway-migration.md` 3-4절
- "객체를 어떻게 생성하지 / Lombok 어디까지" → `coding-style.md` 2-10·2-11절
- "외부 클라이언트 구현체가 여러 개인데 일부 메서드가 특정 구현체에서 의미 없음" → `coding-style.md` 2-12절 (`UnsupportedOperationException` + `@ConditionalOnProperty` 스위칭)
- "에러 코드 추가" → `error-handling.md`
- "도메인 내부 구현 숨기기 / 패키지 구조" → `architecture.md` 4-3절 (`domain/{도메인}/{domain|repository|service|service.impl}`, OPEN 모듈 + ArchUnit)

Expand Down
Loading