diff --git a/.gitignore b/.gitignore index 5d947ca..6a34028 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,5 @@ -# Build and Release Folders -bin-debug/ -bin-release/ -[Oo]bj/ -[Bb]in/ - -# Other files and folders -.settings/ - -# Executables -*.swf -*.air -*.ipa -*.apk - -# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties` -# should NOT be excluded as they contain compiler settings and other important -# information for Eclipse / Flash Builder. +target/ +logs/ +.idea/ +*.iml +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..67e7aca --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# CadLabel Backend + +Spring Boot backend skeleton for CadLabel. + +## Stack + +- Java 21 +- Spring Boot 3.4.4 +- Maven +- MyBatis-Plus +- MySQL +- Tencent Cloud COS SDK + +This project intentionally does not include Spring Security, JWT, login APIs, or user-system code. + +## Run + +```bash +mvn test +mvn spring-boot:run +``` + +The default profile is `dev`. Configure these environment variables before starting against a real database and COS bucket: + +```bash +export APP_DB_URL='jdbc:mysql://localhost:3306/cadlabel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true' +export APP_DB_USERNAME='root' +export APP_DB_PASSWORD='' + +export APP_COS_SECRET_ID='' +export APP_COS_SECRET_KEY='' +export APP_COS_BUCKET='' +export APP_COS_REGION='ap-beijing' +export APP_COS_PUBLIC_BASE_URL='' +``` + +## COS APIs + +- `POST /api/cos/upload-sessions` issues temporary STS credentials for direct browser upload. +- `POST /api/cos/objects` accepts multipart file upload and stores the file through the backend. + +Both endpoints return the standard `ApiResponse` shape. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e066f13 --- /dev/null +++ b/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + + com.cadlabel + cadlabel + 0.0.1-SNAPSHOT + cadlabel + CadLabel backend service. + + + 21 + 3.5.11 + 2.8.6 + 5.6.246 + 3.1.1 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + com.mysql + mysql-connector-j + runtime + + + com.qcloud + cos_api + ${tencentcloud.cos.sdk.version} + + + com.qcloud + cos-sts_api + ${tencentcloud.cos.sts.sdk.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + org.springframework.boot + spring-boot-configuration-processor + + + + + + + + + + public + aliyun nexus + https://maven.aliyun.com/repository/public + + true + + + + diff --git a/src/main/java/com/cadlabel/CadLabelApplication.java b/src/main/java/com/cadlabel/CadLabelApplication.java new file mode 100644 index 0000000..9a8f232 --- /dev/null +++ b/src/main/java/com/cadlabel/CadLabelApplication.java @@ -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); + } +} diff --git a/src/main/java/com/cadlabel/common/api/ApiCode.java b/src/main/java/com/cadlabel/common/api/ApiCode.java new file mode 100644 index 0000000..77e6698 --- /dev/null +++ b/src/main/java/com/cadlabel/common/api/ApiCode.java @@ -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() { + } +} diff --git a/src/main/java/com/cadlabel/common/api/ApiResponse.java b/src/main/java/com/cadlabel/common/api/ApiResponse.java new file mode 100644 index 0000000..1cd3dfb --- /dev/null +++ b/src/main/java/com/cadlabel/common/api/ApiResponse.java @@ -0,0 +1,29 @@ +package com.cadlabel.common.api; + +import lombok.Getter; + +@Getter +public class ApiResponse { + + 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 ApiResponse success(T data) { + return new ApiResponse<>(ApiCode.OK, SUCCESS_MESSAGE, data, RequestIdHolder.get()); + } + + public static ApiResponse failure(String code, String message) { + return new ApiResponse<>(code, message, null, RequestIdHolder.get()); + } +} diff --git a/src/main/java/com/cadlabel/common/api/PageResponse.java b/src/main/java/com/cadlabel/common/api/PageResponse.java new file mode 100644 index 0000000..043bf82 --- /dev/null +++ b/src/main/java/com/cadlabel/common/api/PageResponse.java @@ -0,0 +1,11 @@ +package com.cadlabel.common.api; + +import java.util.List; + +public record PageResponse( + List items, + long page, + long size, + long total +) { +} diff --git a/src/main/java/com/cadlabel/common/api/RequestIdHolder.java b/src/main/java/com/cadlabel/common/api/RequestIdHolder.java new file mode 100644 index 0000000..73c4757 --- /dev/null +++ b/src/main/java/com/cadlabel/common/api/RequestIdHolder.java @@ -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); + } +} diff --git a/src/main/java/com/cadlabel/common/exception/BizException.java b/src/main/java/com/cadlabel/common/exception/BizException.java new file mode 100644 index 0000000..dcdcd3d --- /dev/null +++ b/src/main/java/com/cadlabel/common/exception/BizException.java @@ -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; + } +} diff --git a/src/main/java/com/cadlabel/common/exception/GlobalExceptionHandler.java b/src/main/java/com/cadlabel/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d675530 --- /dev/null +++ b/src/main/java/com/cadlabel/common/exception/GlobalExceptionHandler.java @@ -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> 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> handleBadRequest(Exception exception) { + return ResponseEntity.unprocessableEntity() + .body(ApiResponse.failure(ApiCode.INVALID_REQUEST, exception.getMessage())); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUploadSize(MaxUploadSizeExceededException exception) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(ApiResponse.failure(ApiCode.INVALID_REQUEST, "上传文件超过大小限制")); + } + + @ExceptionHandler(DataAccessException.class) + public ResponseEntity> 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> 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> 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, "系统异常")); + } +} diff --git a/src/main/java/com/cadlabel/common/web/RequestIdFilter.java b/src/main/java/com/cadlabel/common/web/RequestIdFilter.java new file mode 100644 index 0000000..53e1c9d --- /dev/null +++ b/src/main/java/com/cadlabel/common/web/RequestIdFilter.java @@ -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(); + } + } +} diff --git a/src/main/java/com/cadlabel/config/CosProperties.java b/src/main/java/com/cadlabel/config/CosProperties.java new file mode 100644 index 0000000..63f851d --- /dev/null +++ b/src/main/java/com/cadlabel/config/CosProperties.java @@ -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 +) { +} diff --git a/src/main/java/com/cadlabel/config/JacksonConfig.java b/src/main/java/com/cadlabel/config/JacksonConfig.java new file mode 100644 index 0000000..fa78e64 --- /dev/null +++ b/src/main/java/com/cadlabel/config/JacksonConfig.java @@ -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 { + + @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 { + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return LocalDateTime.parse(p.getValueAsString(), DATE_TIME_FORMATTER); + } + } + + private static class InstantJsonSerializer extends JsonSerializer { + + @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 { + + @Override + public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return LocalDateTime.parse(p.getValueAsString(), DATE_TIME_FORMATTER).atZone(ZONE_ID).toInstant(); + } + } +} diff --git a/src/main/java/com/cadlabel/config/MybatisPlusConfig.java b/src/main/java/com/cadlabel/config/MybatisPlusConfig.java new file mode 100644 index 0000000..a79b12b --- /dev/null +++ b/src/main/java/com/cadlabel/config/MybatisPlusConfig.java @@ -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()); + } + }; + } +} diff --git a/src/main/java/com/cadlabel/controller/CosController.java b/src/main/java/com/cadlabel/controller/CosController.java new file mode 100644 index 0000000..cabad26 --- /dev/null +++ b/src/main/java/com/cadlabel/controller/CosController.java @@ -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 prepareUploadSession( + @Valid @RequestBody(required = false) PrepareCosUploadSessionRequest request + ) { + return ApiResponse.success(cosService.prepareUploadSession(request)); + } + + @PostMapping("/objects") + public ApiResponse uploadObject( + @RequestParam("file") MultipartFile file, + @RequestParam(required = false) String directory + ) { + return ApiResponse.success(cosService.uploadObject(file, directory)); + } +} diff --git a/src/main/java/com/cadlabel/dto/cos/CosTemporaryCredentialsResponse.java b/src/main/java/com/cadlabel/dto/cos/CosTemporaryCredentialsResponse.java new file mode 100644 index 0000000..5035525 --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cos/CosTemporaryCredentialsResponse.java @@ -0,0 +1,10 @@ +package com.cadlabel.dto.cos; + +public record CosTemporaryCredentialsResponse( + String tmpSecretId, + String tmpSecretKey, + String sessionToken, + long startTime, + long expiredTime +) { +} diff --git a/src/main/java/com/cadlabel/dto/cos/CosUploadObjectResponse.java b/src/main/java/com/cadlabel/dto/cos/CosUploadObjectResponse.java new file mode 100644 index 0000000..c8f065b --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cos/CosUploadObjectResponse.java @@ -0,0 +1,11 @@ +package com.cadlabel.dto.cos; + +public record CosUploadObjectResponse( + String bucket, + String region, + String objectKey, + String publicUrl, + long size, + String contentType +) { +} diff --git a/src/main/java/com/cadlabel/dto/cos/PrepareCosUploadSessionRequest.java b/src/main/java/com/cadlabel/dto/cos/PrepareCosUploadSessionRequest.java new file mode 100644 index 0000000..25beb90 --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cos/PrepareCosUploadSessionRequest.java @@ -0,0 +1,9 @@ +package com.cadlabel.dto.cos; + +import java.time.Duration; + +public record PrepareCosUploadSessionRequest( + String directory, + Duration duration +) { +} diff --git a/src/main/java/com/cadlabel/dto/cos/PrepareCosUploadSessionResponse.java b/src/main/java/com/cadlabel/dto/cos/PrepareCosUploadSessionResponse.java new file mode 100644 index 0000000..d138868 --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cos/PrepareCosUploadSessionResponse.java @@ -0,0 +1,9 @@ +package com.cadlabel.dto.cos; + +public record PrepareCosUploadSessionResponse( + String bucket, + String region, + String keyPrefix, + CosTemporaryCredentialsResponse credentials +) { +} diff --git a/src/main/java/com/cadlabel/service/CosService.java b/src/main/java/com/cadlabel/service/CosService.java new file mode 100644 index 0000000..534cca8 --- /dev/null +++ b/src/main/java/com/cadlabel/service/CosService.java @@ -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); +} diff --git a/src/main/java/com/cadlabel/service/client/CosObjectStorageClient.java b/src/main/java/com/cadlabel/service/client/CosObjectStorageClient.java new file mode 100644 index 0000000..b455530 --- /dev/null +++ b/src/main/java/com/cadlabel/service/client/CosObjectStorageClient.java @@ -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 + ) { + } +} diff --git a/src/main/java/com/cadlabel/service/client/TencentCosObjectStorageClient.java b/src/main/java/com/cadlabel/service/client/TencentCosObjectStorageClient.java new file mode 100644 index 0000000..7374039 --- /dev/null +++ b/src/main/java/com/cadlabel/service/client/TencentCosObjectStorageClient.java @@ -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 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; + } +} diff --git a/src/main/java/com/cadlabel/service/impl/CosObjectUrlResolver.java b/src/main/java/com/cadlabel/service/impl/CosObjectUrlResolver.java new file mode 100644 index 0000000..bf860e7 --- /dev/null +++ b/src/main/java/com/cadlabel/service/impl/CosObjectUrlResolver.java @@ -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(); + } +} diff --git a/src/main/java/com/cadlabel/service/impl/CosServiceImpl.java b/src/main/java/com/cadlabel/service/impl/CosServiceImpl.java new file mode 100644 index 0000000..11ec6ac --- /dev/null +++ b/src/main/java/com/cadlabel/service/impl/CosServiceImpl.java @@ -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(); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..2de1668 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,25 @@ +spring: + config: + activate: + on-profile: dev + datasource: + url: ${APP_DB_URL:jdbc:mysql://49.232.4.148:3306/cadlabel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true} + username: ${APP_DB_USERNAME:root} + password: ${APP_DB_PASSWORD:mysql_QRWm4M} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + pool-name: CadLabelDevHikariCP + minimum-idle: 1 + maximum-pool-size: 10 + connection-timeout: 30000 + validation-timeout: 5000 + +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + +logging: + level: + com.cadlabel: DEBUG diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..3795364 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,37 @@ +spring: + config: + activate: + on-profile: prod + datasource: + url: ${APP_DB_URL} + username: ${APP_DB_USERNAME} + password: ${APP_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + pool-name: CadLabelProdHikariCP + minimum-idle: 5 + maximum-pool-size: 20 + connection-timeout: 30000 + validation-timeout: 5000 + idle-timeout: 600000 + max-lifetime: 1800000 + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + +server: + tomcat: + threads: + min-spare: 10 + max: 200 + accept-count: 100 + connection-timeout: 10s + +logging: + level: + root: INFO + com.cadlabel: INFO + org.springframework.web: INFO diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..8c72286 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,56 @@ +spring: + application: + name: cadlabel + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} + lifecycle: + timeout-per-shutdown-phase: 30s + servlet: + multipart: + max-file-size: ${APP_MULTIPART_MAX_FILE_SIZE:100MB} + max-request-size: ${APP_MULTIPART_MAX_REQUEST_SIZE:100MB} + jackson: + default-property-inclusion: non_null + time-zone: Asia/Shanghai + +server: + port: ${SERVER_PORT:8080} + shutdown: graceful + forward-headers-strategy: framework + compression: + enabled: true + min-response-size: 2KB + +mybatis-plus: + global-config: + db-config: + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 + configuration: + map-underscore-to-camel-case: true + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + +app: + cos: + secret-id: ${APP_COS_SECRET_ID:} + secret-key: ${APP_COS_SECRET_KEY:} + bucket: ${APP_COS_BUCKET:} + region: ${APP_COS_REGION:ap-beijing} + public-base-url: ${APP_COS_PUBLIC_BASE_URL:} + upload-session-ttl: ${APP_COS_UPLOAD_SESSION_TTL:PT30M} + upload-prefix: ${APP_COS_UPLOAD_PREFIX:uploads} + max-upload-size-bytes: ${APP_COS_MAX_UPLOAD_SIZE_BYTES:104857600} + +logging: + file: + path: ${LOG_PATH:logs} + level: + root: INFO + com.cadlabel: INFO + org.springframework.web: INFO diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..70154d4 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,71 @@ + + + + + + + + + + ${LOG_PATTERN} + UTF-8 + + + + + ${LOG_PATH}/${APP_NAME}.log + + ${LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 30 + 10GB + + + + + ${LOG_PATH}/${APP_NAME}-error.log + + ERROR + + + ${LOG_PATTERN} + UTF-8 + + + ${LOG_PATH}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz + 50MB + 60 + 5GB + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/cadlabel/CadLabelApplicationTests.java b/src/test/java/com/cadlabel/CadLabelApplicationTests.java new file mode 100644 index 0000000..0b71de5 --- /dev/null +++ b/src/test/java/com/cadlabel/CadLabelApplicationTests.java @@ -0,0 +1,14 @@ +package com.cadlabel; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class CadLabelApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/src/test/java/com/cadlabel/common/ApiResponseTest.java b/src/test/java/com/cadlabel/common/ApiResponseTest.java new file mode 100644 index 0000000..9c6a806 --- /dev/null +++ b/src/test/java/com/cadlabel/common/ApiResponseTest.java @@ -0,0 +1,41 @@ +package com.cadlabel.common; + +import com.cadlabel.common.api.ApiCode; +import com.cadlabel.common.api.ApiResponse; +import com.cadlabel.common.api.RequestIdHolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiResponseTest { + + @AfterEach + void tearDown() { + RequestIdHolder.clear(); + } + + @Test + void successIncludesOkCodeMessageDataAndRequestId() { + RequestIdHolder.set("req-001"); + + ApiResponse response = ApiResponse.success("pong"); + + assertThat(response.getCode()).isEqualTo(ApiCode.OK); + assertThat(response.getMessage()).isEqualTo("success"); + assertThat(response.getData()).isEqualTo("pong"); + assertThat(response.getRequestId()).isEqualTo("req-001"); + } + + @Test + void failureIncludesCodeMessageAndNoData() { + RequestIdHolder.set("req-002"); + + ApiResponse response = ApiResponse.failure(ApiCode.INVALID_REQUEST, "bad request"); + + assertThat(response.getCode()).isEqualTo(ApiCode.INVALID_REQUEST); + assertThat(response.getMessage()).isEqualTo("bad request"); + assertThat(response.getData()).isNull(); + assertThat(response.getRequestId()).isEqualTo("req-002"); + } +} diff --git a/src/test/java/com/cadlabel/controller/CosControllerTest.java b/src/test/java/com/cadlabel/controller/CosControllerTest.java new file mode 100644 index 0000000..bdd85a1 --- /dev/null +++ b/src/test/java/com/cadlabel/controller/CosControllerTest.java @@ -0,0 +1,80 @@ +package com.cadlabel.controller; + +import com.cadlabel.common.exception.GlobalExceptionHandler; +import com.cadlabel.common.web.RequestIdFilter; +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 org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Duration; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CosController.class) +@Import({GlobalExceptionHandler.class, RequestIdFilter.class}) +class CosControllerTest { + + @MockitoBean + private CosService cosService; + + @Autowired + private MockMvc mockMvc; + + @Test + void prepareUploadSessionReturnsUnifiedResponse() throws Exception { + given(cosService.prepareUploadSession(any(PrepareCosUploadSessionRequest.class))).willReturn( + new PrepareCosUploadSessionResponse( + "cadlabel-1250000000", + "ap-beijing", + "uploads/20260707/id/docs/", + new CosTemporaryCredentialsResponse("tmp-id", "tmp-key", "tmp-token", 1000L, 1600L) + ) + ); + + mockMvc.perform(post("/api/cos/upload-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"directory\":\"docs\",\"duration\":\"PT10M\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.bucket").value("cadlabel-1250000000")) + .andExpect(jsonPath("$.data.region").value("ap-beijing")) + .andExpect(jsonPath("$.data.keyPrefix").value("uploads/20260707/id/docs/")) + .andExpect(jsonPath("$.data.credentials.tmpSecretId").value("tmp-id")); + } + + @Test + void uploadObjectReturnsUnifiedResponse() throws Exception { + given(cosService.uploadObject(any(), any())).willReturn( + new CosUploadObjectResponse( + "cadlabel-1250000000", + "ap-beijing", + "uploads/20260707/id/docs/a.txt", + "https://cdn.example.com/uploads/20260707/id/docs/a.txt", + 12L, + "text/plain" + ) + ); + + mockMvc.perform(multipart("/api/cos/objects") + .file("file", "hello".getBytes()) + .param("directory", "docs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.objectKey").value("uploads/20260707/id/docs/a.txt")) + .andExpect(jsonPath("$.data.publicUrl").value("https://cdn.example.com/uploads/20260707/id/docs/a.txt")); + } +} diff --git a/src/test/java/com/cadlabel/controller/GlobalExceptionHandlerTest.java b/src/test/java/com/cadlabel/controller/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..3daea27 --- /dev/null +++ b/src/test/java/com/cadlabel/controller/GlobalExceptionHandlerTest.java @@ -0,0 +1,143 @@ +package com.cadlabel.controller; + +import com.cadlabel.common.api.ApiCode; +import com.cadlabel.common.api.ApiResponse; +import com.cadlabel.common.exception.BizException; +import com.cadlabel.common.exception.GlobalExceptionHandler; +import com.cadlabel.common.web.RequestIdFilter; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +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.RestController; + +import static org.hamcrest.Matchers.matchesPattern; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = GlobalExceptionHandlerTest.ProbeController.class) +@Import({GlobalExceptionHandler.class, RequestIdFilter.class, GlobalExceptionHandlerTest.ProbeController.class}) +class GlobalExceptionHandlerTest { + + @MockitoBean + private ProbeService probeService; + + @Autowired + private MockMvc mockMvc; + + @Test + void requestIdFilterAddsHeaderAndResponseRequestId() throws Exception { + given(probeService.echo(anyString())).willReturn("pong"); + + mockMvc.perform(get("/probe/ok").header("X-Request-Id", "rid-123")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Request-Id", "rid-123")) + .andExpect(jsonPath("$.code").value(ApiCode.OK)) + .andExpect(jsonPath("$.message").value("success")) + .andExpect(jsonPath("$.data").value("pong")) + .andExpect(jsonPath("$.requestId").value("rid-123")); + } + + @Test + void generatedRequestIdIsReturnedWhenHeaderMissing() throws Exception { + given(probeService.echo(anyString())).willReturn("pong"); + + mockMvc.perform(get("/probe/ok")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Request-Id", matchesPattern("[0-9a-f]{32}"))) + .andExpect(jsonPath("$.requestId", matchesPattern("[0-9a-f]{32}"))); + } + + @Test + void validationExceptionUsesUnifiedInvalidRequestResponse() throws Exception { + mockMvc.perform(post("/probe/validate") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value(ApiCode.INVALID_REQUEST)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.requestId").exists()); + } + + @Test + void businessExceptionUsesDeclaredStatusAndCode() throws Exception { + given(probeService.failBusiness()).willThrow( + new BizException(HttpStatus.CONFLICT.value(), "PROBE_CONFLICT", "probe conflict") + ); + + mockMvc.perform(get("/probe/biz")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PROBE_CONFLICT")) + .andExpect(jsonPath("$.message").value("probe conflict")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void unknownExceptionUsesInternalErrorResponse() throws Exception { + given(probeService.failUnknown()).willThrow(new IllegalStateException("boom")); + + mockMvc.perform(get("/probe/unknown")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value(ApiCode.INTERNAL_ERROR)) + .andExpect(jsonPath("$.message").value("系统异常")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @RestController + @RequestMapping("/probe") + static class ProbeController { + + private final ProbeService probeService; + + ProbeController(ProbeService probeService) { + this.probeService = probeService; + } + + @GetMapping("/ok") + ApiResponse ok() { + return ApiResponse.success(probeService.echo("ping")); + } + + @PostMapping("/validate") + ApiResponse validate(@Valid @RequestBody ProbeRequest request) { + return ApiResponse.success(request.name()); + } + + @GetMapping("/biz") + ApiResponse biz() { + return ApiResponse.success(probeService.failBusiness()); + } + + @GetMapping("/unknown") + ApiResponse unknown() { + return ApiResponse.success(probeService.failUnknown()); + } + } + + interface ProbeService { + + String echo(String value); + + String failBusiness(); + + String failUnknown(); + } + + record ProbeRequest(@NotBlank String name) { + } +} diff --git a/src/test/java/com/cadlabel/service/impl/CosServiceTest.java b/src/test/java/com/cadlabel/service/impl/CosServiceTest.java new file mode 100644 index 0000000..3f5cedc --- /dev/null +++ b/src/test/java/com/cadlabel/service/impl/CosServiceTest.java @@ -0,0 +1,109 @@ +package com.cadlabel.service.impl; + +import com.cadlabel.common.exception.BizException; +import com.cadlabel.config.CosProperties; +import com.cadlabel.dto.cos.CosUploadObjectResponse; +import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest; +import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse; +import com.cadlabel.service.client.CosObjectStorageClient; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CosServiceTest { + + private final FakeCosObjectStorageClient storageClient = new FakeCosObjectStorageClient(); + private final CosProperties cosProperties = new CosProperties( + "sid", + "skey", + "cadlabel-1250000000", + "ap-beijing", + "https://cdn.example.com/assets", + Duration.ofMinutes(30), + "uploads", + 100L + ); + private final CosServiceImpl cosService = new CosServiceImpl(cosProperties, storageClient, new CosObjectUrlResolver(cosProperties)); + + @Test + void prepareUploadSessionBuildsUploadPrefixAndReturnsTemporaryCredentials() { + PrepareCosUploadSessionResponse response = cosService.prepareUploadSession( + new PrepareCosUploadSessionRequest("manuals", Duration.ofMinutes(10)) + ); + + assertThat(response.bucket()).isEqualTo("cadlabel-1250000000"); + assertThat(response.region()).isEqualTo("ap-beijing"); + assertThat(response.keyPrefix()).startsWith("uploads/"); + assertThat(response.keyPrefix()).contains("/manuals/"); + assertThat(response.keyPrefix()).endsWith("/"); + assertThat(response.credentials().tmpSecretId()).isEqualTo("tmp-id"); + assertThat(storageClient.lastCredentialPrefix.get()).isEqualTo(response.keyPrefix()); + assertThat(storageClient.lastCredentialDuration.get()).isEqualTo(Duration.ofMinutes(10)); + } + + @Test + void uploadObjectStoresFileWithSanitizedNameAndPublicUrl() { + MockMultipartFile file = new MockMultipartFile( + "file", + "../图纸 A.dwg", + "application/octet-stream", + "content".getBytes(StandardCharsets.UTF_8) + ); + + CosUploadObjectResponse response = cosService.uploadObject(file, "cad/source"); + + assertThat(response.bucket()).isEqualTo("cadlabel-1250000000"); + assertThat(response.region()).isEqualTo("ap-beijing"); + assertThat(response.objectKey()).startsWith("uploads/"); + assertThat(response.objectKey()).contains("/cad/source/"); + assertThat(response.objectKey()).endsWith("/A.dwg"); + assertThat(response.publicUrl()).isEqualTo("https://cdn.example.com/assets/" + response.objectKey()); + assertThat(storageClient.lastUploadedKey.get()).isEqualTo(response.objectKey()); + assertThat(storageClient.lastUploadedContentType.get()).isEqualTo("application/octet-stream"); + } + + @Test + void uploadObjectRejectsEmptyFiles() { + MockMultipartFile file = new MockMultipartFile("file", "empty.txt", "text/plain", new byte[0]); + + assertThatThrownBy(() -> cosService.uploadObject(file, "docs")) + .isInstanceOf(BizException.class) + .hasMessage("上传文件不能为空"); + } + + @Test + void uploadObjectRejectsOversizedFiles() { + MockMultipartFile file = new MockMultipartFile("file", "large.txt", "text/plain", new byte[101]); + + assertThatThrownBy(() -> cosService.uploadObject(file, "docs")) + .isInstanceOf(BizException.class) + .hasMessage("上传文件超过大小限制"); + } + + private static class FakeCosObjectStorageClient implements CosObjectStorageClient { + + private final AtomicReference lastCredentialPrefix = new AtomicReference<>(); + private final AtomicReference lastCredentialDuration = new AtomicReference<>(); + private final AtomicReference lastUploadedKey = new AtomicReference<>(); + private final AtomicReference lastUploadedContentType = new AtomicReference<>(); + + @Override + public TemporaryCredentials issueTemporaryCredentials(String keyPrefix, Duration duration) { + lastCredentialPrefix.set(keyPrefix); + lastCredentialDuration.set(duration); + return new TemporaryCredentials("tmp-id", "tmp-key", "tmp-token", 1000L, 1600L); + } + + @Override + public void putObject(String objectKey, java.io.InputStream inputStream, long contentLength, String contentType) { + lastUploadedKey.set(objectKey); + lastUploadedContentType.set(contentType); + } + } +} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca8778f --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker