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
+5 -18
View File
@@ -1,18 +1,5 @@
# Build and Release Folders target/
bin-debug/ logs/
bin-release/ .idea/
[Oo]bj/ *.iml
[Bb]in/ .DS_Store
# 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.
+42
View File
@@ -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<T>` shape.
+125
View File
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<relativePath/>
</parent>
<groupId>com.cadlabel</groupId>
<artifactId>cadlabel</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>cadlabel</name>
<description>CadLabel backend service.</description>
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.11</mybatis-plus.version>
<springdoc.version>2.8.6</springdoc.version>
<tencentcloud.cos.sdk.version>5.6.246</tencentcloud.cos.sdk.version>
<tencentcloud.cos.sts.sdk.version>3.1.1</tencentcloud.cos.sts.sdk.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>${tencentcloud.cos.sdk.version}</version>
</dependency>
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos-sts_api</artifactId>
<version>${tencentcloud.cos.sts.sdk.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>public</id>
<name>aliyun nexus</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases>
<enabled>true</enabled>
</releases>
</repository>
</repositories>
</project>
@@ -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();
}
}
+25
View File
@@ -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
+37
View File
@@ -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
+56
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="cadlabel"/>
<springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="logs"/>
<property name="LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{requestId:-}] %logger{36} - %msg%n%ex"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="APP_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.log</file>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
</appender>
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>60</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
</appender>
<springProfile name="dev,test">
<logger name="com.cadlabel" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="APP_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</springProfile>
<springProfile name="prod">
<logger name="com.cadlabel" level="INFO"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="APP_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</springProfile>
<springProfile name="default">
<logger name="com.cadlabel" level="INFO"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
</configuration>
@@ -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() {
}
}
@@ -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<String> 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<Void> 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");
}
}
@@ -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"));
}
}
@@ -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<String> ok() {
return ApiResponse.success(probeService.echo("ping"));
}
@PostMapping("/validate")
ApiResponse<String> validate(@Valid @RequestBody ProbeRequest request) {
return ApiResponse.success(request.name());
}
@GetMapping("/biz")
ApiResponse<String> biz() {
return ApiResponse.success(probeService.failBusiness());
}
@GetMapping("/unknown")
ApiResponse<String> unknown() {
return ApiResponse.success(probeService.failUnknown());
}
}
interface ProbeService {
String echo(String value);
String failBusiness();
String failUnknown();
}
record ProbeRequest(@NotBlank String name) {
}
}
@@ -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<String> lastCredentialPrefix = new AtomicReference<>();
private final AtomicReference<Duration> lastCredentialDuration = new AtomicReference<>();
private final AtomicReference<String> lastUploadedKey = new AtomicReference<>();
private final AtomicReference<String> 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);
}
}
}
@@ -0,0 +1 @@
org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker