ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

仿百度网盘Java后端实战:秒传、分片、断点续传与文件生命周期设计

仿百度网盘Java后端实战:秒传、分片、断点续传与文件生命周期设计 简介这是一份基于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或11Spring 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 ResultT { private int code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.message ok; r.data data; return r; } public static T ResultT error(String message) { ResultT 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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对应2097152max-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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么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条件ListUserFile children userFileMapper.selectList( new LambdaQueryWrapperUserFile() .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 ResultUploadCheckVO check(RequestBody UploadCheckDTO dto) { LambdaQueryWrapperFileInfo 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); }这段逻辑里status0时前端拿到uploadId进入分片上传流程status1时页面直接跳“上传成功”。有一个隐藏参数建议调出来如果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 ResultString 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 ResultFileInfo 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(上传分片不存在); } ListPath chunks new ArrayList(); try (DirectoryStreamPath 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 SimpleFileVisitorPath() { 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里那个SetGetMapping(/upload/{uploadId}/chunks) public ResultSetInteger uploadedChunks(PathVariable String uploadId) { String key UPLOAD_CHUNK_KEY uploadId; SetObject members redisTemplate.opsForSet().members(key); SetInteger indices new HashSet(); if (members ! null) { for (Object member : members) { indices.add((Integer) member); } } return Result.ok(indices); }这段逻辑的前端配合是拿到Set后从chunkIndex0开始循环如果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仿得多像而在把这些看不见的边界条件处理干净。如果你也在做这个方向先把本章这三件事做掉再回头看看那些花哨的前端页面会顺眼很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表