在这里插入图片描述

肖哥弹架构 跟大家“弹弹” Minio 设计与实战应用,需要代码关注

欢迎 点赞,点赞,点赞。

关注公号Solomon肖哥弹架构获取更多精彩内容

历史热点文章

本文详细介绍了如何基于 Spring Boot 和 MinIO 分布式对象存储构建一个高可用、安全可靠的企业内部电子文档管理系统。内容涵盖从 MinIO 集群部署、Spring Boot 项目搭建、数据库设计、核心代码实现,到系统监控、API 测试与高级功能扩展的全流程实战指南。

📋 一、项目概述

1. 业务背景

公司需要开发一个内部电子文档管理系统,用于存储和管理公司的各类文档(合同、报告、设计稿等)。要求实现文档的安全存储、分类管理、权限控制和在线预览等功能。

2. 技术架构图

在这里插入图片描述

🚀 二、 环境准备

在这里插入图片描述

1. MinIO 分布式集群部署

# 创建集群目录结构(在 /opt/minio-cluster 目录下执行)
mkdir -p /opt/minio-cluster/node{1..4}/data
cd /opt/minio-cluster

# 创建 docker-compose.yml 文件
cat > docker-compose.yml << 'EOF'
version: '3.8'
# 使用 Docker Compose 版本 3.8,支持最新的特性

services:
  # 第一个 MinIO 节点
  minio1:
    image: minio/minio:RELEASE.2023-10-25T06-33-25Z
    # 使用特定版本的 MinIO 镜像,确保版本一致性
    container_name: minio-node1
    # 明确指定容器名称,便于管理
    command: server http://minio1/data http://minio2/data http://minio3/data http://minio4/data --console-address ":9001"
    # server: 启动服务器模式
    # http://minio{1..4}/data: 指定集群中所有节点的访问地址和数据目录
    # --console-address ":9001": 将控制台Web界面绑定到9001端口
    hostname: minio1
    # 设置容器主机名,用于节点间通信
    environment:
      MINIO_ROOT_USER: admin
      # 设置管理员用户名(生产环境应使用更复杂的用户名)
      MINIO_ROOT_PASSWORD: your_strong_password_123
      # 设置管理员密码(生产环境必须使用强密码)
      MINIO_DOMAIN: minio.local
      # (可选)设置自定义域名,用于虚拟主机风格的访问
      MINIO_PROMETHEUS_AUTH_TYPE: public
      # (可选)允许Prometheus无需认证访问指标数据
    volumes:
      - ./node1/data:/data
      # 将宿主机的 ./node1/data 目录挂载到容器的 /data 目录
      # 确保数据持久化,容器重启后数据不会丢失
    networks:
      - minio-cluster
      # 连接到自定义网络
    ports:
      - "9001:9001"
      # 将容器的9001端口映射到宿主机的9001端口(控制台)
      - "9000:9000"
      # 将容器的9000端口映射到宿主机的9000端口(API)
    restart: unless-stopped
    # 重启策略:除非手动停止,否则自动重启
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      # 健康检查:通过API端点检查节点是否存活
      interval: 30s
      # 每30秒检查一次
      timeout: 10s
      # 超时时间10秒
      retries: 3
      # 重试3次后才标记为不健康

  # 第二个 MinIO 节点(配置与第一个节点类似)
  minio2:
    image: minio/minio:RELEASE.2023-10-25T06-33-25Z
    container_name: minio-node2
    command: server http://minio1/data http://minio2/data http://minio3/data http://minio4/data --console-address ":9001"
    hostname: minio2
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: your_strong_password_123
    volumes:
      - ./node2/data:/data
    networks:
      - minio-cluster
    ports:
      - "9002:9001"  # 使用不同的宿主机端口避免冲突
      - "9003:9000"  # 使用不同的宿主机端口避免冲突
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3

  # 第三个 MinIO 节点
  minio3:
    image: minio/minio:RELEASE.2023-10-25T06-33-25Z
    container_name: minio-node3
    command: server http://minio1/data http://minio2/data http://minio3/data http://minio4/data --console-address ":9001"
    hostname: minio3
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: your_strong_password_123
    volumes:
      - ./node3/data:/data
    networks:
      - minio-cluster
    ports:
      - "9004:9001"
      - "9005:9000"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3

  # 第四个 MinIO 节点
  minio4:
    image: minio/minio:RELEASE.2023-10-25T06-33-25Z
    container_name: minio-node4
    command: server http://minio1/data http://minio2/data http://minio3/data http://minio4/data --console-address ":9001"
    hostname: minio4
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: your_strong_password_123
    volumes:
      - ./node4/data:/data
    networks:
      - minio-cluster
    ports:
      - "9006:9001"
      - "9007:9000"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3

  # (可选)MinIO 客户端工具容器,用于管理操作
  mc:
    image: minio/mc:latest
    container_name: minio-client
    hostname: minio-client
    environment:
      MC_HOST_minio: http://admin:your_strong_password_123@minio1:9000
      # 配置mc客户端别名,便于执行管理命令
    networks:
      - minio-cluster
    entrypoint: >
      sh -c "
      until mc ready minio; do echo '等待MinIO集群就绪...'; sleep 5; done;
      echo '集群已就绪,创建默认存储桶...';
      mc mb minio/document-bucket;
      mc anonymous set download minio/document-bucket;
      echo '初始化完成';
      tail -f /dev/null
      "
    # 等待集群就绪后自动创建存储桶并设置权限
    depends_on:
      - minio1
      - minio2
      - minio3
      - minio4
    restart: on-failure

networks:
  minio-cluster:
    driver: bridge
    # 使用bridge网络驱动,创建独立的网络隔离环境
    ipam:
      config:
        - subnet: 172.20.0.0/16
          # 指定子网范围,避免IP冲突
          gateway: 172.20.0.1

volumes:
  node1-data:
    driver: local
  node2-data:
    driver: local
  node3-data:
    driver: local
  node4-data:
    driver: local
    # 定义命名卷,提供更好的数据管理(可选)
EOF

2. 创建并启动集群

# 进入项目目录
cd /opt/minio-cluster

# 启动所有服务(在后台运行)
docker-compose up -d

# 查看启动状态
docker-compose ps

# 查看实时日志
docker-compose logs -f

3. 验证集群状态

# 检查所有容器是否正常运行
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

# 检查集群健康状态
curl http://localhost:9000/minio/health/cluster

# 检查单个节点健康状态
curl http://localhost:9000/minio/health/live

4. 访问管理控制台

🛠 三、 Spring Boot 项目搭建

1. 项目结构

document-system/
├── src/main/java/com/company/document/
│   ├── config/
│   ├── controller/
│   ├── service/
│   ├── entity/
│   ├── repository/
│   ├── dto/
│   └── DocumentApplication.java
├── src/main/resources/
│   └── application.yml
└── pom.xml

2. Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>io.minio</groupId>
        <artifactId>minio</artifactId>
        <version>8.5.6</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

📊 四、 数据库设计

1. 实体关系图

在这里插入图片描述

2. SQL 表结构

CREATE TABLE documents (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    category VARCHAR(100),
    created_by BIGINT NOT NULL,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE document_versions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    document_id BIGINT NOT NULL,
    version VARCHAR(50) NOT NULL,
    file_name VARCHAR(255) NOT NULL,
    file_key VARCHAR(500) NOT NULL,
    file_size BIGINT NOT NULL,
    content_type VARCHAR(100),
    upload_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (document_id) REFERENCES documents(id)
);

🔧 五、 核心代码实现

1. MinIO 配置类

/**
 * MinIO 对象存储配置类
 * 负责配置和初始化 MinIO 客户端连接
 * 
 * @Configuration 表示这是一个配置类,Spring Boot 启动时会自动处理其中的Bean定义
 */
@Configuration
public class MinioConfig {
    
    /**
     * MinIO 服务器端点地址
     * 从 application.yml 配置文件中注入,配置示例:
     * minio:
     *   endpoint: http://localhost:9000
     * 
     * 支持多种格式:
     * - HTTP: http://minio.example.com:9000
     * - HTTPS: https://minio.example.com:9000
     * - 集群模式: http://minio1:9000,http://minio2:9000 (需要DNS轮询)
     */
    @Value("${minio.endpoint}")
    private String endpoint;
    
    /**
     * MinIO 访问密钥 (Access Key)
     * 相当于用户名,用于身份认证
     * 生产环境建议使用 vault 或 k8s secret 管理,不要硬编码在配置文件中
     */
    @Value("${minio.accessKey}")
    private String accessKey;
    
    /**
     * MinIO 秘密密钥 (Secret Key)
     * 相当于密码,用于身份认证
     * 这是敏感信息,生产环境必须加密存储
     */
    @Value("${minio.secretKey}")
    private String secretKey;
    
    /**
     * 连接超时时间(毫秒)
     * 默认值:10000ms (10秒)
     */
    @Value("${minio.connect-timeout:10000}")
    private int connectTimeout;
    
    /**
     * 读写超时时间(毫秒)
     * 默认值:30000ms (30秒)
     */
    @Value("${minio.timeout:30000}")
    private int timeout;
    
    /**
     * 创建 MinIO 客户端 Bean
     * 
     * @Bean 表示该方法返回的对象将被Spring容器管理,可以在其他地方通过@Autowired注入
     * @Primary 表示当有多个同类型Bean时,优先使用这个
     * 
     * @return 配置好的 MinioClient 实例
     * @throws IllegalArgumentException 如果 endpoint 或 credentials 无效
     */
    @Bean
    @Primary
    public MinioClient minioClient() {
        try {
            // 使用建造者模式创建 MinIO 客户端实例
            MinioClient client = MinioClient.builder()
                    // 设置 MinIO 服务器地址
                    .endpoint(endpoint)
                    // 设置访问凭证(用户名和密码)
                    .credentials(accessKey, secretKey)
                    // 设置连接超时时间
                    .connectTimeout(connectTimeout)
                    // 设置读写超时时间
                    .timeout(timeout)
                    // 构建 MinIO 客户端实例
                    .build();
            
            // 记录初始化成功日志(实际使用时需要注入Logger)
            System.out.println("MinIO客户端初始化成功,端点: " + endpoint);
            
            return client;
            
        } catch (Exception e) {
            // 记录错误日志并抛出运行时异常,阻止应用启动
            throw new IllegalStateException("MinIO客户端初始化失败: " + e.getMessage(), e);
        }
    }
    
    /**
     * 可选的:创建自定义区域的 MinIO 客户端
     * 用于访问特定区域的存储桶
     */
    @Bean
    public MinioClient minioClientWithRegion() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .region("us-east-1") // 设置特定区域
                .build();
    }
}

2. 文档实体类

@Entity
@Table(name = "documents")
@Data
public class Document {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private String description;
    private String category;
    private Long createdBy;
    
    @CreationTimestamp
    private LocalDateTime createTime;
    
    @OneToMany(mappedBy = "document", cascade = CascadeType.ALL)
    private List<DocumentVersion> versions;
}

@Entity
@Table(name = "document_versions")
@Data
public class DocumentVersion {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "document_id")
    private Document document;
    
    private String version;
    private String fileName;
    private String fileKey;
    private Long fileSize;
    private String contentType;
    
    @CreationTimestamp
    private LocalDateTime uploadTime;
}

3. 文件服务层

@Service
@Slf4j
public class FileStorageService {
    
    @Autowired
    private MinioClient minioClient;
    
    @Value("${minio.bucket-name}")
    private String bucketName;
    
    public String uploadFile(MultipartFile file, String fileKey) throws Exception {
        // 检查存储桶是否存在
        boolean found = minioClient.bucketExists(BucketExistsArgs.builder()
                .bucket(bucketName).build());
        if (!found) {
            minioClient.makeBucket(MakeBucketArgs.builder()
                    .bucket(bucketName).build());
        }
        
        // 上传文件
        minioClient.putObject(PutObjectArgs.builder()
                .bucket(bucketName)
                .object(fileKey)
                .stream(file.getInputStream(), file.getSize(), -1)
                .contentType(file.getContentType())
                .build());
        
        return fileKey;
    }
    
    public byte[] downloadFile(String fileKey) throws Exception {
        try (InputStream stream = minioClient.getObject(GetObjectArgs.builder()
                .bucket(bucketName)
                .object(fileKey)
                .build())) {
            return IOUtils.toByteArray(stream);
        }
    }
    
    public void deleteFile(String fileKey) throws Exception {
        minioClient.removeObject(RemoveObjectArgs.builder()
                .bucket(bucketName)
                .object(fileKey)
                .build());
    }
}

4. 文档管理服务

@Service
@Transactional
public class DocumentService {
    
    @Autowired
    private DocumentRepository documentRepository;
    
    @Autowired
    private DocumentVersionRepository versionRepository;
    
    @Autowired
    private FileStorageService fileStorageService;
    
    public Document createDocument(Document document, MultipartFile file, Long userId) {
        document.setCreatedBy(userId);
        Document savedDocument = documentRepository.save(document);
        
        // 保存文件版本
        DocumentVersion version = new DocumentVersion();
        version.setDocument(savedDocument);
        version.setVersion("1.0");
        version.setFileName(file.getOriginalFilename());
        version.setFileSize(file.getSize());
        version.setContentType(file.getContentType());
        
        String fileKey = generateFileKey(savedDocument.getId(), file.getOriginalFilename());
        version.setFileKey(fileKey);
        
        try {
            fileStorageService.uploadFile(file, fileKey);
            versionRepository.save(version);
        } catch (Exception e) {
            throw new RuntimeException("文件上传失败", e);
        }
        
        return savedDocument;
    }
    
    private String generateFileKey(Long documentId, String fileName) {
        return String.format("documents/%d/%s_%s", 
                documentId, 
                System.currentTimeMillis(), 
                fileName);
    }
    
    public DocumentVersion downloadDocumentVersion(Long versionId) {
        return versionRepository.findById(versionId)
                .orElseThrow(() -> new RuntimeException("文件版本不存在"));
    }
}

5. REST 控制器

@RestController
@RequestMapping("/api/documents")
@CrossOrigin
public class DocumentController {
    
    @Autowired
    private DocumentService documentService;
    
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<Document> uploadDocument(
            @RequestParam("file") MultipartFile file,
            @RequestParam("name") String name,
            @RequestParam(value = "description", required = false) String description,
            @RequestParam("category") String category,
            @RequestAttribute Long userId) {
        
        Document document = new Document();
        document.setName(name);
        document.setDescription(description);
        document.setCategory(category);
        
        Document savedDocument = documentService.createDocument(document, file, userId);
        return ResponseEntity.ok(savedDocument);
    }
    
    @GetMapping("/{documentId}/download/{versionId}")
    public ResponseEntity<byte[]> downloadDocument(
            @PathVariable Long documentId,
            @PathVariable Long versionId) {
        
        DocumentVersion version = documentService.downloadDocumentVersion(versionId);
        
        try {
            byte[] fileContent = fileStorageService.downloadFile(version.getFileKey());
            
            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_DISPOSITION, 
                            "attachment; filename="" + version.getFileName() + """)
                    .contentType(MediaType.parseMediaType(version.getContentType()))
                    .body(fileContent);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
    
    @GetMapping
    public ResponseEntity<List<Document>> listDocuments(
            @RequestParam(required = false) String category) {
        
        List<Document> documents;
        if (category != null) {
            documents = documentService.findByCategory(category);
        } else {
            documents = documentService.findAll();
        }
        
        return ResponseEntity.ok(documents);
    }
}

⚙️ 六、 应用配置

1. application.yml

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/document_db
    username: root
    password: mysql_password
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

minio:
  endpoint: http://localhost:9000
  accessKey: admin
  secretKey: your_strong_password_123
  bucket-name: document-bucket

logging:
  level:
    com.company.document: DEBUG
    io.minio: INFO

🧪 七、功能测试与验证

1. 测试脚本

@SpringBootTest
class DocumentSystemApplicationTests {
    
    @Autowired
    private DocumentService documentService;
    
    @Autowired
    private MinioClient minioClient;
    
    @Test
    void testFileUploadAndDownload() throws Exception {
        // 创建测试文件
        MockMultipartFile file = new MockMultipartFile(
                "file", 
                "test.pdf", 
                "application/pdf", 
                "test content".getBytes());
        
        Document document = new Document();
        document.setName("测试文档");
        document.setCategory("合同");
        
        Document savedDoc = documentService.createDocument(document, file, 1L);
        
        assertNotNull(savedDoc.getId());
        assertFalse(savedDoc.getVersions().isEmpty());
        
        // 验证文件是否存在于 MinIO
        StatObjectResponse stat = minioClient.statObject(StatObjectArgs.builder()
                .bucket("document-bucket")
                .object(savedDoc.getVersions().get(0).getFileKey())
                .build());
        
        assertEquals(file.getSize(), stat.size());
    }
}

2. API 测试用例

# 上传文档
curl -X POST http://localhost:8080/api/documents \
  -H "Authorization: Bearer <token>" \
  -F "file=@contract.pdf" \
  -F "name=销售合同" \
  -F "category=合同" \
  -F "description=2024年销售合同"

# 获取文档列表
curl -X GET http://localhost:8080/api/documents?category=合同

# 下载文档
curl -X GET http://localhost:8080/api/documents/1/download/1 \
  -H "Authorization: Bearer <token>" \
  --output downloaded_contract.pdf

📈 八、 系统监控与运维

1. 健康检查端点

@RestController
@RequestMapping("/api/health")
public class HealthController {
    
    @Autowired
    private MinioClient minioClient;
    
    @GetMapping("/minio")
    public ResponseEntity<String> checkMinioHealth() {
        try {
            minioClient.listBuckets();
            return ResponseEntity.ok("MinIO connection is healthy");
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                    .body("MinIO connection failed: " + e.getMessage());
        }
    }
}

2. 监控配置

management:
  # 端点配置部分
  endpoints:
    # Web端点配置(HTTP暴露)
    web:
      # 端点暴露配置
      exposure:
        # 包含哪些端点对外暴露
        include: health,info,metrics
    # 单个端点详细配置
    endpoint:
      # health端点的特定配置
      health:
        # 显示健康详情的方式
        show-details: always

配置功能说明:

2.1. management.endpoints.web.exposure.include

作用: 控制哪些监控端点通过 HTTP 对外暴露
可选值:

  • health - 应用健康状态检查
  • info - 应用基本信息
  • metrics - 应用指标数据
  • env - 环境变量信息
  • beans - Spring Bean 信息
  • mappings - URL 映射信息
  • loggers - 日志配置信息
  • * - 暴露所有端点(生产环境不推荐)
    安全建议: 生产环境只暴露必要的端点,避免信息泄露
2.2. management.endpoint.health.show-details

作用: 控制健康检查结果的详细程度
可选值:

  • never - 从不显示详情(默认值)
  • when-authorized - 仅对授权用户显示详情
  • always - 总是显示详情
2.3. 完整的生产环境配置示例
management:
  server:
    port: 9090  # 管理端口与业务端口分离
  endpoints:
    web:
      base-path: /manage  # 修改基础路径
      exposure:
        include: health,info,metrics
      cors:
        allowed-origins: "https://monitor.example.com"
        allowed-methods: "GET"
    jmx:
      exposure:
        include: "*"
  endpoint:
    health:
      show-details: when_authorized
      probes:
        enabled: true  # 启用K8s就绪性和存活性探针
      group:
        readiness:
          include: db,minio,diskSpace
        liveness:
          include: ping
        custom:
          include: db,minio,redis
    metrics:
      enabled: true
    info:
      enabled: true
    env:
      enabled: true
      show-values: when_authorized
    beans:
      enabled: false  # 生产环境禁用敏感端点
    shutdown:
      enabled: false
  metrics:
    export:
      prometheus:
        enabled: true
    distribution:
      percentiles: [0.5, 0.75, 0.95, 0.99]
    tags:
      application: ${spring.application.name}
      environment: ${spring.profiles.active}
  trace:
    http:
      enabled: true

info:
  app:
    name: "@project.name@"
    version: "@project.version@"
    description: "@project.description@"
  build:
    timestamp: "@build.timestamp@"
    java:
      version: "@java.version@"
  env:
    profiles: "${spring.profiles.active}"
    zone: "${zone:unknown}"
2.4. 访问方式

启动应用后,可以通过以下URL访问监控端点:

  1. 健康检查: http://localhost:8080/actuator/health
  2. 应用信息: http://localhost:8080/actuator/info
  3. 性能指标: http://localhost:8080/actuator/metrics
  4. 特定指标: http://localhost:8080/actuator/metrics/jvm.memory.used
  5. Prometheus: http://localhost:8080/actuator/prometheus

🎯 九、 业务流程图

在这里插入图片描述

💡 十、 高级功能扩展

1. 版本控制实现

/**
 * 为指定文档添加新版本
 * 这是一个核心业务方法,负责文档的版本管理和文件存储
 *
 * @param documentId 文档ID,指定要添加版本的文档
 * @param file 上传的文件对象,包含文件内容和元数据
 * @param versionComment 版本注释,描述本次版本更新的内容(可选)
 * @return DocumentVersion 保存成功的版本对象
 * @throws RuntimeException 当文档不存在或文件上传失败时抛出
 * 
 * 方法流程:
 * 1. 验证文档存在性
 * 2. 计算新版本号
 * 3. 创建版本记录
 * 4. 上传文件到存储服务
 * 5. 保存版本元数据
 */
@Transactional(rollbackFor = Exception.class) // 添加事务管理,任何异常都回滚
public DocumentVersion addVersion(Long documentId, MultipartFile file, String versionComment) {
    // 参数校验
    if (file == null || file.isEmpty()) {
        throw new IllegalArgumentException("上传文件不能为空");
    }
    
    if (documentId == null) {
        throw new IllegalArgumentException("文档ID不能为空");
    }
    
    // 1. 查询文档实体,使用自定义异常更友好
    Document document = documentRepository.findById(documentId)
            .orElseThrow(() -> new DocumentNotFoundException("文档不存在,ID: " + documentId));
    
    // 2. 获取当前最新版本号并递增
    // 使用Optional和Stream API安全地获取最大版本
    String currentVersion = document.getVersions().stream()
            .max(Comparator.comparing(DocumentVersion::getUploadTime)) // 按上传时间排序
            .map(DocumentVersion::getVersion) // 提取版本号字符串
            .orElse("0.0"); // 如果没有历史版本,默认从"0.0"开始
    
    // 版本号递增逻辑(主版本.次版本)
    String newVersion = incrementVersion(currentVersion);
    
    // 3. 创建新的版本记录对象
    DocumentVersion version = new DocumentVersion();
    version.setDocument(document); // 设置关联的文档
    version.setVersion(newVersion); // 设置新版本号
    version.setFileName(file.getOriginalFilename()); // 原始文件名
    version.setFileSize(file.getSize()); // 文件大小(字节)
    version.setContentType(file.getContentType()); // 文件MIME类型
    version.setVersionComment(versionComment); // 版本注释
    version.setUploadTime(LocalDateTime.now()); // 上传时间
    version.setUploadBy(getCurrentUserId()); // 上传用户(需要实现)
    
    // 4. 生成唯一的文件存储键(避免文件名冲突)
    // 格式: documents/{documentId}/versions/{timestamp}_{filename}
    String fileKey = generateFileKey(documentId, file.getOriginalFilename());
    version.setFileKey(fileKey);
    
    try {
        // 5. 上传文件到MinIO对象存储
        // 这里可能会抛出IOException或MinIO相关异常
        fileStorageService.uploadFile(file, fileKey);
        
        // 6. 保存版本元数据到数据库
        DocumentVersion savedVersion = versionRepository.save(version);
        
        // 记录操作日志
        log.info("文档版本添加成功 - 文档ID: {}, 版本号: {}, 文件名: {}, 大小: {}字节",
                documentId, newVersion, file.getOriginalFilename(), file.getSize());
        
        return savedVersion;
        
    } catch (Exception e) {
        // 7. 异常处理:记录详细错误日志并抛出业务异常
        log.error("文档版本上传失败 - 文档ID: {}, 文件名: {}, 错误信息: {}",
                documentId, file.getOriginalFilename(), e.getMessage(), e);
        
        // 抛出具体的业务异常,便于上层处理
        throw new FileUploadException("版本上传失败: " + e.getMessage(), e);
    }
}
Logo

更多推荐