简介:这是一份基于Java技术的仿百度网盘设计源码,适合Java Web开发者、毕业设计或课程设计人员学习参考。项目完整模拟百度网盘的文件存储、在线管理、分享等核心功能,覆盖用户权限控制、文件上传下载、界面渲染等模块,并采用XML与YAML配置数据库和服务参数,便于二次开发与部署。压缩包共292个文件,大小约39.91MB,主要包含126个Java源文件、129个class文件、26个XML配置、2个YAML配置,以及SQL数据库脚本、JAR包、属性文件和Git忽略文件等,目录结构清晰,方便按功能定位代码。目前已有260人学习下载,适合想要通过完整项目理解网盘系统设计思路、掌握Java技术栈的开发者。资源附带数据库初始化脚本与配置文件,可快速搭建运行环境,是仿照主流云盘产品进行实战练手的良好素材。
1. 仿百度网盘不是高仿UI,而是把文件生命周期拆明白
基于Java技术的仿百度网盘设计源码这类工程,经常出现在课程设计、毕业设计和个人作品集里,它要解决的不是“做一个看起来像网盘的网页”,而是把上传、秒传、分片、断点续传、目录树、分享提取码这一整条文件生命周期跑通。我见过很多demo卡在“上传成功”这一步,却不知道文件到了服务器之后去哪、重名怎么处理、删除能不能恢复。实际用下来,这个标题最有价值的三个能力是:用MD5做文件去重,用分片做稳定上传,用数据表把“物理文件”和“用户目录”拆开。适合三类人:准备投Java开发工程师岗位、想拿项目回答Java面试题的人;需要一个能部署到服务器上的私有网盘的学生或小团队;想理解文件系统与数据库如何配合的Spring Boot使用者。这篇笔记我会按照从选型到建表、到核心代码、到部署前补丁的顺序讲,最后告诉你哪些参数不调会翻车。
2. 技术选型与项目骨架:为什么是Spring Boot + MySQL + Redis的组合
2.1 核心组件选型:从上传并发到断点续传的取舍
仿百度网盘这类Java后端项目,最常见的组合是Spring Boot + MySQL + Redis + MyBatis-Plus,存储层先用本地磁盘,再抽象出对象存储接口。选这套不是因为生态好看,而是三个组件恰好覆盖网盘三个硬需求:Spring Boot负责把文件上传、下载、分享这些HTTP接口快速组织起来;MySQL负责把用户、文件、分享关系落库;Redis负责扛住分片上传的进度记录和秒传的并发去重。文件上传是典型的高并发写入场景,但网盘写入又不要求像订单系统那样强事务,它更看重“同一文件只存一份”,所以file_info表成了整个去重的核心。
为什么不建议直接用MongoDB或者Elasticsearch做文件元数据存储?因为网盘的查询模型极度简单,绝大多数操作是“按user_id和parent_id查列表”“按md5和size查唯一记录”,这种场景MySQL索引就能精准命中,引入搜索引擎只会增加索引同步和运维成本。Redis在这里不是缓存,而是放分片索引集合和上传并发锁。JDK版本建议用8或11,Spring Boot用2.7.x,版本太高会对机器部署环境产生不必要的限制,也容易让源码里的配置项在一个新版本里突然失效。MyBatis-Plus主要负责把SQL从selectByMd5这种重复劳动里省出来,它的LambdaQueryWrapper在拼接动态条件时很顺手,源码维护成本也低。
2.2 项目结构设计与通用返回体
拿到源码先别急着跑,看懂目录再动手。我一般把工程分成controller、service、mapper、entity、config、common六层,common里放Result统一返回体、异常处理、分页工具。controller只做参数接收和状态码转换,业务逻辑全部沉到service,这样面试聊起来也能说清楚“我的是什么架构”,而不是一个controller写到两千行。如果你拿到的是单模块Maven工程,也不必急着重构成多模块,单模块完全够用,关键是把文件上传相关的接口单独放一个UploadController,把分享和目录操作分开。
controller层最容易被忽略的是返回体结构,很多新手的controller直接把实体类或者Map返回给前端,导致接口一会儿返回{data: ...},一会儿返回{items: ...},前端联调时全靠猜。我习惯统一用一个Result对象包住所有响应,以下是最小的实现:
public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "ok"; r.data = data; return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.code = 1; r.message = message; return r; } }这个类是所有接口的“对外契约”,code为0表示成功,非0表示失败。有了它,后面秒传、分片上传、合并接口的返回逻辑就可以统一写成Result.ok(...)或者Result.error(...)。exception包里的全局异常处理器也依赖这个返回体,比如上传文件超过max-size时抛出的异常,最终都会被转成Result.error,而不是把堆栈直接打在http响应里。参数校验建议直接用Spring的@Validated,在DTO字段上加@NotNull和@Size,省掉手写一长串if判断。
2.3 启动配置、初始化目录与第一次跑通
配置文件是新手翻车的高发区。你需要配置数据源、Redis、本地文件存储根目录、临时分片目录四个块。存储根目录和临时分片目录如果不配置,默认值很容易落在系统的临时目录里,重启一次就丢数据。下面这个application.yml是本地开发版,生产环境要替换密码并开启密码环境变量注入:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/netdisk?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: isDeleted logic-delete-value: 1 logic-not-delete-value: 0 file: storage-root: /data/netdisk/files tmp-root: /data/netdisk/tmp chunk-size: 2097152 max-size: 10737418240map-underscore-to-camel-case必须开,否则parent_id映射不到parentId,查询出来全是null。chunk-size是给前端切分用的约定值,单位为字节,2MB对应2097152;max-size限制单文件10GB,超过会在上传入口直接拒绝。首次运行前需要先创建/data/netdisk/files和/data/netdisk/tmp两个目录,同时把建表SQL导入数据库:
mkdir -p /data/netdisk/files /data/netdisk/tmp chown -R www-data:www-data /data/netdisk mysql -uroot -p netdisk < docs/schema.sql目录属主要根据你的后端进程用户调整,不调整在高权限下会变成root创建的文件,后续上传线程写入时可能没权限覆盖。更省心的做法是在Spring Boot启动类里加一个ApplicationRunner,随着应用启动自动检查目录:
@Component public class StoragePathInitializer implements ApplicationRunner { @Value("${file.storage-root}") private String storageRoot; @Value("${file.tmp-root}") private String tmpRoot; @Override public void run(ApplicationArguments args) throws Exception { Files.createDirectories(Paths.get(storageRoot)); Files.createDirectories(Paths.get(tmpRoot)); } }这一步做好以后,分片上传接口里的transferTo就再也不会因为目录不存在而抛IOException。启动时打开StdOutImpl日志,第一次跑分片上传能看到每一个SQL和Redis操作,排查问题非常直观。还有个小的本地联调技巧:如果你用前端工程单独起端口访问后端,记得在config里配置Cors跨域映射,允许的前端端口不要写通配符,否则后面做Cookie会话登录时会因为跨域携带凭据失败。
3. 数据库设计与文件存储方案:网盘系统的地基
3.1 用户、文件元数据、分享链接的表结构设计
网盘和普通文件系统的最大区别是同一个物理文件可以挂到多个用户目录下,所以数据库不能只建一张file表。这个源码里最关键的设计决策就是把file_info和user_file拆成两张表:file_info记录真实存在的文件块,user_file记录“哪个用户在哪条路径下看见了什么名字”。秒传的本质就是user_file插入一条新记录,把file_info_id指向已经存在的那条物理文件。用户表不复杂,但要预留逻辑删除和状态字段,为账号禁用和以后接入第三方登录铺路。
file_info表的唯一索引要建在(file_md5, file_size)上,这是秒传判断的唯一依据。user_file表的核心字段是parent_id和is_dir,一个网盘目录树的所有关系都靠这两个字段表达。分享链接单独放share_info表,因为分享的是user_file条目,不是物理文件,这样分享的文件夹在源文件删除后也能控制回收。建表脚本如下:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL, `password` varchar(128) NOT NULL COMMENT 'BCrypt加密', `status` tinyint NOT NULL DEFAULT '1', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `file_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `file_name` varchar(255) NOT NULL, `file_md5` char(32) NOT NULL, `file_size` bigint NOT NULL, `storage_path` varchar(512) NOT NULL, `storage_type` tinyint NOT NULL DEFAULT '0' COMMENT '0本地磁盘,1对象存储', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_md5_size` (`file_md5`, `file_size`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `user_file` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `file_info_id` bigint DEFAULT NULL COMMENT '文件夹时为NULL', `parent_id` bigint NOT NULL DEFAULT '0' COMMENT '0表示根目录', `is_dir` tinyint NOT NULL DEFAULT '0', `file_name` varchar(255) NOT NULL, `file_size` bigint NOT NULL DEFAULT '0', `is_deleted` tinyint NOT NULL DEFAULT '0', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_parent` (`user_id`, `parent_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `share_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_file_id` bigint NOT NULL, `share_code` varchar(16) NOT NULL, `extract_code` varchar(4) NOT NULL, `expire_time` datetime DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `uk_share_code` (`share_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为什么file_info里要存file_name?因为同一个MD5文件第一次入库时得保留原始名字,后面秒传的用户虽然有自己的命名,但物理文件的出处还在。file_info的storage_path字段建议写成相对路径,不要带绝对磁盘目录,这样以后从本地磁盘切到MinIO时,不用回填历史数据。share_info的share_code用随机短串,不要用自增id直接当分享码,否则别人遍历id就能刷出你的分享列表。这里的字符集统一用utf8mb4,否则中文文件名和特殊符号在入库时会因为字符集不支持而报错。
3.2 文件物理存储:本地磁盘 vs 对象存储,怎么抽象
很多教程直接把文件写到/data/files/原文件名,这会让所有文件堆在一个目录里,千万级文件时单目录的IO性能会直线下降。正确做法是按MD5的前两位分桶,再把MD5作为文件名,比如MD5为abcd1234...的文件存到/data/netdisk/files/ab/abcd1234...。分桶带来的额外收益是删除和移动都只涉及字符串拼接,不需要关心业务路径。源码里应该有一个StorageService接口,把“保存、读取、删除、判断是否存在”抽象出来,这是将来切换存储后端的关键。
我给出的本地实现接口如下:
public interface StorageService { String store(InputStream in, String relativePath) throws IOException; void delete(String relativePath); InputStream load(String relativePath) throws IOException; boolean exists(String relativePath); }本地实现store方法时,要先生成yyyy/MM/dd日期路径,再拼上MD5的前两位分桶,最后才是文件哈希,最终得到的relativePath类似2025/01/15/ab/abcd1234...。这样既避免了单目录文件过多,又让运维能按日期检查磁盘增长。delete方法用Files.deleteIfExists,配合common层的deleteQuietly工具吞掉文件不存在异常。切对象存储时,只需要实现同一套接口,把store换成MinIO的putObject,在配置类里把StorageService的@Autowired注入改成按storage_type选择,业务层代码一行都不用动。
3.3 目录树与批量移动:parent_id自关联之外还要做什么
user_file表用parent_id做自关联,一层层向下找子文件,查询当前目录列表就是一次简单的equal条件:
List<UserFile> children = userFileMapper.selectList( new LambdaQueryWrapper<UserFile>() .eq(UserFile::getUserId, userId) .eq(UserFile::getParentId, parentId) .eq(UserFile::getIsDeleted, 0) .orderByDesc(UserFile::getIsDir) );这个查询虽然简单,但隐藏着一个性能问题:每次打开文件夹都查一次数据库,用户连续点击多级目录会产生一串查询。工程上可以额外维护一个path字段,保存“/root/文件夹A/文件夹B”这种全路径。移动文件夹时,SQL要用REPLACE或CONCAT批量更新所有子节点,否则只有一个顶层条目改了parent_id,下层所有条目的path全部对不上。可参考的移动语句片段:
UPDATE user_file SET path = CONCAT('新目录前缀', SUBSTRING(path, LENGTH('旧目录前缀')+1)) WHERE path LIKE '旧目录前缀%' AND user_id = #{userId}代价是移动文件夹时要多跑一条批量更新SQL,属于典型的“读多写少”优化。还有一层必须注意的语义:删除文件夹不能把user_file记录物理删除,否则回收站功能就废了。is_deleted字段配合逻辑删除配置,让MyBatis-Plus的删除操作自动变成update,回收站列表、还原、彻底删除都建立在同一套软删除机制上。逻辑删除与唯一索引会有冲突,比如在根目录反复创建“新建文件夹”,软删除的记录还占着名字,硬插入时可能会撞唯一索引,所以查询时显式排除已删除记录非常重要。
4. 核心功能落地:秒传、分片上传、断点续传的编码实现
4.1 秒传:用MD5做文件去重,接口怎么设计
秒传是整个网盘产品里最有辨识度的功能,它的价值在于不同用户上传同一个文件时,服务器只保留一份物理文件。前端拿到File对象后,先算出MD5,再带着文件大小向后端发检查请求。后端只做一件事:到file_info表按(md5, size)查记录,存在就把这个物理文件挂到当前用户的user_file下,不存在就返回“需要走分片上传”。这个接口读写混合,要注意两个用户同时传同一个新文件时,file_info表会同时插入两条相同MD5记录,唯一索引能挡住后面那条。
秒传检查接口的常用实现如下:
@PostMapping("/upload/check") public Result<UploadCheckVO> check(@RequestBody UploadCheckDTO dto) { LambdaQueryWrapper<FileInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(FileInfo::getFileMd5, dto.getMd5()); wrapper.eq(FileInfo::getFileSize, dto.getSize()); FileInfo fileInfo = fileInfoMapper.selectOne(wrapper); UploadCheckVO vo = new UploadCheckVO(); if (fileInfo != null) { UserFile userFile = new UserFile(); userFile.setUserId(dto.getUserId()); userFile.setFileInfoId(fileInfo.getId()); userFile.setParentId(dto.getParentId()); userFile.setFileName(dto.getFileName()); userFile.setFileSize(fileInfo.getFileSize()); userFile.setIsDir(0); userFileMapper.insert(userFile); vo.setStatus(1); vo.setFileId(fileInfo.getId()); } else { vo.setStatus(0); vo.setUploadId(UUID.randomUUID().toString().replace("-", "")); } return Result.ok(vo); }这段逻辑里status=0时,前端拿到uploadId进入分片上传流程;status=1时页面直接跳“上传成功”。有一个隐藏参数建议调出来:如果dto里带了fileId,说明是重试场景,应该先查出user_file里是否已有同名记录,避免连点两次秒传按钮造成重复条目。如果前端的上传流程是“先check、再单独bind”,那check接口只返回状态,绑定动作交给bind接口更稳。秒传的判断只信数据库里的唯一索引,不要用Redis先查再插,否则并发窗口期还是会产生脏数据。
4.2 分片上传与合并:从前端到后端的完整链路
超过50MB的文件不切片直接传,很容易在弱网环境里传一半断掉。分片上传的核心思路是把大文件切成固定大小的块,每块独立上传,全部完成后由服务器合并。前端切片的代码非常固定,关键参数是分片大小和uploadId。uploadId在check接口或者专门的分片初始化接口里生成,前端要一直保存到合并完成。以2MB分片大小为例:
const CHUNK_SIZE = 2 * 1024 * 1024; const file = document.getElementById('uploadFile').files[0]; let chunkIndex = 0; for (let start = 0; start < file.size; start += CHUNK_SIZE) { const chunk = file.slice(start, Math.min(start + CHUNK_SIZE, file.size)); const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('chunkIndex', chunkIndex); formData.append('file', chunk, `${file.name}.part${chunkIndex}`); await axios.post('/api/upload/chunk', formData, { timeout: 60000 }); chunkIndex++; }chunkIndex在Java后端要原样解析成int,写入临时目录时命名为chunkIndex + ".part",禁止直接用原始文件名。这里前端并发数是1,一片一片依次传;如果调并发,可以用Promise.all同时传3片,但服务端合并时必须有全部分片才能开始,并发会让HTTP请求的时序变得不可控,重试复杂度也成倍上升。表单里再带一个timestamp,用来在后端避免相同分片重复提交。对应的后端分片接收接口如下:
@PostMapping("/upload/chunk") public Result<String> uploadChunk(ChunkUploadDTO dto, @RequestPart("file") MultipartFile chunk) throws IOException { Path dir = Paths.get(tmpRoot, dto.getUploadId()); Files.createDirectories(dir); Path target = dir.resolve(dto.getChunkIndex() + ".part"); chunk.transferTo(target.toAbsolutePath()); redisTemplate.opsForSet().add(UPLOAD_CHUNK_KEY + dto.getUploadId(), dto.getChunkIndex()); return Result.ok("分片已接收"); }这段代码用Redis的Set保存已上传分片索引,比List更好,因为Set天然去重,前端重复提交同一片不会产生脏数据。transferTo在Spring Boot里接收MultipartFile时要特别注意:目标目录必须预先创建,如果目录不存在,即使打成jar包运行也会抛IOException,所以上面用Files.createDirectories(dir)兜底。分片上传接口不要放太多业务校验,把“合并前统一校验”放到merge接口,这样能减少重复代码。
合并接口是网盘工程里最接近“翻车现场”的代码。先检查临时目录里分片数量,再按整数索引排序,用OutputStream逐个把分片写进最终文件。缓冲区建议用8192字节,太大会让内存峰值上升,太小会拖慢写盘速度。合并完成后用MD5重新计算整个文件,与前端上传前传过来的md5比对,不一致直接删除文件返回失败:
@PostMapping("/upload/merge") public Result<FileInfo> merge(@RequestParam String uploadId, @RequestParam String md5, @RequestParam Long size, @RequestParam String fileName) throws IOException { Path dir = Paths.get(tmpRoot, uploadId); if (!Files.exists(dir)) { return Result.error("上传分片不存在"); } List<Path> chunks = new ArrayList<>(); try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) { for (Path p : stream) { chunks.add(p); } } chunks.sort(Comparator.comparingInt(p -> { String name = p.getFileName().toString(); return Integer.parseInt(name.substring(0, name.indexOf(".part"))); })); String relativePath = "/" + md5.substring(0, 2) + "/" + md5; File targetFile = new File(storageRoot, relativePath); File parentDir = targetFile.getParentFile(); if (!parentDir.exists()) { parentDir.mkdirs(); } try (FileOutputStream fos = new FileOutputStream(targetFile)) { byte[] buffer = new byte[8192]; for (Path chunk : chunks) { try (FileInputStream fis = new FileInputStream(chunk.toFile())) { int len; while ((len = fis.read(buffer)) != -1) { fos.write(buffer, 0, len); } } } } String actualMd5; try (InputStream is = Files.newInputStream(targetFile.toPath())) { actualMd5 = DigestUtils.md5Hex(is); } if (!md5.equalsIgnoreCase(actualMd5) || targetFile.length() != size) { Files.deleteIfExists(targetFile.toPath()); return Result.error("合并校验失败:文件损坏,请重新上传"); } FileInfo fileInfo = new FileInfo(); fileInfo.setFileName(fileName); fileInfo.setFileMd5(md5); fileInfo.setFileSize(size); fileInfo.setStoragePath(relativePath); fileInfo.setStorageType(0); fileInfoMapper.insert(fileInfo); try { Files.walkFileTree(dir, new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { Files.deleteIfExists(file); return FileVisitResult.CONTINUE; } @Override public FileVisitResult postVisitDirectory(Path d, IOException exc) throws IOException { Files.deleteIfExists(d); return FileVisitResult.CONTINUE; } }); } catch (IOException ignored) { // 临时文件清理失败不影响主流程,留给定时任务扫 } redisTemplate.delete(UPLOAD_CHUNK_KEY + uploadId); return Result.ok(fileInfo); }这段合并代码里有个容易忽略的细节:目标文件名直接用MD5,好处是同一文件秒传时物理文件只有一份。DigestUtils.md5Hex来自commons-codec,这也是第2章pom里特意加它的原因。分片临时目录递归清理用Files.walkFileTree,因为目录里有几十个part文件,直接用Files.delete删不干净,会留下大量临时垃圾。最后再删Redis索引,保证“临时目录-分片索引-物理文件”三者一致,不会出现用户上传一半、服务端残留几百MB垃圾的情况。
4.3 断点续传与进度恢复:已上传分片如何查询
断点续传最常见的实现是“重新上传时跳过已存在的分片”。前端在用户点击继续按钮后,向后端请求已上传分片索引列表,然后把待传分片里已经被标记过的片跳过。这个接口读的数据源就是Redis里那个Set:
@GetMapping("/upload/{uploadId}/chunks") public Result<Set<Integer>> uploadedChunks(@PathVariable String uploadId) { String key = UPLOAD_CHUNK_KEY + uploadId; Set<Object> members = redisTemplate.opsForSet().members(key); Set<Integer> indices = new HashSet<>(); if (members != null) { for (Object member : members) { indices.add((Integer) member); } } return Result.ok(indices); }这段逻辑的前端配合是:拿到Set后,从chunkIndex=0开始循环,如果Set里包含当前index就跳过,否则才发起上传。这样即使浏览器刷新,也能从断点恢复。另一个实用技巧是给Redis的分片key设置过期时间,比如24小时,否则用户上传一半放弃时,临时目录和Redis里会积累大量垃圾。设置过期时间推荐在每次收到分片时调用expire命令,而不是在上传初始化时设置,这样可以保证长时间上传不被中间过期打断。如果用户量上来,可以把分片索引存成字符串,避免跨语言反序列化带来的ClassCastException,纯Java工程里Integer存进去取出来问题不大。
5. 仿百度网盘踩坑记录:从分片合并到并发去重的常见问题排查
5.1 现象:合并后的文件打开提示已损坏
很多人在本地测试小文件一切正常,一旦超过20MB,下载下来的文件就打不开,或者视频播放到一半花屏。定位时会发现分片数量是对、文件大小也对,但二进制内容顺序是乱的。原因基本落在排序上:如果把分片文件名按字符串排序,10.part会排在2.part前面,合并顺序就变成了1、10、11、2、3。解决方式是在merge接口里用Integer.parseInt解析文件名,按整数排序,不能简单地用Comparator.naturalOrder()处理字符串路径。更保险的动作是在生成分片时就把文件名改成定宽格式,比如00002.part,这样字符串排序和整数排序结果统一。我现在写代码时两个方案都会用:前端生成index字段,后端排序按index解析,彻底绕开这个问题。
5.2 现象:秒传状态返回成功,但下载下来的内容是另一个文件
这类问题最隐蔽,用户上传一个文件,点击秒传后显示成功,下载得到的文件却是别的内容。原因基本是MD5判断条件少了file_size,不同文件在特殊构造下可能碰撞出相同MD5,更常见的是前端只传了MD5没传大小,后端就根据MD5去匹配,匹配到了一条总大小不一致的脏数据。解决方式是把秒传查询条件固定为file_md5和file_size两个字段都相等,建表时给这两个字段建联合唯一索引。如果你的源码里已经用Redis做了秒传缓存,也要把Redis的value设计成md5:size拼接,不要只放一个md5。还有一个连带问题:如果file_info表里两条记录md5相同、size不同,查询时要考虑取最早那一条,所以查询条件要带上orderByAsc(FileInfo::getId)或者直接用limit 1。
5.3 现象:高并发上传时MySQL报Duplicate entry,并且用户文件页面出现两条重复记录
并发上传同一个文件,两个请求都查不到file_info,于是同时insert,在唯一索引上有一个会挂掉。如果你在service里吞掉了这个异常只返回“上传成功”,用户文件目录就会出现两条相同的引用;如果你没建唯一索引,file_info表会积累两条一模一样的物理记录,秒传就失去了意义。解决方式是在merge接口的insert处捕获DuplicateKeyException,捕获后重新selectOne查询file_info,再插入user_file。很多人第一时间想用synchronized锁service方法,这在单机部署有效,但既然用了Redis就该用Redis分布式锁,锁粒度是md5字符串,而不是整个上传。注意锁的过期时间要大于合并时间,否则过期瞬间另一个线程也会进来,唯一索引仍然是最终防线。
5.4 现象:分片上传接口报“Could not parse multipart servlet request”或transferTo目标目录不存在
这个坑在Windows上尤其常见,开发机的数据目录写的是/data/netdisk/tmp,启动时没创建目录,前端传第一个分片时transferTo就炸了。原因是Spring Boot不会替用户自动创建业务目录。解决方式是在启动类里用ApplicationRunner初始化,或者在上传分片的代码里先Files.createDirectories。还有一个容易被忽略的细节:如果uploadId来自外部输入,一定要先做格式校验,判断是否包含..或/,否则合并时的Integer.parseInt会抛异常,严重时还会产生路径穿越漏洞。我一般会在ChunkUploadDTO里给uploadId加一个正则校验,只允许字母数字中划线,不符合直接拒掉,从入口挡住畸形请求。
5.5 现象:文件名含中文或特殊字符时,下载响应头乱码、分享链接打不开
上传是成功的,但下载时浏览器看到的文件名变成一串百分号,或者分享链接在微信里被截断。原因是controller里设置Content-Disposition时,直接拼接原始文件名,而HTTP响应头不支持非ASCII字符。解决方式是用URLEncoder.encode把文件名转成UTF-8的百分号编码,再拼到响应头里;更稳的方案是下载接口只返回file_info的id,文件名由前端在收到content-disposition后自行解析。分享链接打不开的另一层原因是分享码里带了中文或空格,生成分享码时用大小写字母加数字的62进制随机串,不要包含URL特殊字符。这里还有个体验细节:文件名里的双引号和反斜杠可能在拼接header时直接截断响应,稳妥做法是只保留文件名中的字母数字和点,其余字符统一编码。
6. 联调上线前值得做的三件事:完整性校验、上传限流与分享链接的安全策略
这三件事不属于“能不能跑”的范畴,但都决定“能不能给面试官演示、能不能挂到服务器上给人用”。第一件事是给合并接口加一个总开关:只有当前上传用户完成了check、拿到uploadId、再携带全部参数请求merge,才允许合并。这个开关直接用Redis记录uploadId对应的用户来实现,防止有人绕过前端构造请求,直接往临时目录塞part文件。第二件事是上传限流,用Redis做一个最简单的令牌桶:每个用户在固定窗口内最多上传N个分片。这个N根据机器性能和分片大小来调,比如2MB分片、单机4核8G,单用户每秒限5个分片比较合适。限流不需要精确,但能防止恶意脚本短时间内把磁盘打满。第三件事是分享链接要带提取码和过期时间,share_code不要用自增id,提取码用随机数生成四位,每次验证提取码时加锁,连续失败5次就锁定该分享,避免被遍历爆破。
我最初做这个项目时,觉得能上传能下载就完了,结果放到测试服务器第二天,发现磁盘被一个循环脚本上传了三百多GB的垃圾分片。当时的教训是:文件上传这类接口,对“谁在传、传多大、总共传了多少”完全没有感知,是一定会翻车的。后来补上“上传前登记、上传中按用户限流、合并后校验MD5”这三道关卡,才敢真正对外演示。修复完那一刻最深的体会是,仿百度网盘这类源码工程,真正的价值不在UI仿得多像,而在把这些看不见的边界条件处理干净。如果你也在做这个方向,先把本章这三件事做掉,再回头看看那些花哨的前端页面,会顺眼很多。希望帮到你。
本文还有配套的精品资源,点击获取