☰
JavaWeb仿百度网盘实战:文件上传下载、秒传与去重全解析
2026/10/26 16:30:39 网站建设 项目流程

简介:一款仿照百度网盘核心功能的小型云盘系统,基于JavaWeb技术实现,适合计算机专业学生、毕业设计开发者和刚接触服务端编程的入门人员参考学习。系统前端使用Bootstrap框架搭建页面,后台没有采用Spring、Struts等重量级框架,而是用最基础的Servlet处理请求,涉及登录、文件上传、下载、列表展示等典型场景,能够帮助读者理解JavaWeb中请求响应机制、文件流操作以及简单的数据库交互。资源共204个文件,其中50个Java源码文件负责业务实现,65个class文件为编译产物,24个js与5个css配合4个jsp完成前端交互,15个jar包提供运行依赖,1个sql脚本用于初始化数据库,压缩包整体仅4.59MB,便于下载后导入IDE直接使用。目前已有387人参与学习,拿到手可直接部署运行,也能通过源码快速还原项目结构,作为课程设计、毕业设计或继续扩展云盘功能的起点。

1. 先问值不值:为什么“仿百度网盘”值得用 JavaWeb 完整做一遍

在 JavaWeb 练习项目里,“仿百度网盘”是一个性价比极高的选择。它不要求高并发和微服务,却能把用户登录、文件上传下载、目录树、分享链接这些业务动作完整串起来,一台普通 PC、一个 Tomcat、一个 MySQL 就能撑起一个课程设计或小组内部共享的小型云盘。这个标题真正带给你的不是“再造一个百度网盘”,而是让你理解文件怎么落盘、元数据怎么与物理文件对应、删除时怎么才不把数据搞丢——这三件事恰好是大部分网盘类项目翻车的根源。下面从模块拆分讲到数据表设计,再到可复现的上传下载接口和踩坑点,按顺序展开。

2. 系统拆分与数据库建模:小型云盘到底要几张表和几个模块

动手写代码前,先把三个问题想清楚:上传的文件物理上放在磁盘哪个目录;数据库里用什么字段定位它;用户删除文件时,是删数据库记录还是删磁盘文件。这三个问题的答案基本决定了项目的骨架。我见过不少直接先写 Servlet 和 JSP、边写边补表的做法,最后表结构被迫跟着业务反复删改,成本极高。更稳的顺序是先把表结构定下来,再倒推业务模块。

2.1 四个核心模块:上传、存储、索引、分享

这种规模的项目别上微服务,也用不上分布式文件系统,一个单体 JavaWeb 应用足够。用 Spring MVC 或 Spring Boot 起一个 Tomcat 应用,MyBatis 连 MySQL,剩下的就是模块边界划分。我会把系统拆成四块:

  • 用户模块:注册、登录、注销,把“谁在操作文件”这个身份确认下来。
  • 文件索引模块:负责文件元数据的增删改查,包括目录树构建。它只操作数据库,不碰磁盘文件。
  • 存储模块:处理文件实体的落盘、读取和物理删除。磁盘路径全部通过配置项注入,不散落在业务代码里。
  • 分享模块:为文件或目录生成分享记录,控制提取码、过期时间和访问次数。

这四个模块靠两组约定连接:存储路径和 MD5 值,指向磁盘上的物理文件;user_id 与 parent_id 决定用户在界面上看到的目录树。只要这两组字段不混乱,模块之间替换起来也不难。

2.2 数据库设计:用户表、文件表、分享表的字段与约束(含 SQL)

这是项目的所有地基。我常用三张核心表:user、file_info、share。目录不单独建表,用 file_info 里的 is_dir 和 parent_id 表达,这样查询子目录只需一条按父节点过滤的 SQL。

-- 用户表 CREATE TABLE `user` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `username` VARCHAR(64) NOT NULL COMMENT '登录名', `password` VARCHAR(128) NOT NULL COMMENT '加盐后的密码', `nickname` VARCHAR(64) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

逻辑说明:username 加唯一约束,登录时直接作为查询条件。password 存 128 位是给加盐留的余量,如果只存 32 位 MD5,后期想升级为加盐方案就得改表结构。字段说明上,nickname 可以空,create_time 由数据库默认值填充,代码里不用手动赋值。

-- 文件元数据表 CREATE TABLE `file_info` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT, `user_id` INT(11) NOT NULL COMMENT '所属用户', `file_name` VARCHAR(255) NOT NULL COMMENT '用户在网盘里看到的文件名', `file_md5` VARCHAR(32) DEFAULT NULL COMMENT '秒传去重用', `file_size` BIGINT(20) DEFAULT 0 COMMENT '字节数', `storage_path` VARCHAR(255) DEFAULT NULL COMMENT '物理绝对路径', `parent_id` BIGINT(20) DEFAULT 0 COMMENT '父目录id,0表示根目录', `is_dir` TINYINT(1) DEFAULT 0 COMMENT '1目录 0文件', `deleted` TINYINT(1) DEFAULT 0 COMMENT '逻辑删除标记', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_parent` (`user_id`, `parent_id`), KEY `idx_md5` (`file_md5`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件元数据表';

字段与索引的参数说明:id 用 BIGINT 而不是 INT,文件表是高频写入表,几年积累下来 INT 容易触顶。parent_id 默认 0 表示用户根目录,界面展开目录树时,按 user_id 加 parent_id 一次查出当前层。file_md5 单独建索引,因为秒传接口要高频按 MD5 查重,这个索引区分度接近 90%,性能足够。deleted 是逻辑删除标记,回收站功能全靠它,不能省。

-- 分享链接表 CREATE TABLE `share` ( `id` BIGINT(20) NOT NULL AUTO_INCREMENT, `user_id` INT(11) NOT NULL, `file_id` BIGINT(20) NOT NULL COMMENT '被分享的文件或目录id', `share_code` VARCHAR(32) NOT NULL COMMENT '短链接提取码', `expire_time` DATETIME DEFAULT NULL COMMENT '到期时间,NULL长期有效', `visit_count` INT(11) DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_share_code` (`share_code`), KEY `idx_file_id` (`file_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享记录表';

share_code 是分享功能的入口,必须唯一。到期判断放 Service 层,查询时同时比较 expire_time 和 visit_count,两条都满足才算有效分享。实际使用时,file_id 指向的可以是目录,也可以是一个文件,指向目录时分享出去的是一整棵树。

2.3 为什么文件元数据和物理文件必须分开存

这是整个方案最关键的设计决策。文件内容不能进数据库,BLOB 存大文件会同时占满连接内存和网络传输;更重要的是,雷同文件去重时,需要同一份磁盘文件对应多条元数据记录。只有把元数据和物理文件拆开,去重、秒传和重命名才有操作空间。

实际收益有三个方面:两个用户上传同一个 MD5 文件时,磁盘上只保留一份实体,第二条记录直接引用它;用户重命名文件时,只更新 file_info 的 file_name,磁盘路径不动;用户删除文件时,把 deleted 置为 1,回收站和恢复都只对元数据操作。这三个操作都不涉及大文件 IO,业务响应会非常快。

这样设计也带来一个新问题:物理文件到底什么时候真正删除。我的习惯是业务层只做逻辑删除,由定时任务统计引用计数,当一条 MD5 对应的所有元数据都被标记为删除后,才执行磁盘文件清理。这个策略放到第四章详细说,因为它直接影响数据安全和磁盘占用之间的平衡。

3. 上传下载主链路:Spring MVC + MyBatis 的核心代码与参数

选 Spring Boot 2.7 作为运行载体在目前最省事:内置 Tomcat,IDEA 打开即跑;需要部署到独立 Tomcat 时,改成 War 打包并把 spring-boot-starter-tomcat 设为 provided 即可。MyBatis 的 mapper 用 XML 风格,SQL 改动不用重新编译。下面三个小节覆盖最常见的主链路。

3.1 上传接口:multipart 解析、MD5 计算与磁盘落盘

这节用一个完整的 Controller 方法把上传链路走通。

@RestController @RequestMapping("/file") public class FileController { @Value("${custom.storage-root}") private String storageRoot; @Resource private FileInfoMapper fileInfoMapper; @PostMapping("/upload") public ApiResult upload(@RequestParam("file") MultipartFile file, @RequestParam("parentId") Long parentId, HttpSession session) throws IOException { // 1. 计算MD5,秒传和去重都靠它 String md5 = DigestUtils.md5Hex(file.getInputStream()); // 2. 按日期分目录,避免一个目录下文件数量过大 String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); File dir = new File(storageRoot, datePath); if (!dir.exists()) { dir.mkdirs(); } // 3. 落盘文件名带md5前缀,防止不同来源同名文件互相覆盖 String storeName = md5 + "-" + file.getOriginalFilename(); File destFile = new File(dir, storeName); if (!destFile.exists()) { file.transferTo(destFile); } // 4. 写元数据,一条文件记录对应一个用户在目录树里的节点 FileInfo info = new FileInfo(); info.setUserId((Long) session.getAttribute("userId")); info.setFileName(file.getOriginalFilename()); info.setFileMd5(md5); info.setFileSize(file.getSize()); info.setStoragePath(destFile.getAbsolutePath()); info.setParentId(parentId); info.setIsDir(false); fileInfoMapper.insert(info); return ApiResult.ok(info.getId()); } }

逻辑说明:第 2 到 3 步是落盘核心,存储路径必须用绝对路径,否则项目换目录或重启后找不到文件。md5 前置到物理文件名,是为了让秒传接口能直接复用“磁盘已有此文件”的事实。第 3 步的 if 判断很关键:两个用户上传相同内容时,磁盘上只保留一份实体,后面的请求直接跳过写盘。

参数说明:依赖 spring.servlet.multipart 配置,这个后面单独讲。MultipartFile 的 transferTo 方法会自动处理临时文件清理,不建议再调用 file.getBytes(),大文件会直接撑爆内存。parentId 和 session 里的 userId 一定不能省,否则文件会出现在“别人的网盘”里。

3.2 下载接口:流式响应与 Content-Disposition 的细节

下载和上传对应,这里最容易出问题的不是 IO,而是响应头。

@GetMapping("/download") public ResponseEntity<Resource> download(@RequestParam("fileId") Long fileId, HttpSession session) throws IOException { FileInfo info = fileInfoMapper.selectByIdAndUserId(fileId, (Long) session.getAttribute("userId")); if (info == null) { return ResponseEntity.notFound().build(); } File file = new File(info.getStoragePath()); if (!file.exists()) { return ResponseEntity.notFound().build(); } FileSystemResource resource = new FileSystemResource(file); String fileName = URLEncoder.encode(info.getFileName(), "UTF-8").replaceAll("\\+", "%20"); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName) .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(file.length()) .body(resource); }

为什么不用 byte[]:文件大时整个文件进内存,几十个并发下载就能把 JVM 堆打满。FileSystemResource 走流式响应,框架自动以分块方式把文件写回客户端,性能和安全都更稳。Content-Disposition 里 filename 只认 ASCII,中文名直接拼进去会乱码,所以用 URLEncoder 先转一轮;文件名里如果有空格,加号会被客户端当空格处理,因此要 replaceAll 成 %20。

如果想在浏览器里预览 PDF 或图片,把 attachment 改成 inline,同时把 contentType 设成对应的 MIME 类型即可。我会把附件类型做成一个参数,由前端根据接口需求传 down 或 preview,避免为预览单独再写一个接口。

3.3 连接 MySQL 与 IDEA 运行配置:别在第一步卡住

很多项目代码本身没问题,卡在环境配置上。这里给一份最小可跑的配置:

spring: datasource: url: jdbc:mysql://localhost:3306/cloud_disk?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 idle-timeout: 60000 max-lifetime: 1760000 connection-test-query: SELECT 1 servlet: multipart: max-file-size: 200MB max-request-size: 512MB file-size-threshold: 10MB

IDEA 里的运行步骤:用 Maven 导入项目,选择 JDK 8 或 11,等依赖下载完直接运行 Spring Boot 入口类的 main 方法。如果改成外部 Tomcat 部署 War 包,注意在 Deployment 标签里把 Application Context 设为 /,否则访问时带项目名容易 404。

连接 MySQL 的几个参数不用全记住,但 useSSL=false 能避免开发环境 SSL 握手警告;characterEncoding=utf8 保证中文文件名写入数据库不变乱码;serverTimezone=Asia/Shanghai 是必填项,MySQL 8 驱动没有时区会直接报错。Hikari 的配置具体作用在第五章第二节展开,这里先照着配。

4. 秒传、分片与文件去重:把“网盘体验”拉满的三个进阶点

到这一步,基本的上传下载已经能跑,但它更像一个文件服务器,距离“网盘体验”还差一截。百度网盘最让人舒服的三件事是秒传、断点续传和重复文件只占一份空间,这节把这三个点逐一落地。

4.1 秒传:先比 MD5,再决定要不要落盘

秒传的本质是内容寻址:后端拿文件的 MD5 去 file_info 表里查,如果已经存在相同 MD5 的物理文件,就直接把这条元数据复制一份给当前用户,跳过磁盘写入。用户看到的效果是瞬间完成上传。

@PostMapping("/upload/instant") public ApiResult instant(@RequestParam("md5") String md5, @RequestParam("fileName") String fileName, @RequestParam("fileSize") Long fileSize, @RequestParam("parentId") Long parentId, HttpSession session) { // 按md5查一条已有元数据,FOR UPDATE防止并发下物理文件被清理 FileInfo exist = fileInfoMapper.selectByMd5ForUpdate(md5); if (exist == null) { // 没有对应物理文件,告诉前端走普通上传 return ApiResult.ok().put("instant", false); } // 有物理文件,直接复制一条元数据记录,storagePath复用 FileInfo newInfo = new FileInfo(); newInfo.setUserId((Long) session.getAttribute("userId")); newInfo.setFileName(fileName); newInfo.setFileMd5(md5); newInfo.setFileSize(fileSize); newInfo.setStoragePath(exist.getStoragePath()); newInfo.setParentId(parentId); newInfo.setIsDir(false); fileInfoMapper.insert(newInfo); return ApiResult.ok().put("instant", true); }

逻辑说明:秒传接口只写数据库,不碰磁盘,所以速度极快。selectByMd5ForUpdate 里的 FOR UPDATE 是必要的,防止并发场景下两个秒传请求同时读到同一条引用,而其中一个请求随后触发清理任务把物理文件删掉。fileSize 需要注意,严谨的做法是用 exist.getFileSize() 回填,而不是完全信任前端传上来的值,否则元数据可能与实际文件大小不一致。

前端计算 MD5 的常见做法是:用 FileReader 把文件切片读成 ArrayBuffer,再用 spark-md5 这类库做增量摘要计算。注意这个动作不需要读完整文件时才算完,边读边算对用户体验最友好。

4.2 分片上传与合并:前端切块、后端拼接的参数与边界

超过 1GB 的文件用单请求上传会非常痛苦:网络抖动一次就全盘重来。分片上传的思路是把文件切成固定大小的块,分别上传,最后在后端按序号合并。

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB/片 function splitFile(file) { const chunks = []; for (let start = 0; start < file.size; start += CHUNK_SIZE) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push({ index: chunks.length, // 分片序号,合并时靠它排序 blob: file.slice(start, end) }); } return chunks; }

参数说明:分片大小选 5MB 是实用折中。512MB 文件切 103 片,每片失败后单独重传的成本很低;分片再小会导致 HTTP 请求数量暴增,后端合并时的文件句柄开销也会跟着上去。前端给所有分片带上同一个 uploadId(通常是 MD5 加时间戳),后端按 uploadId 把分片存进同一个临时目录。

@PostMapping("/upload/merge") public ApiResult merge(@RequestParam("uploadId") String uploadId, @RequestParam("fileName") String fileName, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunkCount") Integer chunkCount, @RequestParam("parentId") Long parentId) throws IOException { File tmpDir = new File(uploadTempRoot, uploadId); File target = new File(storageRoot + File.separator + fileMd5 + "-" + fileName); try (FileOutputStream fos = new FileOutputStream(target)) { for (int i = 0; i < chunkCount; i++) { File part = new File(tmpDir, i + ".part"); if (!part.exists()) { throw new IOException("缺失分片索引 " + i); } Files.copy(part.toPath(), fos); } } // 合并完成后清理临时分片目录 FileUtils.deleteDirectory(tmpDir); // 写元数据,代码与普通上传一致,此处省略 return ApiResult.ok(); }

关键参数:合并完成后一定要重新计算整个文件的 MD5,和 fileMd5 比对,不能直接用前端传来的值为准,防止分片传输过程中出现静默损坏。缺分片时直接抛异常,不要拼出一个残缺文件再告诉用户成功。合并接口要做幂等处理,同一 uploadId 的重复 merge 请求,第二次应当直接返回成功,避免并发重试时文件被重复拼接。

4.3 文件去重与假删除:磁盘占用与数据安全怎么平衡

去重可以做成两个层级:单用户去重是同一用户下相同 MD5 的文件共用一个物理副本,记录级去重实现简单;系统级去重更彻底,所有用户的相同 MD5 都指向同一个物理文件,但必须配合引用计数。

我通常用系统级去重加引用计数。每次新增 file_info 记录时,如果 MD5 已在数据库存在,就把该 MD5 对应的物理文件引用计数加一。这个计数字段可以放在一张 file_physical 表里,也可以在 file_info 里按 MD5 聚合统计,前者查询更高效。

删除操作我走“假删除”策略,这也是网盘回收站的实现基础。用户点删除时,只把 file_info 的 deleted 置为 1,同时把 parent_id 改成回收站节点的 id,这样目录树查询时自动过滤,回收站里还能看到完整记录。这个方案相当于给用户留了后悔药,避免误删直接丢文件。

这里有一个重要坑:不要在业务事务里直接调用 File.delete()。数据库回滚可以擦掉 INSERT,但磁盘上的文件删了就是删了,两者不在同一个事务边界内。如果先在事务里删了物理文件,后面 SQL 失败回滚,数据库里留了一条记录,用户再点下载就是 404。正确顺序是:先把待清理的 file_id 查出来,逐个执行“先删磁盘文件,再删数据库记录”,并把失败项写入补偿日志,由定时任务下次重试。

5. 避坑排查:JavaWeb 云盘最容易踩的五个坑

网盘类项目的问题往往不是出在业务逻辑上,而是出在配置、连接和文件生命周期管理上。这几条全是我实际做这类系统时踩过的,整理成“现象—原因—解决”方便你对照排查。

5.1 现象:上传超过 20MB 的文件直接 500,报 maximun permitted size

原因:Spring Boot 的 multipart 默认 max-file-size 是 1MB,max-request-size 是 10MB。在这个限制下,任何超过 1MB 的正常文件都会被拒绝,日志里看起来像业务代码写错,实际只是默认配置没改。

解决:在 application.yml 里把两个值同时调大。max-file-size 要覆盖你的产品规划最大单文件,max-request-size 因为要把表单字段一起算进去,通常乘 1.2 倍比较保险。file-size-threshold 设为 10MB 是为了让大文件尽早从内存移到临时文件,降低对 JVM 的占用。临时文件由框架用后即删,不用手工清理。

5.2 现象:项目跑了一夜,第二天上传下载偶发 “Connection is not available” 或 Communications link failure

原因:MySQL 的 wait_timeout 默认是 8 小时,晚上没有请求时,连接池里的空闲连接被服务端断开,但连接池并不知道。下一次请求拿到这条“死连接”,连续几次重试都失败,就会抛出偶发异常。白天流量不断时反而不容易出现,所以这个坑特别像黑匣子。

解决:给数据源显式配置 Hikari 的 max-lifetime,且必须小于 MySQL 的 wait_timeout。MySQL wait_timeout 是 28800 秒时,把 max-lifetime 设为 1760000 毫秒(约 29 分钟),让连接在服务端断开前就被连接池主动回收。idle-timeout 用于清掉长期空闲的连接,connection-test-query 换成 SELECT 1,取连接时自动做一次有效性验证。

5.3 现象:下载的文件名是中文,在 Chrome 里正常,换 Safari 或某些国产浏览器就乱码

原因:Content-Disposition 响应头里的 filename 只支持 ASCII 字符。直接把中文字符串拼进去,浏览器按 Latin-1 解码就变成了乱码。Chrome 会优先取 filename*,但部分浏览器只认 filename,所以两种格式必须同时给。

解决:把文件名先做 URL 编码,再同时输出 filename 和 filename* 两个字段。核心代码就是 3.2 节里的那一行,注意 URLEncoder.encode 出来的加号必须替换成 %20,否则会把空格吃掉。这个写法兼容新旧两类浏览器,遇到同类问题可以直接套用。

5.4 现象:删除文件后磁盘空间没有释放,或磁盘文件没了但列表里还能看到这条记录

原因:把“删除数据库记录”和“删除磁盘文件”当成同一件事处理。没有引用计数保护时,直接 File.delete() 会把其他用户还在引用的公共文件删掉;不删物理文件则让垃圾越堆越多。更麻烦的是数据库事务不能回滚磁盘操作,两边一旦不一致就再也对不上。

解决:上线后统一走假删除逻辑,删除动作只更新 file_info 的 deleted 和 parent_id,不允许直接删物理文件。物理清理交给定时任务,条件是所有引用该 MD5 的 file_info 记录都已经是 deleted=1,确认引用计数归零,才执行磁盘删除。任务执行前先记录待删清单,失败的下次任务继续重试,这比在业务代码里硬着头皮做跨系统一致性强太多。

5.5 现象:连续上传几个 200MB 文件后,服务器内存持续走高,最后 OOM

原因:最常见的是在 Servlet 代码里调用 file.getBytes(),或下载时用 Files.readAllBytes() 把整个文件读进内存。Spring 的 multipart 虽然已经把大文件落到临时目录,但 getBytes() 一旦调用,等于又把整个文件拉到 JVM 堆里,多个并发上传直接堆满。

解决:上传用 file.transferTo(destFile) 走流式落盘,下载用 FileSystemResource 作为 ResponseEntity 的 body,让框架以流的方式写回客户端。排查线上 OOM 时,重点搜代码里的 readAllBytes、getBytes、IOUtils.toByteArray 这类调用,把它们集中到一个 FileStorageService 里统一管理,后续换存储方案也方便。

6. 从“能跑”到“能用”:验证清单与上线前必做的三件事

能跑通上传下载,离“能交付”还有一段距离。我习惯在功能开发完后先按场景矩阵过一遍测试,把最容易漏的边界情况一次覆盖完:

场景预期结果
上传 1KB 文本文件列表可见,下载内容一致
上传 100MB 大文件上传成功,过程不报 500
文件名含中文、空格、# 字符列表显示正常,下载不乱码
同名文件再次上传显示两个文件条目,互不覆盖
上传已存在的同 MD5 文件秒传成功,磁盘不新增副本
创建目录并在子目录上传目录树能正常展开和跳转
删除带有引用的文件回收站可见,定时任务到期后清理
分享文件并设置过期时间过期后访问返回失效提示
并发上传 10 个文件文件记录无丢失,磁盘文件数一致

这个矩阵覆盖了从第三章到第四章的所有核心功能,每一项都通过,才算真正把主链路吃透。

上线前还有三件事必须做,全是我踩坑换来的经验。第一,存储根目录必须配置成外部绝对路径,绝不能放在项目编译输出目录下。我经历过一次 mvn clean 把存储目录整个清掉的事故,那之后所有存储路径都通过配置注入,与代码完全隔离。第二,数据库和存储目录都要有备份,数据库用 mysqldump 定时导出,存储目录用 rsync 同步到另一块磁盘,分享码和文件索引一旦丢失,磁盘上只剩散文件,无法再对应回用户。第三,留操作日志,文件上传、删除、分享这些动作写一张 log 表,排查问题时能知道谁在什么时候删了什么。

做这类项目到最后,最大的体会是:云盘系统难的不是上传下载那几行代码,而是文件生命周期的一致性管理。把数据库设计、删除策略和存储路径规则先在前面定死,后面写接口基本不会跑偏。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询