This commit is contained in:
lxp
2026-07-07 15:47:14 +08:00
parent 55d74653da
commit 234a166924
34 changed files with 1566 additions and 18 deletions
@@ -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();
}
}