init
This commit is contained in:
+5
-18
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user