feat: add Minio

This commit is contained in:
2026-09-15 23:08:38 +07:00
parent dc43e34271
commit 7f3fd7d1e6
13 changed files with 314 additions and 174 deletions
@@ -1,5 +1,6 @@
package aplp.backend.lms.common.api;
import aplp.backend.core.common.exception.DomainException;
import aplp.backend.core.common.exception.ErrorCode;
import aplp.backend.core.common.exception.ResourceNotFoundException;
import aplp.backend.core.common.response.ApiResponse;
@@ -27,6 +28,16 @@ public class GlobalExceptionHandler {
));
}
@ExceptionHandler(DomainException.class)
public ResponseEntity<ApiResponse<?>> handleDomainException(DomainException ex) {
return ResponseEntity
.status(ex.code().status())
.body(ApiResponse.error(
ex.code().name(),
ex.getMessage()
));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleException(
Exception ex
@@ -0,0 +1,29 @@
package aplp.backend.lms.common.storage;
import aplp.backend.core.common.response.ApiResponse;
import org.springframework.http.HttpStatus;
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;
@RestController
@RequestMapping("/api/files")
public class FileController {
private final FileStorageService fileStorageService;
public FileController(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}
@PostMapping
public ResponseEntity<ApiResponse<FileResDto>> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "folder", required = false, defaultValue = "general") String folder) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.ok(fileStorageService.upload(file, folder)));
}
}
@@ -0,0 +1,8 @@
package aplp.backend.lms.common.storage;
public record FileResDto(
String url,
String objectKey,
long size,
String contentType
) {}
@@ -0,0 +1,81 @@
package aplp.backend.lms.common.storage;
import aplp.backend.core.common.exception.DomainException;
import aplp.backend.core.common.exception.ErrorCode;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.Locale;
import java.util.UUID;
@Service
public class FileStorageService {
private final MinioClient minioClient;
private final StorageProperties properties;
public FileStorageService(MinioClient minioClient, StorageProperties properties) {
this.minioClient = minioClient;
this.properties = properties;
}
public FileResDto upload(MultipartFile file, String folder) {
if (file == null || file.isEmpty()) {
throw new DomainException(ErrorCode.INVALID_ARGUMENT, "File is required");
}
String contentType = file.getContentType();
if (contentType == null || !contentType.startsWith("image/")) {
throw new DomainException(ErrorCode.INVALID_ARGUMENT, "Only image files are allowed");
}
String objectKey = buildObjectKey(folder, contentType, file.getOriginalFilename());
try {
minioClient.putObject(PutObjectArgs.builder()
.bucket(properties.bucket())
.object(objectKey)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(contentType)
.build());
} catch (Exception e) {
throw new DomainException(ErrorCode.INTERNAL_ERROR, "Failed to upload file: " + e.getMessage());
}
return new FileResDto(
properties.publicUrl() + "/" + properties.bucket() + "/" + objectKey,
objectKey,
file.getSize(),
contentType
);
}
static String buildObjectKey(String folder, String contentType, String originalFilename) {
return sanitizeFolder(folder) + "/" + UUID.randomUUID() + "." + extensionFor(contentType, originalFilename);
}
static String sanitizeFolder(String folder) {
if (folder == null || folder.isBlank()) {
return "general";
}
String clean = folder.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "");
return clean.isEmpty() ? "general" : clean;
}
static String extensionFor(String contentType, String originalFilename) {
return switch (contentType) {
case "image/jpeg", "image/jpg" -> "jpg";
case "image/png" -> "png";
case "image/gif" -> "gif";
case "image/webp" -> "webp";
case "image/svg+xml" -> "svg";
default -> {
String name = originalFilename == null ? "" : originalFilename;
int dot = name.lastIndexOf('.');
String ext = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
yield ext.isEmpty() ? "img" : ext;
}
};
}
}
@@ -0,0 +1,61 @@
package aplp.backend.lms.common.storage;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.SetBucketPolicyArgs;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(StorageProperties.class)
public class MinioConfig {
private static final Logger log = LoggerFactory.getLogger(MinioConfig.class);
private static final String PUBLIC_READ_POLICY = """
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::%s/*"]
}
]
}
""";
private final StorageProperties properties;
public MinioConfig(StorageProperties properties) {
this.properties = properties;
}
@Bean
public MinioClient minioClient() {
MinioClient client = MinioClient.builder()
.endpoint(properties.endpoint())
.credentials(properties.accessKey(), properties.secretKey())
.build();
try {
if (!client.bucketExists(BucketExistsArgs.builder().bucket(properties.bucket()).build())) {
client.makeBucket(MakeBucketArgs.builder().bucket(properties.bucket()).build());
log.info("Created MinIO bucket '{}'", properties.bucket());
}
client.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(properties.bucket())
.config(PUBLIC_READ_POLICY.formatted(properties.bucket()))
.build());
} catch (Exception e) {
log.warn("MinIO bucket init skipped ({}): {}", properties.endpoint(), e.getMessage());
}
return client;
}
}
@@ -0,0 +1,12 @@
package aplp.backend.lms.common.storage;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "minio")
public record StorageProperties(
String endpoint,
String accessKey,
String secretKey,
String bucket,
String publicUrl
) {}
+11
View File
@@ -0,0 +1,11 @@
spring:
config:
activate:
on-profile: prod
minio:
endpoint: ${MINIO_ENDPOINT}
access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY}
bucket: ${MINIO_BUCKET}
public-url: ${MINIO_PUBLIC_URL}
+13 -1
View File
@@ -5,7 +5,7 @@ spring:
datasource:
url: jdbc:postgresql://pgsql.koda.id.vn:5432/aplp
username: postgres
password: Pa55w0rd
password: xxxx
jpa:
hibernate:
@@ -22,6 +22,18 @@ spring:
schemas: lms
locations: classpath:db/migration
servlet:
multipart:
max-file-size: 5MB
max-request-size: 5MB
minio:
endpoint: https://minio.koda.id.vn
access-key: CHANGE_ME
secret-key: CHANGE_ME
bucket: lms
public-url: https://minio.koda.id.vn
springdoc:
swagger-ui:
path: /swagger-ui