init
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package com.cadlabel;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@MapperScan("com.cadlabel.mapper")
|
||||
@ConfigurationPropertiesScan
|
||||
@SpringBootApplication
|
||||
public class CadLabelApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CadLabelApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cadlabel.common.api;
|
||||
|
||||
public final class ApiCode {
|
||||
|
||||
public static final String OK = "OK";
|
||||
public static final String NOT_FOUND = "NOT_FOUND";
|
||||
public static final String INVALID_REQUEST = "INVALID_REQUEST";
|
||||
public static final String INTERNAL_ERROR = "INTERNAL_ERROR";
|
||||
|
||||
private ApiCode() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cadlabel.common.api;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private static final String SUCCESS_MESSAGE = "success";
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
private final T data;
|
||||
private final String requestId;
|
||||
|
||||
private ApiResponse(String code, String message, T data, String requestId) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
this.requestId = requestId;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(ApiCode.OK, SUCCESS_MESSAGE, data, RequestIdHolder.get());
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> failure(String code, String message) {
|
||||
return new ApiResponse<>(code, message, null, RequestIdHolder.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cadlabel.common.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> items,
|
||||
long page,
|
||||
long size,
|
||||
long total
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.cadlabel.common.api;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
|
||||
public final class RequestIdHolder {
|
||||
|
||||
private static final String KEY = "requestId";
|
||||
|
||||
private RequestIdHolder() {
|
||||
}
|
||||
|
||||
public static void set(String requestId) {
|
||||
MDC.put(KEY, requestId);
|
||||
}
|
||||
|
||||
public static String get() {
|
||||
return MDC.get(KEY);
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
MDC.remove(KEY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cadlabel.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class BizException extends RuntimeException {
|
||||
|
||||
private final int httpStatus;
|
||||
private final String code;
|
||||
|
||||
public BizException(int httpStatus, String code, String message) {
|
||||
super(message);
|
||||
this.httpStatus = httpStatus;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.cadlabel.common.exception;
|
||||
|
||||
import com.cadlabel.common.api.ApiCode;
|
||||
import com.cadlabel.common.api.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BizException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBiz(BizException exception) {
|
||||
return ResponseEntity.status(exception.getHttpStatus())
|
||||
.body(ApiResponse.failure(exception.getCode(), exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
MethodArgumentNotValidException.class,
|
||||
BindException.class,
|
||||
ConstraintViolationException.class,
|
||||
HttpMediaTypeNotSupportedException.class,
|
||||
HttpRequestMethodNotSupportedException.class,
|
||||
IllegalArgumentException.class
|
||||
})
|
||||
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception) {
|
||||
return ResponseEntity.unprocessableEntity()
|
||||
.body(ApiResponse.failure(ApiCode.INVALID_REQUEST, exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(MaxUploadSizeExceededException exception) {
|
||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||
.body(ApiResponse.failure(ApiCode.INVALID_REQUEST, "上传文件超过大小限制"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataAccessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDataAccess(DataAccessException exception, HttpServletRequest request) {
|
||||
log.error("Database exception at {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.failure(ApiCode.INTERNAL_ERROR, "数据库异常"));
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
NoResourceFoundException.class,
|
||||
NoHandlerFoundException.class
|
||||
})
|
||||
public ResponseEntity<ApiResponse<Void>> handleNotFound(HttpServletRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.failure(ApiCode.NOT_FOUND, "请求路径不存在: %s %s"
|
||||
.formatted(request.getMethod(), request.getRequestURI())));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnknown(Exception exception, HttpServletRequest request) {
|
||||
log.error("Unhandled exception at {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.failure(ApiCode.INTERNAL_ERROR, "系统异常"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.cadlabel.common.web;
|
||||
|
||||
import com.cadlabel.common.api.RequestIdHolder;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class RequestIdFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
|
||||
.filter(value -> !value.isBlank())
|
||||
.orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));
|
||||
RequestIdHolder.set(requestId);
|
||||
response.setHeader("X-Request-Id", requestId);
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
RequestIdHolder.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cadlabel.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.cos")
|
||||
public record CosProperties(
|
||||
String secretId,
|
||||
String secretKey,
|
||||
String bucket,
|
||||
String region,
|
||||
String publicBaseUrl,
|
||||
Duration uploadSessionTtl,
|
||||
String uploadPrefix,
|
||||
long maxUploadSizeBytes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.cadlabel.config;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
|
||||
private static final ZoneId ZONE_ID = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
|
||||
return builder -> {
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addSerializer(LocalDateTime.class, new LocalDateTimeJsonSerializer());
|
||||
module.addDeserializer(LocalDateTime.class, new LocalDateTimeJsonDeserializer());
|
||||
module.addSerializer(Instant.class, new InstantJsonSerializer());
|
||||
module.addDeserializer(Instant.class, new InstantJsonDeserializer());
|
||||
builder.modules(module);
|
||||
builder.simpleDateFormat(DATE_TIME_PATTERN);
|
||||
builder.timeZone(ZONE_ID.getId());
|
||||
};
|
||||
}
|
||||
|
||||
private static class LocalDateTimeJsonSerializer extends JsonSerializer<LocalDateTime> {
|
||||
|
||||
@Override
|
||||
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
gen.writeString(value.format(DATE_TIME_FORMATTER));
|
||||
}
|
||||
}
|
||||
|
||||
private static class LocalDateTimeJsonDeserializer extends JsonDeserializer<LocalDateTime> {
|
||||
|
||||
@Override
|
||||
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
return LocalDateTime.parse(p.getValueAsString(), DATE_TIME_FORMATTER);
|
||||
}
|
||||
}
|
||||
|
||||
private static class InstantJsonSerializer extends JsonSerializer<Instant> {
|
||||
|
||||
@Override
|
||||
public void serialize(Instant value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
gen.writeString(DATE_TIME_FORMATTER.format(value.atZone(ZONE_ID).toLocalDateTime()));
|
||||
}
|
||||
}
|
||||
|
||||
private static class InstantJsonDeserializer extends JsonDeserializer<Instant> {
|
||||
|
||||
@Override
|
||||
public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
return LocalDateTime.parse(p.getValueAsString(), DATE_TIME_FORMATTER).atZone(ZONE_ID).toInstant();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.cadlabel.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MetaObjectHandler metaObjectHandler() {
|
||||
return new MetaObjectHandler() {
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
|
||||
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.cadlabel.controller;
|
||||
|
||||
import com.cadlabel.common.api.ApiResponse;
|
||||
import com.cadlabel.dto.cos.CosUploadObjectResponse;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
|
||||
import com.cadlabel.service.CosService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cos")
|
||||
@RequiredArgsConstructor
|
||||
public class CosController {
|
||||
|
||||
private final CosService cosService;
|
||||
|
||||
@PostMapping("/upload-sessions")
|
||||
public ApiResponse<PrepareCosUploadSessionResponse> prepareUploadSession(
|
||||
@Valid @RequestBody(required = false) PrepareCosUploadSessionRequest request
|
||||
) {
|
||||
return ApiResponse.success(cosService.prepareUploadSession(request));
|
||||
}
|
||||
|
||||
@PostMapping("/objects")
|
||||
public ApiResponse<CosUploadObjectResponse> uploadObject(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(required = false) String directory
|
||||
) {
|
||||
return ApiResponse.success(cosService.uploadObject(file, directory));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cadlabel.dto.cos;
|
||||
|
||||
public record CosTemporaryCredentialsResponse(
|
||||
String tmpSecretId,
|
||||
String tmpSecretKey,
|
||||
String sessionToken,
|
||||
long startTime,
|
||||
long expiredTime
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cadlabel.dto.cos;
|
||||
|
||||
public record CosUploadObjectResponse(
|
||||
String bucket,
|
||||
String region,
|
||||
String objectKey,
|
||||
String publicUrl,
|
||||
long size,
|
||||
String contentType
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.dto.cos;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
public record PrepareCosUploadSessionRequest(
|
||||
String directory,
|
||||
Duration duration
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.dto.cos;
|
||||
|
||||
public record PrepareCosUploadSessionResponse(
|
||||
String bucket,
|
||||
String region,
|
||||
String keyPrefix,
|
||||
CosTemporaryCredentialsResponse credentials
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cadlabel.service;
|
||||
|
||||
import com.cadlabel.dto.cos.CosUploadObjectResponse;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface CosService {
|
||||
|
||||
PrepareCosUploadSessionResponse prepareUploadSession(PrepareCosUploadSessionRequest request);
|
||||
|
||||
CosUploadObjectResponse uploadObject(MultipartFile file, String directory);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cadlabel.service.client;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
|
||||
public interface CosObjectStorageClient {
|
||||
|
||||
TemporaryCredentials issueTemporaryCredentials(String keyPrefix, Duration duration);
|
||||
|
||||
void putObject(String objectKey, InputStream inputStream, long contentLength, String contentType);
|
||||
|
||||
record TemporaryCredentials(
|
||||
String tmpSecretId,
|
||||
String tmpSecretKey,
|
||||
String sessionToken,
|
||||
long startTime,
|
||||
long expiredTime
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.cadlabel.service.client;
|
||||
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.config.CosProperties;
|
||||
import com.qcloud.cos.COSClient;
|
||||
import com.qcloud.cos.ClientConfig;
|
||||
import com.qcloud.cos.auth.BasicCOSCredentials;
|
||||
import com.qcloud.cos.exception.CosClientException;
|
||||
import com.qcloud.cos.model.ObjectMetadata;
|
||||
import com.qcloud.cos.model.PutObjectRequest;
|
||||
import com.qcloud.cos.region.Region;
|
||||
import com.tencent.cloud.CosStsClient;
|
||||
import com.tencent.cloud.Response;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.TreeMap;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TencentCosObjectStorageClient implements CosObjectStorageClient, DisposableBean {
|
||||
|
||||
private static final String[] ALLOWED_ACTIONS = new String[]{
|
||||
"name/cos:PutObject",
|
||||
"name/cos:InitiateMultipartUpload",
|
||||
"name/cos:ListMultipartUploads",
|
||||
"name/cos:ListParts",
|
||||
"name/cos:UploadPart",
|
||||
"name/cos:CompleteMultipartUpload",
|
||||
"name/cos:AbortMultipartUpload"
|
||||
};
|
||||
|
||||
private final CosProperties cosProperties;
|
||||
private final COSClient cosClient;
|
||||
|
||||
public TencentCosObjectStorageClient(CosProperties cosProperties) {
|
||||
this.cosProperties = cosProperties;
|
||||
this.cosClient = createCosClient(cosProperties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TemporaryCredentials issueTemporaryCredentials(String keyPrefix, Duration duration) {
|
||||
validateConfiguration();
|
||||
int durationSeconds = validateDuration(duration);
|
||||
TreeMap<String, Object> config = new TreeMap<>();
|
||||
config.put("secretId", cosProperties.secretId());
|
||||
config.put("secretKey", cosProperties.secretKey());
|
||||
config.put("bucket", cosProperties.bucket());
|
||||
config.put("region", cosProperties.region());
|
||||
config.put("durationSeconds", durationSeconds);
|
||||
config.put("allowPrefixes", new String[]{normalizeKeyPrefix(keyPrefix) + "*"});
|
||||
config.put("allowActions", ALLOWED_ACTIONS);
|
||||
try {
|
||||
Response response = CosStsClient.getCredential(config);
|
||||
if (response == null || response.credentials == null
|
||||
|| !StringUtils.hasText(response.credentials.tmpSecretId)
|
||||
|| !StringUtils.hasText(response.credentials.tmpSecretKey)
|
||||
|| !StringUtils.hasText(response.credentials.sessionToken)) {
|
||||
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_UPLOAD_CREDENTIALS_FAILED", "COS 未返回完整的临时上传凭证");
|
||||
}
|
||||
return new TemporaryCredentials(
|
||||
response.credentials.tmpSecretId,
|
||||
response.credentials.tmpSecretKey,
|
||||
response.credentials.sessionToken,
|
||||
response.startTime,
|
||||
response.expiredTime
|
||||
);
|
||||
} catch (IOException exception) {
|
||||
log.error("Failed to issue COS upload credentials. keyPrefix={}", keyPrefix, exception);
|
||||
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_UPLOAD_CREDENTIALS_FAILED",
|
||||
"申请 COS 临时上传凭证失败: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putObject(String objectKey, InputStream inputStream, long contentLength, String contentType) {
|
||||
validateConfiguration();
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentLength(contentLength);
|
||||
if (StringUtils.hasText(contentType)) {
|
||||
metadata.setContentType(contentType);
|
||||
}
|
||||
try {
|
||||
cosClient.putObject(new PutObjectRequest(cosProperties.bucket(), normalizeKeyPrefix(objectKey), inputStream, metadata));
|
||||
} catch (CosClientException exception) {
|
||||
log.error("Failed to upload COS object. objectKey={}", objectKey, exception);
|
||||
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_OBJECT_UPLOAD_FAILED",
|
||||
"上传 COS 对象失败: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
cosClient.shutdown();
|
||||
}
|
||||
|
||||
private static COSClient createCosClient(CosProperties cosProperties) {
|
||||
String region = StringUtils.hasText(cosProperties.region()) ? cosProperties.region().trim() : "ap-beijing";
|
||||
return new COSClient(
|
||||
new BasicCOSCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey())),
|
||||
new ClientConfig(new Region(region))
|
||||
);
|
||||
}
|
||||
|
||||
private void validateConfiguration() {
|
||||
requireText(cosProperties.secretId(), "APP_COS_SECRET_ID");
|
||||
requireText(cosProperties.secretKey(), "APP_COS_SECRET_KEY");
|
||||
requireText(cosProperties.bucket(), "APP_COS_BUCKET");
|
||||
requireText(cosProperties.region(), "APP_COS_REGION");
|
||||
}
|
||||
|
||||
private int validateDuration(Duration duration) {
|
||||
if (duration == null || duration.isZero() || duration.isNegative()) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 上传会话有效期配置非法");
|
||||
}
|
||||
long seconds = duration.toSeconds();
|
||||
if (seconds > Integer.MAX_VALUE) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 上传会话有效期配置过大");
|
||||
}
|
||||
return (int) seconds;
|
||||
}
|
||||
|
||||
private String normalizeKeyPrefix(String keyPrefix) {
|
||||
String normalized = defaultText(keyPrefix).trim().replace("\\", "/");
|
||||
while (normalized.startsWith("/")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void requireText(String value, String envName) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: " + envName);
|
||||
}
|
||||
}
|
||||
|
||||
private static String defaultText(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.cadlabel.config.CosProperties;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CosObjectUrlResolver {
|
||||
|
||||
private final CosProperties cosProperties;
|
||||
|
||||
public String resolvePublicUrl(String objectKey) {
|
||||
if (!StringUtils.hasText(objectKey)) {
|
||||
return "";
|
||||
}
|
||||
return resolveBaseUrl() + "/" + encodePath(objectKey.trim());
|
||||
}
|
||||
|
||||
private String resolveBaseUrl() {
|
||||
if (StringUtils.hasText(cosProperties.publicBaseUrl())) {
|
||||
return trimTrailingSlash(cosProperties.publicBaseUrl().trim());
|
||||
}
|
||||
return "https://%s.cos.%s.myqcloud.com".formatted(requireText(cosProperties.bucket()), requireText(cosProperties.region()));
|
||||
}
|
||||
|
||||
private String encodePath(String path) {
|
||||
return Arrays.stream(path.split("/", -1))
|
||||
.map(segment -> UriUtils.encodePathSegment(segment, StandardCharsets.UTF_8))
|
||||
.collect(Collectors.joining("/"));
|
||||
}
|
||||
|
||||
private String trimTrailingSlash(String value) {
|
||||
String result = value;
|
||||
while (result.endsWith("/")) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String requireText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.config.CosProperties;
|
||||
import com.cadlabel.dto.cos.CosTemporaryCredentialsResponse;
|
||||
import com.cadlabel.dto.cos.CosUploadObjectResponse;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
|
||||
import com.cadlabel.service.CosService;
|
||||
import com.cadlabel.service.client.CosObjectStorageClient;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CosServiceImpl implements CosService {
|
||||
|
||||
private static final String DEFAULT_UPLOAD_PREFIX = "uploads";
|
||||
private static final long DEFAULT_MAX_UPLOAD_SIZE_BYTES = 100L * 1024L * 1024L;
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE;
|
||||
|
||||
private final CosProperties cosProperties;
|
||||
private final CosObjectStorageClient cosObjectStorageClient;
|
||||
private final CosObjectUrlResolver cosObjectUrlResolver;
|
||||
|
||||
@Override
|
||||
public PrepareCosUploadSessionResponse prepareUploadSession(PrepareCosUploadSessionRequest request) {
|
||||
Duration duration = resolveDuration(request == null ? null : request.duration());
|
||||
String keyPrefix = buildKeyPrefix(request == null ? null : request.directory());
|
||||
CosObjectStorageClient.TemporaryCredentials credentials = cosObjectStorageClient.issueTemporaryCredentials(keyPrefix, duration);
|
||||
return new PrepareCosUploadSessionResponse(
|
||||
requireBucket(),
|
||||
requireRegion(),
|
||||
keyPrefix,
|
||||
new CosTemporaryCredentialsResponse(
|
||||
credentials.tmpSecretId(),
|
||||
credentials.tmpSecretKey(),
|
||||
credentials.sessionToken(),
|
||||
credentials.startTime(),
|
||||
credentials.expiredTime()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CosUploadObjectResponse uploadObject(MultipartFile file, String directory) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), "COS_UPLOAD_FILE_EMPTY", "上传文件不能为空");
|
||||
}
|
||||
long maxUploadSizeBytes = resolveMaxUploadSizeBytes();
|
||||
if (file.getSize() > maxUploadSizeBytes) {
|
||||
throw new BizException(HttpStatus.PAYLOAD_TOO_LARGE.value(), "COS_UPLOAD_FILE_TOO_LARGE", "上传文件超过大小限制");
|
||||
}
|
||||
String objectKey = buildKeyPrefix(directory) + sanitizeFilename(file.getOriginalFilename());
|
||||
try {
|
||||
cosObjectStorageClient.putObject(objectKey, file.getInputStream(), file.getSize(), file.getContentType());
|
||||
} catch (IOException exception) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_UPLOAD_FILE_READ_FAILED",
|
||||
"读取上传文件失败: " + exception.getMessage());
|
||||
}
|
||||
return new CosUploadObjectResponse(
|
||||
requireBucket(),
|
||||
requireRegion(),
|
||||
objectKey,
|
||||
cosObjectUrlResolver.resolvePublicUrl(objectKey),
|
||||
file.getSize(),
|
||||
file.getContentType()
|
||||
);
|
||||
}
|
||||
|
||||
private Duration resolveDuration(Duration requestedDuration) {
|
||||
Duration duration = requestedDuration == null ? cosProperties.uploadSessionTtl() : requestedDuration;
|
||||
if (duration == null) {
|
||||
duration = Duration.ofMinutes(30);
|
||||
}
|
||||
if (duration.isZero() || duration.isNegative()) {
|
||||
throw new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), "COS_UPLOAD_DURATION_INVALID", "COS 上传会话有效期非法");
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
private String buildKeyPrefix(String directory) {
|
||||
return normalizePath(resolveUploadPrefix()) + "/"
|
||||
+ LocalDate.now().format(DATE_FORMATTER) + "/"
|
||||
+ UUID.randomUUID() + "/"
|
||||
+ normalizeOptionalDirectory(directory);
|
||||
}
|
||||
|
||||
private String resolveUploadPrefix() {
|
||||
if (StringUtils.hasText(cosProperties.uploadPrefix())) {
|
||||
return cosProperties.uploadPrefix();
|
||||
}
|
||||
return DEFAULT_UPLOAD_PREFIX;
|
||||
}
|
||||
|
||||
private String normalizeOptionalDirectory(String directory) {
|
||||
String normalized = normalizePath(directory);
|
||||
if (!StringUtils.hasText(normalized)) {
|
||||
return "";
|
||||
}
|
||||
return normalized + "/";
|
||||
}
|
||||
|
||||
private String normalizePath(String path) {
|
||||
String normalized = path == null ? "" : path.trim().replace("\\", "/");
|
||||
String[] segments = normalized.split("/");
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String segment : segments) {
|
||||
String cleaned = sanitizePathSegment(segment);
|
||||
if (!StringUtils.hasText(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
if (!builder.isEmpty()) {
|
||||
builder.append('/');
|
||||
}
|
||||
builder.append(cleaned);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
String normalized = StringUtils.hasText(filename) ? filename.trim().replace("\\", "/") : "file";
|
||||
String basename = normalized.substring(normalized.lastIndexOf('/') + 1);
|
||||
String cleaned = sanitizePathSegment(basename);
|
||||
if (!StringUtils.hasText(cleaned)) {
|
||||
return "file";
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private String sanitizePathSegment(String segment) {
|
||||
if (!StringUtils.hasText(segment)) {
|
||||
return "";
|
||||
}
|
||||
String cleaned = segment.trim()
|
||||
.replace("..", "")
|
||||
.replaceAll("[^A-Za-z0-9._-]", "");
|
||||
while (cleaned.startsWith(".")) {
|
||||
cleaned = cleaned.substring(1);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private long resolveMaxUploadSizeBytes() {
|
||||
long configured = cosProperties.maxUploadSizeBytes();
|
||||
return configured > 0 ? configured : DEFAULT_MAX_UPLOAD_SIZE_BYTES;
|
||||
}
|
||||
|
||||
private String requireBucket() {
|
||||
if (!StringUtils.hasText(cosProperties.bucket())) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: APP_COS_BUCKET");
|
||||
}
|
||||
return cosProperties.bucket().trim();
|
||||
}
|
||||
|
||||
private String requireRegion() {
|
||||
if (!StringUtils.hasText(cosProperties.region())) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: APP_COS_REGION");
|
||||
}
|
||||
return cosProperties.region().trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user