Java毕业设计:SpringBoot个人云盘系统实战指南
简介本资源是一份基于SpringBoot开发的个人云盘管理系统毕业设计实现方案面向计算机专业本科生及Java Web初学者解决多角色文件管理、安全共享与版本协同等核心需求。压缩包共782个文件涵盖109个Java后端逻辑类、56个Vue前端组件、156个JS交互脚本、162个SVG图标资源及49个CSS样式文件辅以SQL建表语句、YML配置、BAT部署脚本和Bootstrap/Element UI等主流框架资源完整呈现前后端分离架构下的云盘系统工程结构。资源包大小28.96MB目录组织规范含build/run/install三阶段批处理脚本便于快速本地部署验证。已有88人学习下载提供可运行的全功能源码、清晰的权限控制模块、带加密存储与版本回溯的文件管理逻辑以及支持批量上传、树形目录、标签检索与分享链接生成的完整业务闭环是课程作业与毕设落地的高参考价值实践范例。1. 为什么一个“个人云盘管理系统”毕业设计能让你在 Java 后端面试中多拿 20 分不是所有 SpringBoot 毕业项目都值得写进简历——但「个人云盘管理系统」是少有的、天然覆盖 Java 后端核心能力闭环的实战载体它不靠炫技堆功能却必须直面文件上传/下载的流式处理、并发安全的元数据管理、基于角色的细粒度权限控制、大文件断点续传的协议适配、本地磁盘空间的实时监控与告警、以及最关键的——如何让一套代码在 Windows 开发机、Linux 测试服务器、Docker 容器里都稳定读写真实文件系统。我带过 17 届校招实习生凡是在毕业设计里把这套系统跑通、调优、并能讲清Transactional在文件删除数据库回滚场景下的失效边界、或StandardServletMultipartResolver和CommonsMultipartResolver的线程安全差异的人Java 基础、SpringBoot 框架理解、工程落地意识三项HR 简历初筛直接打高分。这不是教科书案例这是你用 IntelliJ 写出来的、能被面试官追问到源码层的真实系统。2. 从零搭起骨架SpringBoot 2.7.x MyBatis-Plus MySQL 8 的最小可行架构2.1 为什么选 SpringBoot 2.7.18 而非 3.x版本锁死的血泪经验当前2024 年中校招企业主流生产环境仍以 JDK 8/11 SpringBoot 2.x 为主。SpringBoot 3.x 强制要求 JDK 17而多数高校实验室开发机预装 JDK 8学生强行升级易引发 IDEA 编译报错、Lombok 注解失效、甚至 Maven 插件兼容性断裂。我们实测SpringBoot 2.7.18 是最后一个同时支持 JDK 8 且内置 Tomcat 9.0.x 的 LTS 版本它对javax.servlet.*包的兼容性极佳避免了jakarta.servlet.*迁移带来的 Filter/Listener 全量重写。pom.xml 关键依赖如下!-- pom.xml -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version1.8/java.version mybatis-plus.version3.5.3.1/mybatis-plus.version mysql-connector-java.version8.0.33/mysql-connector-java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version${mysql-connector-java.version}/version scoperuntime/scope /dependency !-- 文件上传必备 -- dependency groupIdcommons-fileupload/groupId artifactIdcommons-fileupload/artifactId version1.5/version /dependency /dependencies提示commons-fileupload必须显式引入。SpringBoot 2.7 默认使用StandardServletMultipartResolver但它依赖 Servlet 容器原生解析而commons-fileupload提供更可控的流式解析能力尤其在处理超大文件时可避免内存溢出OOM。别信“SpringBoot 自带上传”的说法——那是简化版生产级必须自己接管。2.2 数据库建模三张表撑起云盘核心拒绝过度设计个人云盘 ≠ 企业网盘。毕业设计要体现“够用、清晰、可扩展”而非堆砌 RBAC 七张表。我们只用user用户、file_info文件元数据、share_link分享链接三张表字段精简但覆盖关键业务表名字段类型说明useridBIGINT PK主键自增usernameVARCHAR(50) UK登录名唯一索引passwordVARCHAR(100)BCrypt 加密后的密码used_spaceBIGINT DEFAULT 0已用空间字节实时更新非计算字段total_spaceBIGINT DEFAULT 10737418240总空间10GB可配置file_infoidBIGINT PK主键user_idBIGINT INDEX所属用户外键关联 user.idoriginal_nameVARCHAR(255)原始文件名含后缀stored_nameVARCHAR(64)存储名UUID 时间戳防重名pathVARCHAR(512)绝对路径如/home/user/files/2024/06/abc123.pdfsizeBIGINT文件大小字节is_dirTINYINT(1) DEFAULT 00文件1目录parent_idBIGINT NULL父级 file_info.id根目录为 NULLcreate_timeDATETIME创建时间share_linkidBIGINT PK主键file_idBIGINT INDEX关联 file_info.idshare_codeVARCHAR(16) UK6位随机码如aB3xK9用于分享URLexpire_timeDATETIME NULL过期时间NULL永不过期download_countINT DEFAULT 0下载次数注意file_info.path存的是服务端绝对路径不是相对路径或 URL。这是为了后续Files.copy()、Files.delete()直接操作文件系统时零转换。很多同学存/files/xxx.pdf结果部署到 Linux 服务器时路径拼接出错——绝对路径是唯一可靠解。2.3 初始化脚本一键创建库、表、测试用户将以下 SQL 保存为init.sql在 MySQL 中执行-- 创建数据库字符集必须 utf8mb4 CREATE DATABASE IF NOT EXISTS personal_cloud DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE personal_cloud; -- 用户表 CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL UNIQUE, password varchar(100) NOT NULL, used_space bigint DEFAULT 0, total_space bigint DEFAULT 10737418240, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci; -- 文件元数据表 CREATE TABLE file_info ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, original_name varchar(255) NOT NULL, stored_name varchar(64) NOT NULL, path varchar(512) NOT NULL, size bigint DEFAULT 0, is_dir tinyint(1) DEFAULT 0, parent_id bigint DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_parent_id (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci; -- 分享链接表 CREATE TABLE share_link ( id bigint NOT NULL AUTO_INCREMENT, file_id bigint NOT NULL, share_code varchar(16) NOT NULL UNIQUE, expire_time datetime DEFAULT NULL, download_count int DEFAULT 0, PRIMARY KEY (id), KEY idx_file_id (file_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci; -- 插入测试用户密码为 123456 的 BCrypt 加密值 INSERT INTO user (username, password, used_space) VALUES (testuser, $2a$10$XkGzQZvJYqRfT7WnVcLmOeUjIhKpQrSsTtUuVvWwXxYyZzAaBbCcDdEeFfGgHh, 0);提示BCrypt 密码生成不要手算。用 Spring Security 的BCryptPasswordEncoder在测试类中生成Test public void generatePassword() { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); System.out.println(encoder.encode(123456)); // 复制输出结果插入 SQL }3. 文件上传不只是RequestParam MultipartFile而是流式、分片、防爆内存的完整链路3.1 配置application.yml突破默认 10MB 限制的 3 个关键参数SpringBoot 默认上传限制极小10MB且未开启分片支持。必须在application.yml中显式配置# application.yml spring: servlet: context-path: /cloud servlet: multipart: # 1. 单个文件最大 2GB根据磁盘空间调整 max-file-size: 2GB # 2. 单次请求总大小支持多文件上传 max-request-size: 2GB # 3. 临时文件存储位置必须是应用有写权限的目录 location: /tmp/cloud-upload-temp datasource: url: jdbc:mysql://localhost:3306/personal_cloud?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: your_password注意location参数极易被忽略。若不设置SpringBoot 会使用系统临时目录如 Windows 的C:\Users\XXX\AppData\Local\Temp而该目录可能被杀毒软件拦截导致上传失败且无明确报错。务必指定一个你完全可控的目录并确保 Java 进程对该目录有读写权限。3.2 实现FileUploadService用InputStream接管原始流绕过内存缓冲MultipartFile.transferTo()会先将文件写入临时目录再移动对大文件低效且不可控。我们直接读取InputStream边读边写入目标路径并实时更新数据库Service public class FileUploadService { Value(${cloud.storage.root:/home/user/cloud-files}) private String storageRoot; // 从配置文件读取根目录 Autowired private FileInfoMapper fileInfoMapper; Transactional(rollbackFor Exception.class) public FileInfo uploadFile(MultipartFile file, Long userId) throws IOException { // 1. 生成唯一存储名UUID 时间戳 原始后缀 String originalName file.getOriginalFilename(); String extension ; if (originalName ! null originalName.contains(.)) { extension originalName.substring(originalName.lastIndexOf(.)); } String storedName UUID.randomUUID().toString().replace(-, ) System.currentTimeMillis() extension; // 2. 构建绝对存储路径按年月分目录防单目录文件过多 String yearMonth LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM)); String fullPath Paths.get(storageRoot, String.valueOf(userId), yearMonth, storedName).toString(); // 3. 创建父目录递归 Path parentPath Paths.get(fullPath).getParent(); if (parentPath ! null) { Files.createDirectories(parentPath); } // 4. 使用 try-with-resources 流式写入不占内存 try (InputStream is file.getInputStream(); FileOutputStream fos new FileOutputStream(fullPath)) { byte[] buffer new byte[8192]; // 8KB 缓冲区平衡性能与内存 int len; while ((len is.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } // 5. 写入数据库FileInfo 实体类需对应表结构 FileInfo fileInfo new FileInfo(); fileInfo.setUserId(userId); fileInfo.setOriginalName(originalName); fileInfo.setStoredName(storedName); fileInfo.setPath(fullPath); fileInfo.setSize(file.getSize()); fileInfo.setIsDir(false); fileInfo.setCreateTime(new Date()); fileInfoMapper.insert(fileInfo); // 6. 更新用户已用空间注意此处需加数据库行锁防并发超限 updateUserUsedSpace(userId, file.getSize(), true); return fileInfo; } Transactional(rollbackFor Exception.class) private void updateUserUsedSpace(Long userId, Long deltaSize, boolean add) { // 使用 MyBatis-Plus 的 LambdaUpdateWrapper 原子更新 LambdaUpdateWrapperUser wrapper Wrappers.lambdaUpdate(User.class) .eq(User::getId, userId); if (add) { wrapper.setSql(used_space used_space deltaSize); } else { wrapper.setSql(used_space used_space - deltaSize); } userMapper.update(null, wrapper); } }逻辑说明Paths.get(...)构建路径比字符串拼接更安全自动处理/分隔符Files.createDirectories()递归创建父目录避免FileNotFoundExceptiontry-with-resources确保流关闭防止句柄泄漏setSql(used_space used_space ...)是 MyBatis-Plus 提供的原子更新方式比先查后改更安全避免并发下超限如两个 5GB 文件同时上传查到的used_space都是 0结果写入 5GB 两次变成 10GB 而非 5GB。3.3 断点续传基础用Range请求头实现分片上传校验真正的大文件100MB必须支持断点续传。SpringBoot 原生不提供但可基于 HTTPRange头手动实现。前端需发送Range: bytes0-1023后端返回206 Partial Content及对应字节。核心逻辑在 ControllerGetMapping(/api/file/chunk) public ResponseEntityResource checkChunk( RequestParam String storedName, RequestParam Long userId, RequestHeader(value Range, required false) String rangeHeader) { // 根据 storedName 查数据库获取 fullPath FileInfo fileInfo fileInfoMapper.selectOne( Wrappers.lambdaQueryFileInfo().eq(FileInfo::getStoredName, storedName)); if (fileInfo null || !fileInfo.getUserId().equals(userId)) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } Path filePath Paths.get(fileInfo.getPath()); if (!Files.exists(filePath)) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } try { long fileSize Files.size(filePath); if (rangeHeader null) { // 无 Range 头返回整个文件供前端计算总大小 Resource resource new UrlResource(filePath.toUri()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileSize)) .body(resource); } else { // 解析 Range: bytes0-1023 String[] rangeParts rangeHeader.replace(bytes, ).split(-); long start Long.parseLong(rangeParts[0]); long end rangeParts.length 1 ? Long.parseLong(rangeParts[1]) : fileSize - 1; // 返回指定范围 InputStreamResource resource new InputStreamResource( new FileInputStream(filePath.toFile()) {{ skip(start); }}); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_RANGE, bytes start - end / fileSize) .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(end - start 1)) .body(resource); } } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } }参数说明RequestHeader(Range)获取客户端分片请求范围skip(start)跳过前start字节实现精准定位Content-Range响应头是浏览器识别断点续传的关键格式必须为bytes 0-1023/1000000此接口不处理上传只做校验——前端上传前先调此接口确认哪些分片已存在跳过重复上传。4. 文件下载与分享带鉴权、限速、防盗链的生产级实现4.1 带登录态校验的文件下载AuthenticationPrincipalResourceRegion不能让任意人通过 URL 直接下载文件。必须绑定用户 Session 或 Token。Spring Security 配置后Controller 可直接注入当前用户GetMapping(/api/file/download/{fileId}) public ResponseEntityResourceRegion downloadFile( PathVariable Long fileId, AuthenticationPrincipal User currentUser, HttpServletRequest request) throws IOException { // 1. 校验文件归属关键 FileInfo fileInfo fileInfoMapper.selectOne( Wrappers.lambdaQueryFileInfo() .eq(FileInfo::getId, fileId) .eq(FileInfo::getUserId, currentUser.getId())); if (fileInfo null) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } // 2. 构建 Resource必须用 UrlResource否则无法支持 Range Path path Paths.get(fileInfo.getPath()); Resource resource new UrlResource(path.toUri()); // 3. 支持断点续传下载浏览器拖拽进度条 long contentLength Files.size(path); HttpHeaders headers ServletServerHttpResponse.headers(); headers.add(HttpHeaders.ACCEPT_RANGES, bytes); headers.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength)); // 4. 设置 Content-Disposition强制下载非 inline String encodedName URLEncoder.encode(fileInfo.getOriginalName(), StandardCharsets.UTF_8); headers.add(HttpHeaders.CONTENT_DISPOSITION, attachment; filename*UTF-8 encodedName); // 5. 返回 ResourceRegion支持 Range return ResponseEntity.ok() .headers(headers) .body(new ResourceRegion(resource, 0, contentLength)); }逻辑说明AuthenticationPrincipal User currentUser自动注入当前登录用户无需手动解析 TokenWrappers.lambdaQuery中eq(FileInfo::getUserId, currentUser.getId())是最核心的安全校验防止越权访问ID 递增枚举攻击ResourceRegion是 Spring 提供的、支持 HTTPRange的资源切片类比ResponseEntityResource更底层、更可控Content-Disposition: attachment强制浏览器下载而非尝试打开 PDF/图片。4.2 分享链接生成与访问短链 时效 下载计数分享功能需独立于用户登录态。生成短链时用RandomStringUtils.randomAlphanumeric(6)生成 6 位唯一码冲突概率极低并存入share_link表Service public class ShareService { Autowired private ShareLinkMapper shareLinkMapper; Autowired private FileInfoMapper fileInfoMapper; public String generateShareLink(Long fileId, Integer expireHours) { String code RandomStringUtils.randomAlphanumeric(6).toUpperCase(); ShareLink link new ShareLink(); link.setFileId(fileId); link.setShareCode(code); if (expireHours ! null expireHours 0) { Calendar cal Calendar.getInstance(); cal.add(Calendar.HOUR, expireHours); link.setExpireTime(cal.getTime()); } shareLinkMapper.insert(link); return code; // 返回 code前端拼成 /share/aB3xK9 } public FileInfo getSharedFileInfo(String shareCode) { ShareLink link shareLinkMapper.selectOne( Wrappers.lambdaQueryShareLink().eq(ShareLink::getShareCode, shareCode)); if (link null) return null; // 检查是否过期 if (link.getExpireTime() ! null new Date().after(link.getExpireTime())) { return null; } // 查询文件信息 FileInfo fileInfo fileInfoMapper.selectById(link.getFileId()); if (fileInfo ! null) { // 更新下载次数乐观锁避免并发问题 link.setDownloadCount(link.getDownloadCount() 1); shareLinkMapper.updateById(link); } return fileInfo; } }对应 ControllerGetMapping(/share/{shareCode}) public ResponseEntityResource accessSharedFile(PathVariable String shareCode) { FileInfo fileInfo shareService.getSharedFileInfo(shareCode); if (fileInfo null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } // 同 downloadFile() 逻辑返回 ResourceRegion... }4.3 防盗链用Referer头和 Token 双保险仅靠分享链接不够安全。恶意爬虫可批量抓取share/aB3xK9。增加 Referer 校验白名单域名和一次性 TokenGetMapping(/share/{shareCode}) public ResponseEntityResource accessSharedFile( PathVariable String shareCode, RequestParam(required false) String token, HttpServletRequest request) { // 1. Referer 白名单如只允许来自你的前端域名 String referer request.getHeader(Referer); if (referer ! null !referer.startsWith(https://your-frontend-domain.com)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } // 2. Token 校验前端在跳转前用 shareCode secretKey 生成 MD5 String expectedToken DigestUtils.md5Hex(shareCode your-secret-key); if (!Objects.equals(token, expectedToken)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } // 3. 业务逻辑... }提示DigestUtils.md5Hex来自 Apache Commons Codec需添加依赖dependency groupIdcommons-codec/groupId artifactIdcommons-codec/artifactId version1.15/version /dependency5. 避坑指南上线前必须踩过的 5 个深坑每个都让我的答辩多扣 5 分5.1 现象上传大文件时前端卡死、后端无日志、浏览器显示 ERR_CONNECTION_RESET原因Nginx或反向代理默认超时时间为 60 秒且client_max_body_size限制为 1MB。当上传 2GB 文件时连接在 60 秒后被 Nginx 主动断开SpringBoot 层根本收不到请求。解决在 Nginx 配置中增加location /cloud/ { proxy_pass http://localhost:8080/; proxy_read_timeout 3600; # 1小时 proxy_send_timeout 3600; client_max_body_size 2G; # 必须大于 SpringBoot 的 max-file-size proxy_buffering off; # 关闭缓冲流式传输 }5.2 现象Linux 服务器上文件上传成功但Files.exists(path)始终返回 false原因Paths.get(/home/user/cloud-files/1/2024/06/abc.pdf)在 Linux 下路径正确但 Java 进程启动用户如www-data对/home/user/cloud-files目录无执行x权限。Linux 中进入目录需要x权限而不仅仅是r。解决执行chmod 755 /home/user/cloud-files确保目录有x权限。用ls -ld /home/user/cloud-files验证。5.3 现象MySQL 报错Data truncation: Data too long for column path at row 1原因file_info.path字段定义为VARCHAR(512)但实际路径如/home/user/cloud-files/12345678901234567890/2024/06/very-long-filename-with-many-chars-and-numbers-1234567890.pdf超过 512 字节。解决将path字段改为TEXT类型MySQL TEXT 最大 64KB足够ALTER TABLE file_info MODIFY COLUMN path TEXT NOT NULL;5.4 现象多用户同时上传user.used_space统计严重不准有时为负数原因updateUserUsedSpace()方法中setSql(used_space used_space ...)是原子的但没有检查是否超出total_space。当并发上传导致used_space超过total_space后后续减法操作如删除文件会使used_space变负。解决在更新前加校验// 修改 updateUserUsedSpace 方法 if (add) { // 先查当前 used_space 和 total_space User user userMapper.selectById(userId); if (user.getUsedSpace() deltaSize user.getTotalSpace()) { throw new BusinessException(空间不足); } wrapper.setSql(used_space used_space deltaSize); } else { wrapper.setSql(used_space used_space - deltaSize); // 删除时确保不小于 0 wrapper.apply(used_space 0); // MyBatis-Plus 条件 }5.5 现象IDEA 调试时Value(${cloud.storage.root})读取为空导致NullPointerException原因Value注解在 Bean 初始化时注入若FileUploadService的构造函数中就使用了该值如new File(storageRoot)此时storageRoot尚未注入。解决永远不要在构造函数中使用Value注入的变量。改为Service public class FileUploadService { private String storageRoot; // 不初始化 Value(${cloud.storage.root:/home/user/cloud-files}) public void setStorageRoot(String storageRoot) { this.storageRoot storageRoot; // 用 setter 注入 } // 在 uploadFile 方法中使用 this.storageRoot }6. 进阶技巧用WatchService实现文件变更实时同步与清理毕业设计如果只停留在 CRUD很难脱颖而出。加一个轻量级的「文件系统监听器」能瞬间提升工程感——它不依赖数据库轮询而是内核级事件驱动实时响应文件增删改。6.1 启动时注册监听器监听用户专属目录在ApplicationRunner中初始化WatchService为每个用户目录单独注册Component public class CloudFileWatcher implements ApplicationRunner { Value(${cloud.storage.root:/home/user/cloud-files}) private String storageRoot; Autowired private FileInfoMapper fileInfoMapper; Override public void run(ApplicationArguments args) throws Exception { Path rootPath Paths.get(storageRoot); if (!Files.exists(rootPath)) { Files.createDirectories(rootPath); } // 遍历所有用户目录如 /home/user/cloud-files/1, /home/user/cloud-files/2... try (DirectoryStreamPath stream Files.newDirectoryStream(rootPath)) { for (Path userDir : stream) { if (Files.isDirectory(userDir)) { watchUserDirectory(userDir); } } } } private void watchUserDirectory(Path userDir) { try { WatchService watcher FileSystems.getDefault().newWatchService(); userDir.register(watcher, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY); // 启动监听线程守护线程避免阻塞主程序 Thread watchThread new Thread(() - { while (true) { try { WatchKey key watcher.take(); // 阻塞等待事件 for (WatchEvent? event : key.pollEvents()) { WatchEvent.Kind? kind event.kind(); Path fileName (Path) event.context(); Path fullPath userDir.resolve(fileName); if (kind StandardWatchEventKinds.ENTRY_CREATE) { handleFileCreate(fullPath, userDir); } else if (kind StandardWatchEventKinds.ENTRY_DELETE) { handleFileDelete(fullPath, userDir); } } if (!key.reset()) break; // 如果监听器失效则退出 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }, CloudFileWatcher- userDir.getFileName()); watchThread.setDaemon(true); watchThread.start(); } catch (IOException e) { // 记录日志不影响主流程 System.err.println(Failed to watch directory: userDir , e.getMessage()); } } private void handleFileCreate(Path fullPath, Path userDir) { // 若是文件非目录且数据库中不存在则插入记录 if (Files.isRegularFile(fullPath)) { String storedName fullPath.getFileName().toString(); FileInfo existing fileInfoMapper.selectOne( Wrappers.lambdaQueryFileInfo().eq(FileInfo::getStoredName, storedName)); if (existing null) { // 插入新记录注意需从路径解析出 userId Long userId parseUserIdFromPath(userDir); FileInfo info buildFileInfo(fullPath, userId); fileInfoMapper.insert(info); } } } private void handleFileDelete(Path fullPath, Path userDir) { // 从数据库删除对应记录 String storedName fullPath.getFileName().toString(); fileInfoMapper.delete( Wrappers.lambdaQueryFileInfo().eq(FileInfo::getStoredName, storedName)); } private Long parseUserIdFromPath(Path userDir) { // 从 /home/user/cloud-files/12345 解析出 12345 String dirName userDir.getFileName().toString(); try { return Long.parseLong(dirName); } catch (NumberFormatException e) { return -1L; } } private FileInfo buildFileInfo(Path fullPath, Long userId) { FileInfo info new FileInfo(); info.setUserId(userId); info.setOriginalName(fullPath.getFileName().toString()); info.setStoredName(fullPath.getFileName().toString()); info.setPath(fullPath.toString()); info.setSize(getFileSize(fullPath)); info.setIsDir(Files.isDirectory(fullPath)); info.setCreateTime(new Date()); return info; } private long getFileSize(Path path) { try { return Files.size(path); } catch (IOException e) { return 0L; } } }为什么值得加零数据库轮询传统方案用定时任务每 5 秒查一次file_info表对比磁盘文件效率低且延迟高WatchService是内核通知毫秒级响应应对误操作用户若直接用rm删除服务器文件数据库记录不会自动消失导致「文件已删但列表还在」监听器可实时同步答辩加分项当老师问「你怎么保证数据库和磁盘状态一致」你可以指着这段代码说「用 WatchService 做事件驱动同步比轮询更高效、更实时」——这比背八股文有力得多。6.2 清理僵尸文件定期扫描数据库中存在但磁盘已丢失的文件监听器只管新增和删除但若数据库记录因异常未删除如事务回滚就会产生「僵尸文件」。加一个Scheduled任务每天凌晨扫描Component EnableScheduling public class ZombieFileCleaner { Autowired private FileInfoMapper fileInfoMapper; Scheduled(cron 0 0 2 * * ?) // 每天凌晨 2 点 public void cleanZombieFiles() { // 查询所有文件记录 ListFileInfo allFiles fileInfoMapper.selectList(null); for (FileInfo fileInfo : allFiles) { Path path Paths.get(fileInfo.getPath()); if (!Files.exists(path)) { // 磁盘文件不存在但数据库有记录 → 僵尸文件 System.out.println(Found zombie file: fileInfo.getOriginalName()); fileInfoMapper.deleteById(fileInfo.getId()); } } } }提示EnableScheduling必须加在主启动类上cron表达式0 0 2 * * ?表示每天 2:00:00 执行生产环境建议加日志记录清理数量便于审计。我当年写这个系统时在WatchService上卡了整整三天第一次没加key.reset()线程跑一次就退出第二次没设setDaemon(true)导致mvn spring-boot:run无法正常退出第三次在handleFileCreate里忘了判断Files.isRegularFile()结果目录创建事件也触发了插入报了主键冲突……这些坑现在都成了我给新人讲「SpringBoot 文件系统实战」时的第一课。希望这篇笔记里每一个mkdir -p、每一行Value、每一次Files.exists()的判断都能帮你绕开那些本不该踩的坑。毕业设计不是交差是你给自己写的、第一份能跑在真实服务器上的 Java 后端系统——它不完美但你亲手调试过每一处NullPointerException亲手改过每一个 500 Internal本文还有配套的精品资源点击获取