毕业设计大作战:文件管理系统开发指南

前言

各位学弟学妹们好,我是你们即将毕业的学长,现在正在为毕业设计和找工作焦头烂额。最近我接到了一个"看似简单"的任务:开发一个支持10G大文件上传的文件管理系统。本以为就是做个普通的上传功能,没想到这简直就是开发界的"珠穆朗玛峰"啊!

项目需求分析

老板(其实是导师)给了我一堆"简单"的要求:

  1. 必须用原生JS实现(Vue3都给我靠边站)
  2. 支持10G文件上传(我的4G内存笔记本瑟瑟发抖)
  3. 加密传输+存储(感觉自己在搞军工项目)
  4. 文件夹上传还要保留结构(树形结构警告)
  5. 断点续传+离线保存(浏览器关了进度不能丢)
  6. 兼容IE8到最新Edge(Windows7+IE9的遗老遗少们)
  7. 后端SpringBoot+MySQL(标准配置)
  8. 对象存储用阿里云OSS(私有云公有云混合云)

最关键的是:网上找的代码都是残次品,出了问题找不到人!这不就是我现在的写照吗?

技术选型

经过三天三夜的挣扎(实际是刷知乎刷到凌晨三点),我决定:

  • 前端:WebUploader(百度开源)+ 原生JS(说好的Vue3呢?)
  • 后端:SpringBoot(标准企业级选择)
  • 存储:阿里云OSS(毕竟学校给报销云服务费用)
  • 兼容性:IE8+(向Windows7+IE9用户致敬)

前端实现(原生JS版)

HTML结构




    
    毕业设计-文件管理系统
    
    
    
        body { font-family: Arial, sans-serif; margin: 20px; }
        .uploader-container { margin: 20px 0; }
        #filePicker { margin: 10px 0; }
        .progress { width: 100%; background: #eee; margin: 5px 0; }
        .progress-bar { height: 20px; background: #4CAF50; width: 0%; }
        .file-list { margin-top: 20px; }
        .file-item { padding: 5px; border-bottom: 1px solid #ddd; }
        .ie-warning { color: red; font-weight: bold; }
    


    毕业设计-大文件上传系统
    
    
    
        注意:您正在使用旧版浏览器,部分功能可能受限,建议使用Chrome或Firefox。
    
    
    
    
        
            选择文件/文件夹
            开始上传
            暂停
        
        
        
        
            
        
        上传速度: 0 | 已上传: 0
        
        
        
    

    
    
    
    
    
    


JavaScript核心代码 (js/uploader.js)

// 检测IE版本
function detectIE() {
    var ua = window.navigator.userAgent;
    var msie = ua.indexOf('MSIE ');
    if (msie > 0) {
        return parseInt(ua.substring(msie + 5, ua.indexOf('.', msie)), 10);
    }
    var trident = ua.indexOf('Trident/');
    if (trident > 0) {
        var rv = ua.indexOf('rv:');
        return parseInt(ua.substring(rv + 3, ua.indexOf('.', rv)), 10);
    }
    return false;
}

// 显示IE警告
var ieVersion = detectIE();
if (ieVersion && ieVersion <= 9) {
    document.getElementById('ieWarning').style.display = 'block';
}

// 上传器配置
var uploader = WebUploader.create({
    swf: 'https://cdn.bootcdn.net/ajax/libs/webuploader/0.1.1/Uploader.swf',
    server: '/api/upload',
    pick: {
        id: '#filePicker',
        multiple: true
    },
    compress: false,
    chunked: true,
    chunkSize: 5 * 1024 * 1024, // 5MB一片
    threads: 3,
    formData: {
        // 可以在这里添加额外参数
    },
    // 兼容性设置
    accept: {
        title: 'All',
        extensions: '*',
        mimeTypes: '*'
    },
    // 文件夹上传支持(非标准,需要额外处理)
    duplicate: true
});

// 文件加密函数
function encryptFile(file, chunk, callback) {
    // 使用CryptoJS进行简单加密(实际项目中应该用更安全的方案)
    var reader = new FileReader();
    reader.onload = function(e) {
        var wordArray = CryptoJS.enc.Latin1.parse(e.target.result);
        var encrypted = CryptoJS.AES.encrypt(wordArray, 'secret-key-123').toString();
        callback(encrypted);
    };
    
    // 读取分片内容
    var blob = chunk ? file.slice(chunk.start, chunk.end) : file;
    reader.readAsBinaryString(blob);
}

// 解密函数(服务器端需要对应解密)
function decryptData(encrypted) {
    var decrypted = CryptoJS.AES.decrypt(encrypted, 'secret-key-123');
    return decrypted.toString(CryptoJS.enc.Latin1);
}

// 断点续传支持
function saveUploadProgress(file, progress) {
    var key = 'upload_progress_' + file.name + '_' + file.size;
    localStorage.setItem(key, JSON.stringify(progress));
}

function getUploadProgress(file) {
    var key = 'upload_progress_' + file.name + '_' + file.size;
    var progress = localStorage.getItem(key);
    return progress ? JSON.parse(progress) : null;
}

// 文件添加事件
uploader.on('fileQueued', function(file) {
    // 检查是否有未完成的上传
    var progress = getUploadProgress(file);
    if (progress) {
        file.uploadedBytes = progress.uploadedBytes || 0;
        file.chunks = progress.chunks || [];
    }
    
    // 显示文件列表
    var fileItem = document.createElement('div');
    fileItem.id = file.id;
    fileItem.className = 'file-item';
    fileItem.innerHTML = 
        '' + file.name + ' (' + (file.size / 1024 / 1024).toFixed(2) + 'MB)' +
        '' +
        '等待上传...';
    document.getElementById('fileList').appendChild(fileItem);
});

// 上传进度事件
uploader.on('uploadProgress', function(file, percentage) {
    var fileItem = document.getElementById(file.id);
    var progressBar = fileItem.querySelector('.progress-bar');
    progressBar.style.width = (percentage * 100) + '%';
    
    // 保存进度
    saveUploadProgress(file, {
        uploadedBytes: file.size * percentage,
        chunks: file.chunks || []
    });
});

// 上传成功事件
uploader.on('uploadSuccess', function(file, response) {
    var fileItem = document.getElementById(file.id);
    fileItem.querySelector('.status').innerHTML = '上传成功!';
    fileItem.querySelector('.progress-bar').style.backgroundColor = '#4CAF50';
});

// 上传错误事件
uploader.on('uploadError', function(file, reason) {
    var fileItem = document.getElementById(file.id);
    fileItem.querySelector('.status').innerHTML = '上传失败: ' + reason;
    fileItem.querySelector('.progress-bar').style.backgroundColor = '#f44336';
});

// 开始上传按钮
document.getElementById('uploadBtn').addEventListener('click', function() {
    uploader.upload();
    document.getElementById('pauseBtn').style.display = 'inline-block';
});

// 暂停按钮
document.getElementById('pauseBtn').addEventListener('click', function() {
    uploader.stop(true);
    this.style.display = 'none';
});

// 文件夹上传处理(非标准方法)
document.getElementById('filePicker').addEventListener('change', function(e) {
    var files = e.target.files;
    if (files.length > 0) {
        // 模拟文件夹结构(实际浏览器限制,无法直接获取文件夹结构)
        // 这里需要用户手动选择文件夹中的所有文件
        for (var i = 0; i < files.length; i++) {
            // 可以在文件名中编码路径信息,如 "folder/subfolder/file.txt"
            // 服务器端需要解析这种结构
            uploader.addFiles(files[i]);
        }
    }
});

// 分片上传前处理
uploader.on('uploadBeforeSend', function(block, data) {
    // 加密分片数据
    var deferred = WebUploader.Deferred();
    
    // 读取文件分片
    var file = block.file;
    var chunk = file.source.slice(block.start, block.end);
    
    encryptFile(file, {start: block.start, end: block.end}, function(encrypted) {
        // 添加额外参数
        data.chunk = block.chunk;
        data.chunks = block.chunks;
        data.md5 = block.file.md5 || ''; // 需要在文件添加时计算MD5
        data.relativePath = block.file.relativePath || ''; // 文件夹路径
        data.encryptedData = encrypted;
        
        deferred.resolve();
    });
    
    return deferred.promise();
});

// 初始化时恢复未完成的上传
function resumeIncompleteUploads() {
    // 实际项目中需要更复杂的逻辑来恢复上传
    // 这里只是简单示例
    var files = uploader.getFiles();
    files.forEach(function(file) {
        var progress = getUploadProgress(file);
        if (progress && progress.uploadedBytes > 0 && progress.uploadedBytes < file.size) {
            file.uploadedBytes = progress.uploadedBytes;
            file.chunks = progress.chunks;
        }
    });
}

// 页面加载时恢复上传
window.onload = function() {
    resumeIncompleteUploads();
};

后端实现(SpringBoot版)

pom.xml 依赖



    4.0.0
    
        org.springframework.boot
        spring-boot-starter-parent
        2.7.0
        
    
    
    com.example
    file-management
    0.0.1-SNAPSHOT
    file-management
    毕业设计-文件管理系统
    
    
        1.8
    
    
    
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
        
        
            mysql
            mysql-connector-java
            runtime
        
        
        
        
            org.mybatis.spring.boot
            mybatis-spring-boot-starter
            2.2.2
        
        
        
        
            com.aliyun.oss
            aliyun-sdk-oss
            3.15.1
        
        
        
        
            org.projectlombok
            lombok
            true
        
        
        
        
            commons-io
            commons-io
            2.11.0
        
        
        
        
            org.bouncycastle
            bcprov-jdk15on
            1.70
        
    
    
    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
        
    

application.properties 配置

# Server
server.port=8080

# MySQL
spring.datasource.url=jdbc:mysql://localhost:3306/file_management?useSSL=false&serverTimezone=UTC&characterEncoding=utf8
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# MyBatis
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.example.filemanagement.entity

# Aliyun OSS
aliyun.oss.endpoint=your-oss-endpoint
aliyun.oss.accessKeyId=your-access-key-id
aliyun.oss.accessKeySecret=your-access-key-secret
aliyun.oss.bucketName=your-bucket-name

# File Storage
file.storage.path=/data/file_storage/

核心控制器 (UploadController.java)

package com.example.filemanagement.controller;

import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectResult;
import com.example.filemanagement.entity.FileChunk;
import com.example.filemanagement.entity.FileInfo;
import com.example.filemanagement.service.FileService;
import com.example.filemanagement.util.CryptoUtil;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api")
public class UploadController {

    @Value("${aliyun.oss.endpoint}")
    private String endpoint;
    
    @Value("${aliyun.oss.accessKeyId}")
    private String accessKeyId;
    
    @Value("${aliyun.oss.accessKeySecret}")
    private String accessKeySecret;
    
    @Value("${aliyun.oss.bucketName}")
    private String bucketName;
    
    @Value("${file.storage.path}")
    private String localStoragePath;
    
    @Autowired
    private FileService fileService;

    /**
     * 检查文件分片状态(用于断点续传)
     */
    @GetMapping("/chunk/check")
    public Map checkChunk(
            @RequestParam String fileMd5,
            @RequestParam Integer chunk,
            @RequestParam Integer chunks) {
        
        Map result = new HashMap<>();
        
        // 检查分片是否已上传
        boolean exists = fileService.checkChunk(fileMd5, chunk);
        
        result.put("uploaded", exists);
        if (exists) {
            result.put("message", "分片已上传,跳过");
        } else {
            result.put("message", "需要上传分片 " + chunk);
        }
        
        return result;
    }

    /**
     * 上传文件分片
     */
    @PostMapping("/chunk/upload")
    public Map uploadChunk(
            @RequestParam("file") MultipartFile file,
            @RequestParam String fileMd5,
            @RequestParam Integer chunk,
            @RequestParam Integer chunks,
            @RequestParam(required = false) String relativePath,
            @RequestParam String encryptedData) throws IOException {
        
        Map result = new HashMap<>();
        
        try {
            // 解密数据(与前端加密对应)
            byte[] decryptedData = CryptoUtil.decrypt(encryptedData);
            
            // 保存分片到临时目录
            String chunkPath = localStoragePath + "temp/" + fileMd5;
            String chunkFileName = chunkPath + "/" + chunk;
            
            // 确保目录存在
            java.io.File chunkDir = new java.io.File(chunkPath);
            if (!chunkDir.exists()) {
                chunkDir.mkdirs();
            }
            
            // 保存分片
            IOUtils.write(decryptedData, new java.io.FileOutputStream(chunkFileName));
            
            // 记录分片信息到数据库
            FileChunk fileChunk = new FileChunk();
            fileChunk.setFileMd5(fileMd5);
            fileChunk.setChunkNumber(chunk);
            fileChunk.setTotalChunks(chunks);
            fileChunk.setPath(chunkFileName);
            fileService.saveChunk(fileChunk);
            
            // 如果是最后一个分片,合并文件
            if (chunk == chunks - 1) {
                String mergedFilePath = mergeChunks(fileMd5, chunks, relativePath);
                
                // 上传到OSS
                String ossPath = uploadToOSS(mergedFilePath, relativePath);
                
                // 保存文件信息到数据库
                FileInfo fileInfo = new FileInfo();
                fileInfo.setName(file.getOriginalFilename());
                fileInfo.setPath(ossPath);
                fileInfo.setSize(file.getSize());
                fileInfo.setType(file.getContentType());
                fileInfo.setMd5(fileMd5);
                fileInfo.setRelativePath(relativePath);
                fileService.saveFileInfo(fileInfo);
                
                // 删除临时分片
                deleteChunkDirectory(chunkPath);
                
                result.put("merged", true);
                result.put("path", ossPath);
            }
            
            result.put("success", true);
            result.put("message", "分片上传成功");
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "分片上传失败: " + e.getMessage());
            e.printStackTrace();
        }
        
        return result;
    }

    /**
     * 合并分片
     */
    private String mergeChunks(String fileMd5, int chunks, String relativePath) throws IOException {
        String tempDir = localStoragePath + "temp/" + fileMd5;
        String mergedFilePath;
        
        if (relativePath != null && !relativePath.isEmpty()) {
            // 处理文件夹结构
            String dirPath = localStoragePath + "files/" + relativePath;
            new java.io.File(dirPath).mkdirs();
            mergedFilePath = dirPath + "/" + UUID.randomUUID() + ".tmp";
        } else {
            mergedFilePath = localStoragePath + "files/" + UUID.randomUUID() + ".tmp";
        }
        
        try (java.io.FileOutputStream fos = new java.io.FileOutputStream(mergedFilePath, true)) {
            for (int i = 0; i < chunks; i++) {
                String chunkPath = tempDir + "/" + i;
                java.io.File chunkFile = new java.io.File(chunkPath);
                
                if (chunkFile.exists()) {
                    byte[] bytes = org.apache.commons.io.FileUtils.readFileToByteArray(chunkFile);
                    fos.write(bytes);
                }
            }
        }
        
        return mergedFilePath;
    }

    /**
     * 上传到OSS
     */
    private String uploadToOSS(String filePath, String relativePath) throws IOException {
        // 创建OSSClient实例
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        
        try {
            // 生成OSS路径
            String objectName;
            if (relativePath != null && !relativePath.isEmpty()) {
                objectName = "uploads/" + relativePath + "/" + UUID.randomUUID() + "_" + new java.io.File(filePath).getName();
            } else {
                objectName = "uploads/" + UUID.randomUUID() + "_" + new java.io.File(filePath).getName();
            }
            
            // 上传文件
            java.io.File file = new java.io.File(filePath);
            ObjectMetadata metadata = new ObjectMetadata();
            metadata.setContentLength(file.length());
            
            PutObjectResult putResult = ossClient.putObject(bucketName, objectName, new java.io.FileInputStream(file), metadata);
            
            return objectName;
        } finally {
            // 关闭OSSClient
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
    }

    /**
     * 删除分片目录
     */
    private void deleteChunkDirectory(String chunkPath) {
        try {
            org.apache.commons.io.FileUtils.deleteDirectory(new java.io.File(chunkPath));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

加密工具类 (CryptoUtil.java)

package com.example.filemanagement.util;

import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.encoders.Hex;

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.Security;

public class CryptoUtil {

    static {
        // 添加BouncyCastle提供者
        if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
            Security.addProvider(new BouncyCastleProvider());
        }
    }

    private static final String KEY = "secret-key-123456"; // 实际项目中应该从配置读取
    private static final String IV = "1234567890123456";  // 初始化向量

    /**
     * 解密数据(与前端加密对应)
     */
    public static byte[] decrypt(String encryptedData) throws Exception {
        // 这里简化处理,实际应该使用更安全的解密方式
        // 前端使用CryptoJS.AES.encrypt,这里使用对应的解密方式
        
        // 实际项目中应该使用正确的解密方式,这里只是示例
        // 通常应该使用Base64解码,然后解密
        
        // 由于前端使用了toString(),这里需要特殊处理
        // 实际项目中应该避免这种不规范的加密传输方式
        
        // 这里只是一个示例,实际项目中应该使用标准的加密传输方式
        return encryptedData.getBytes();
    }

    /**
     * 更好的解密方法(如果前端使用标准AES加密)
     */
    public static byte[] decryptProperly(String encryptedBase64) throws Exception {
        // 初始化密钥和IV
        SecretKeySpec secretKey = new SecretKeySpec(KEY.getBytes(), "AES");
        IvParameterSpec ivSpec = new IvParameterSpec(IV.getBytes());
        
        // 初始化Cipher
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding", "BC");
        cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
        
        // Base64解码
        byte[] encryptedBytes = org.bouncycastle.util.encoders.Base64.decode(encryptedBase64);
        
        // 解密
        return cipher.doFinal(encryptedBytes);
    }
}

数据库设计

文件信息表 (file_info)

CREATE TABLE `file_info` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL COMMENT '文件名',
  `path` varchar(512) NOT NULL COMMENT '存储路径',
  `size` bigint NOT NULL COMMENT '文件大小(字节)',
  `type` varchar(100) DEFAULT NULL COMMENT '文件类型',
  `md5` varchar(64) DEFAULT NULL COMMENT '文件MD5',
  `relative_path` varchar(512) DEFAULT NULL COMMENT '相对路径(用于文件夹结构)',
  `upload_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
  `uploader` varchar(100) DEFAULT NULL COMMENT '上传者',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态(0-删除,1-正常)',
  PRIMARY KEY (`id`),
  KEY `idx_md5` (`md5`),
  KEY `idx_path` (`path`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件信息表';

文件分片表 (file_chunk)

CREATE TABLE `file_chunk` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `file_md5` varchar(64) NOT NULL COMMENT '文件MD5',
  `chunk_number` int NOT NULL COMMENT '分片编号(从0开始)',
  `total_chunks` int NOT NULL COMMENT '总分片数',
  `path` varchar(512) NOT NULL COMMENT '分片存储路径',
  `upload_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_file_chunk` (`file_md5`,`chunk_number`),
  KEY `idx_file_md5` (`file_md5`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件分片表';

兼容性处理

IE8/9 特殊处理

由于IE8/9对HTML5支持有限,我们需要:

  1. 使用Flash版本的WebUploader(已配置在HTML中)
  2. 避免使用现代JavaScript特性
  3. 提供友好的降级提示
// 在uploader.js中添加IE特殊处理
if (ieVersion && ieVersion <= 9) {
    // 修改分片大小为2MB(IE内存限制)
    uploader.option('chunkSize', 2 * 1024 * 1024);
    
    // 禁用多线程(IE不稳定)
    uploader.option('threads', 1);
    
    // 添加IE进度条轮询(IE不支持Progress事件)
    var ieProgressInterval = setInterval(function() {
        var stats = uploader.getStats();
        var progress = stats.successNum / (stats.successNum + stats.uploadFailNum + stats.queueNum);
        document.getElementById('progressBar').style.width = (progress * 100) + '%';
    }, 1000);
    
    // 清除轮询
    uploader.on('uploadFinished', function() {
        clearInterval(ieProgressInterval);
    });
}

部署说明

  1. 前端部署

    • 将HTML和JS文件放在SpringBoot的src/main/resources/static目录
    • 或者使用Vue CLI构建后部署到Nginx
  2. 后端部署

    • 打包为JAR:mvn clean package
    • 运行:java -jar file-management-0.0.1-SNAPSHOT.jar
  3. 数据库初始化

    • 执行SQL脚本创建表
    • 配置application.properties中的数据库连接
  4. OSS配置

    • 在阿里云控制台创建Bucket
    • 获取AccessKey并配置到application.properties

测试建议

  1. 小文件测试(<100MB):

    • 验证基本上传功能
    • 检查文件是否正确存储在OSS
  2. 大文件测试(>1GB):

    • 测试断点续传
    • 测试网络中断后恢复
  3. 文件夹测试

    • 上传包含子文件夹的结构
    • 验证服务器端是否保留结构
  4. 兼容性测试

    • 在Chrome/Firefox/IE9中测试
    • 在Windows7+IE9的老机器上测试

常见问题解决

  1. 上传失败

    • 检查后端日志
    • 验证OSS配置是否正确
    • 检查本地存储路径权限
  2. 断点续传不工作

    • 确保localStorage可用
    • 检查文件MD5计算是否一致
  3. IE兼容性问题

    • 确保引入了Flash fallback
    • 避免使用ES6+语法

总结

这个项目简直是我毕业设计的"噩梦",但也是最好的学习机会。通过这个项目,我学会了:

  • 如何处理大文件上传
  • 前端加密的基本方法
  • 断点续传的实现原理
  • 兼容各种浏览器的痛苦
  • 实际项目与学校项目的巨大差异

最重要的是,我明白了为什么网上那些"开源项目"都不敢留联系方式——因为这代码质量,出了问题真的会被骂死啊!

欢迎各位学弟学妹加入我们的QQ群:374992201,一起来完善这个项目。新人有红包,推荐有提成,说不定还能找到工作呢!

最后呼吁:如果有师兄师姐在IT公司工作,求内推啊!小弟这里感激不尽,毕业后请你吃饭!

SQL示例

创建数据库

image

配置数据库连接

image

自动下载maven依赖

image

启动项目

image

启动成功

image

访问及测试

默认页面接口定义

image

在浏览器中访问

image

数据表中的数据

image

示例下载

下载完整示例

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐