
简介这是一份面向Java课程设计与网络编程综合实验的小型档案管理系统完整源码包系统采用C/S模式客户端与服务器基于Socket通信并通过多线程同时处理多个客户端请求用户与档案属性存放于MySQL关系数据库档案文件以文件形式保存于服务器目录覆盖登录验证、系统管理员/档案录入员/档案浏览员三类角色权限、档案信息增删查改、条件查询、文件上传下载以及个人信息维护等完整业务流程。包体共47个文件仅1.37MB包含14个Java源文件、15个class编译文件、2个SQL建表脚本、开发环境配置文件、readme说明以及面向对象与多线程综合实验指导书docx并按client/server、src/bin等目录划分便于定位与对照学习其中SQL脚本可直接建库Java源码与class文件便于运行比对。目前已有874人学习/下载适用于课程设计、期末实验或Socket多线程与数据库综合实践参考。借助源码与配套文档读者可深入理解Swing界面编程、Socket多线程通信、JDBC数据库访问、文件流传输等关键实现需要时也可作为二次开发原型直接扩展。1. 小型档案管理系统这门实验课到底在考什么如果你正在为「Java实验设计-实现一个小型档案管理系统」头疼大概率是因为你已经开始写了却发现它比想象中麻烦。一个档案管理系统表面上是「给档案做增删改查」但实际一动手就会撞上三件事档案编号怎么生成才不重、文件上传之后存到哪里、不同角色的人能看到哪些档案。这三件事随便哪一个没想清楚后面都要返工。这门实验真正想考的其实是你能不能把一个带有文件操作、权限控制、模糊检索的真实业务场景拆成可维护的 Java 代码。它适合两类人一类是正在做课程设计、需要从零搭一个能跑能演示系统的学生另一类是刚学完 Java Web、想用一个完整项目把 SSM 或 Spring Boot 串联起来的开发者。无论是哪种你都需要一条能直接照着做的路径而不是泛泛的「设计一个系统」。所以这篇文章我按自己做这类项目的顺序来写先讲技术选型和数据模型怎么定再给核心功能的最小实现方式最后把答辩或演示时最容易被问倒的边界问题提前拆掉。你可以边看边敲也可以先通读一遍再动手。2. 技术选型和数据模型先让表结构立得住2.1 Spring Boot MyBatis Thymeleaf 的组合为什么最省事「小型档案管理系统」的题眼是「小型」这意味着它不需要微服务、不需要消息队列、不需要 Redis 缓存。选型的原则是让实验重点落在业务逻辑上而不是花大半时间配环境。我一般推荐 Spring Boot 2.x MyBatis Thymeleaf MySQLIDE 用 IDEA构建工具用 Maven。这个组合的优势很直接Spring Boot 自带内嵌 Tomcat不用单独部署MyBatis 让 SQL 写在 mapper.xml 里答辩时你能清楚地讲出每一条查询Thymeleaf 作为服务端模板页面和数据都在一个工程里不用处理跨域。相比 SSM 手写配置的方式Spring Boot 能把配置压缩到application.yml一个文件里这对新手来说能少踩很多坑。如果你所在的环境强制要求 Servlet JSP JDBC 的传统路线那也可以但你要有心理准备JSP 里的 Java 代码容易写乱连接池和事务管理要自己处理的地方更多。为了让你能复用常见的课程设计要求下面所有表设计和代码我都尽量按「不依赖特定脚手架特性」的方式来写你迁到传统 Servlet 时改动量会小很多。pom.xml 里的核心依赖就五个spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok可选、thymeleaf或者 spring-boot-starter-thymeleaf。其中 lombok 我用它省掉 getter/setter 的代码量但如果你的 IDE 不支持 Lombok 插件就别用宁可手动写。Maven 仓库拉不下来的时候检查 settings.xml 里的镜像地址是否配置了国内镜像这是一个非常常见的环境翻车点。2.2 四张表的设计档案表、分类表、用户表、借阅记录表档案管理系统的核心数据模型围绕一个关键词「档案的生命周期」。一份档案从录入、归档、借出到归还状态一直在变。如果把状态只做在档案表的一个字段里看似简单但「谁在什么时间借的、什么时候还的」这类审计信息就丢了。所以我拆成四张表档案表、档案分类表、用户表、借阅记录表。档案表是最关键的一张。字段设计上我建议至少包含id、档案编号archives_no、标题title、分类idcategory_id、关键词keywords、存放位置location、文件路径file_path、状态status、创建人create_by、创建时间create_time、更新时间update_time。这里最容易犯的错是把「存放位置」和「文件路径」混在一起。存放位置指物理柜架比如「A区-3排-2层」文件路径指上传文件存在服务器磁盘的哪个目录两者用途完全不同。分类表非常简单id、分类名称、父分类id、备注。之所以要单独一张表是因为档案分类通常不是平铺的比如「技术档案」下面会有「项目文档」「设备台账」两个子类。用父子id结构支持两级就够了不要设计成无限级实验项目的复杂度控制在这里很关键。用户表和借阅记录表放在一起说因为权限控制依赖用户借阅记录则负责「追踪档案的每一次流转」。用户表字段id、用户名、密码、真实姓名、角色1管理员/2普通用户/3只读用户、创建时间。密码必须存加密后的值我会用 MD5 加盐或者 BCrypt不要存明文这在答辩时是加分项。借阅记录表id、档案id、借阅人id、借阅时间、应还时间、实际归还时间、借阅状态借出中/已归还。创建表的 SQL 我建议手写而不是依赖逆向生成因为手写能让你对字段类型和索引有掌控。档案编号字段要加唯一索引借阅记录表的档案id和借阅人id要建普通索引。你可以在schema.sql里初始化数据也可以直接在 MySQL 客户端里执行。下面是档案表和借阅记录表的建表语句CREATE TABLE archives ( id INT AUTO_INCREMENT PRIMARY KEY, archives_no VARCHAR(32) NOT NULL UNIQUE COMMENT 档案编号格式类别缩写-年份-序号, title VARCHAR(200) NOT NULL COMMENT 档案标题, category_id INT NOT NULL COMMENT 所属分类id, keywords VARCHAR(255) DEFAULT COMMENT 检索用关键词逗号分隔, location VARCHAR(100) DEFAULT COMMENT 实体存放位置如 A区-3排-2层, file_path VARCHAR(255) DEFAULT COMMENT 电子文件存储路径相对路径, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在库 2借出 3已销毁, create_by VARCHAR(50) NOT NULL COMMENT 录入人用户名, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow_record ( id INT AUTO_INCREMENT PRIMARY KEY, archives_id INT NOT NULL COMMENT 档案id, user_id INT NOT NULL COMMENT 借阅人id, borrow_time DATETIME NOT NULL COMMENT 借出时间, due_time DATETIME NOT NULL COMMENT 应还时间, return_time DATETIME NULL COMMENT 实际归还时间未还为NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1借出中 2已归还, KEY idx_archives (archives_id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的核心设计判断有两个。第一是状态用 TINYINT 而不是 VARCHAR因为程序里判断数字比判断字符串更高效而且不会出现「在库」「在 库」这种带空格的数据脏值。第二是借阅记录用独立表而不是在档案表里加一个「借阅人」字段——后者虽然查询简单但一份档案借给两个人时旧记录会被覆盖历史就丢了。这些设计决策你在答辩时主动讲出来比被动等老师问要好得多。2.3 档案编号的生成规则一个看似简单却到处是坑的设计档案编号看上去只是个字符串拼接但它直接影响后面的检索和归档。我见过不少项目把编号做成自增id的字符串形式比如「1」「2」「3」,这样做在数据量小时没问题但档案编号作为一个对外可见的业务标识一旦涉及分类迁移或批量导入自增id会带来两个问题编号不表达任何业务信息且删除一条记录后编号会被复用这在审计场景里是灾难。常见的做法是「类别前缀 年份 四位流水号」例如「JS-2024-0001」表示技术档案2024年的第1份。流水号不能简单用数据库的自增id拼接因为删除记录后自增id不会再复用但你需要按年份独立计数。实现方式是在生成编号前查一下当前年份已有多少条记录再加一并用 synchronized 或唯一索引兜底防止并发重复。由于小型项目的写入并发极低synchronized 足够。我一般会把编号生成抽成一个服务方法这样录入档案时只需要调用一次public String generateArchivesNo(Long categoryId) { // 先根据分类查前缀比如技术档案对应 JS String prefix categoryMapper.selectById(categoryId).getPrefix(); String year String.valueOf(Year.now().getValue()); // 查当年该分类下的最大流水号比如 0003 就取 3 Integer maxSeq archivesMapper.selectMaxSeqByCategoryAndYear(categoryId, year); int nextSeq (maxSeq null ? 0 : maxSeq) 1; return String.format(%s-%s-%04d, prefix, year, nextSeq); }这段代码有几个细节值得注意。selectMaxSeqByCategoryAndYear这个 SQL 写的是SELECT MAX(CAST(SUBSTRING_INDEX(archives_no, -, -1) AS UNSIGNED)) FROM archives WHERE category_id ? AND archives_no LIKE ?第二个参数是前缀-年份-%。这里用 SUBSTRING_INDEX 取最后一个片段转成数字避免把「0003」转成「3」后排序出错。%04d 是格式化占位符保证流水号是四位不足补零。Year.now().getValue()比new Date().getYear()更直接后者返回的是「当前年份减1900」是个老坑。这个生成方式在单机部署下够用但有一个边界你要知道如果档案编号被用户手动录入而不是系统生成上面的查最大值逻辑会被绕过可能产生重复。所以建表时archives_no字段的唯一索引必须保留一旦插入重复编号数据库会抛 DuplicateKeyException你要在 Service 层捕获并提示「编号已存在」。3. 核心功能实现登录、权限和档案的增删改查3.1 登录与角色权限用拦截器守住后台入口档案管理系统的权限需求通常分三级管理员能录入、修改、销毁档案普通用户能借阅和查看只读用户只能检索和浏览。如果实验要求更简单两级也够用。关键在于权限控制不能只靠前端隐藏按钮后端接口必须做校验这是很多课程设计翻车的重灾区。我的做法是写一个拦截器校验登录状态再按请求路径区分角色。比如/admin/**开头只允许管理员访问/user/**开头登录即可/public/**不拦截。这个设计直观且能讲清楚。下面是一个基于 Spring Boot 拦截器的实现思路public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User loginUser (User) session.getAttribute(loginUser); // 未登录直接跳转到登录页 if (loginUser null) { response.sendRedirect(/login); return false; } String uri request.getRequestURI(); // 管理员接口非管理员角色直接拒绝并提示 if (uri.startsWith(/admin/) loginUser.getRole() ! 1) { response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\message\:\无权限访问\}); return false; } return true; } }代码逻辑分三步先从 Session 取用户没取到就重定向到登录页取到了再看请求路径是否属于管理员专属。这里有个关键参数说明loginUser.getRole()返回的 int 值1 是管理员、2 是普通用户、3 是只读用户这个数值要和建表时注释里的定义保持一致。页面上的隐藏按钮作用只是让界面干净真正的防线在拦截器里所以你不需要担心「用户拼 URL 绕过按钮」这种问题。配合拦截器你还需要一个登录接口来校验密码。密码校验我用 BCrypt 的matches方法它比明文比较慢是正常的换来的是安全性。如果你用的密码字段是 MD5可以在注册时调用DigestUtils.md5DigestAsHex加密存储登录时同样对输入做一次 MD5 再和数据库比对。不要把校验逻辑写在 JSP 或 Thymeleaf 模板里那个位置不属于后端控制链路别人直接请求接口就能绕过。3.2 档案录入与更新文件上传和表单提交要放同一个事务里档案录入是使用频率最高的操作它同时涉及表单字段和文件上传。最容易出的问题是档案基本信息保存了文件没传上来或者文件传了但档案记录没落库留下一个孤儿文件。正确的做法是让这两个动作在一个方法里完成用事务保证要么都成功、要么都失败。这里的实现关键是「先传文件拿路径再保存数据库记录」。因为 file_path 字段存的是相对路径不是 MultipartFile 对象本身。上传目录我固定放在项目根目录下的upload/子目录中并且按年月建子目录方便后期清理。虚拟机或本地跑的时候这个目录要提前建好Spring Boot 不会自动创建多层目录的话你需要用File.mkdirs()主动创建。下面是一个档案录入 Service 层方法的骨架Transactional(rollbackFor Exception.class) public void addArchives(ArchivesForm form, MultipartFile file) throws Exception { // 1. 生成唯一档案编号 String archivesNo generateArchivesNo(form.getCategoryId()); // 2. 保存上传文件到磁盘返回相对路径 String relativePath null; if (file ! null !file.isEmpty()) { String dateDir LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMM)); String fileName UUID.randomUUID().toString().replace(-, ) getExtension(file.getOriginalFilename()); String fullPath uploadDir File.separator dateDir File.separator fileName; File dest new File(fullPath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); // 核心把临时文件写到目标位置 relativePath dateDir / fileName; } // 3. 组装实体并落库 Archives archives new Archives(); archives.setArchivesNo(archivesNo); archives.setTitle(form.getTitle()); archives.setCategoryId(form.getCategoryId()); archives.setKeywords(form.getKeywords()); archives.setLocation(form.getLocation()); archives.setFilePath(relativePath); archives.setStatus(1); archives.setCreateBy(form.getLoginUser()); archivesMapper.insert(archives); }这段代码里你必须注意四个参数相关的事。文件名的部分我用 UUID 重命名而不是保留原始文件名这样避免了中文文件名和特殊字符在下载时产生的编码问题代价是用户下载时看到的是随机名所以数据库里最好再加一列存原始文件名或者用 title 字段作为下载时的展示名。file.transferTo(dest)是 Spring 封装的方法它负责处理临时文件的搬迁不要自己用 FileOutputStream 再拷一遍那样会重复。Transactional(rollbackFor Exception.class)必须写 Exception 而不是默认的 RuntimeException因为文件传输抛出的 IOException 是受检异常不指定的话事务不会回滚就会发生「文件存了记录没存」的数据不一致。最后form.getLoginUser()建议在拦截器里就把用户对象塞进 Session而不是依赖页面再传一次用户名这样更安全也更方便取当前操作人。3.3 档案检索多条件组合查询的动态SQL写法档案检索是系统的门面功能老师演示时最常输入的就是标题或关键词。实现上我用 MyBatis 的动态 SQL支持按标题模糊搜索、按分类筛选、按状态筛选、按时间范围筛选四种条件的任意组合。这里不建议写多个 if 分支的 Java 代码去手动拼 SQL那样容易漏条件且可读性差。下面这段 mapper.xml 里的查询是这类系统最常见和可靠的标准写法select idselectByCondition resultTypecom.demo.entity.Archives SELECT * FROM archives where if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if teststatus ! null AND status #{status} /if if teststartTime ! null AND create_time gt; #{startTime} /if if testendTime ! null AND create_time lt; #{endTime} /if /where ORDER BY create_time DESC /select几个细节要讲清楚。where标签会自动处理掉第一个条件前面的 AND所以你不必担心条件组合时多出来 and 导致语法错误。LIKE 语句里我写的CONCAT(%, #{title}, %)而不是%${title}%前者是预编译参数绑定能防 SQL 注入后者是字符串拼接存在注入风险这个差异在答辩时被问到的概率很高。时间比较用的是gt;和lt;这是 XML 转义后的写法直接写会报解析错误。最后 ORDER BY 固定用 create_time 倒序保证最新录入的档案排前面这个排序策略贴合档案管理的实际习惯。实际使用中还有一个体验优化的点当档案数量超过几百条后列表页要做分页。我用 MyBatis 的 PageHelper 插件一条PageHelper.startPage(pageNum, pageSize)就能搞定。刚用 PageHelper 的常见问题是明明写了 ORDER BY 但排序不生效原因是你把 XML 里的 ORDER BY 和 PageHelper 的 count 查询混在一起导致 count 语句也带上 ORDER BY 影响性能。解决办法是 XML 里的 ORDER BY 保留在 select 主语句末尾count 用统一的SELECT COUNT(*) FROM archives前缀即可PageHelper 会自动处理成简单 count。4. 文件上传与借阅流程把系统从「能查」做到「能流转」4.1 档案文件的下载和预览别让浏览器把 PDF 当附件下载档案管理里的文件通常是 PDF、Excel、Word 或图片。用户点「预览」时PDF 最好直接在浏览器里打开而不是下载点「下载」时才触发下载。Spring Boot 的 ResponseEntity 可以满足这两个不同的响应方式。这里核心是 Content-Type 和 Content-Disposition 两个响应头的设置前者告诉浏览器文件是什么类型后者决定是内联展示还是附件下载。我给这类场景封装了一个文件下载接口可以直接用在你的 Controller 里GetMapping(/archives/{id}/preview) public ResponseEntityResource preview(PathVariable Long id, HttpSession session) throws Exception { Archives archives archivesMapper.selectById(id); File file new File(uploadDir File.separator archives.getFilePath()); if (!file.exists()) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } String extension getExtension(file.getName()); MediaType mediaType MediaType.parseMediaType(application/pdf); // 默认按PDF处理 if (doc.equals(extension) || docx.equals(extension)) { mediaType MediaType.parseMediaType(application/msword); } InputStreamResource resource new InputStreamResource(new FileInputStream(file)); return ResponseEntity.ok() .contentType(mediaType) .header(HttpHeaders.CONTENT_DISPOSITION, inline; filename\ URLEncoder.encode(file.getName(), UTF-8) \) .body(resource); }这段代码里最有用的是inline这个词。CONTENT_DISPOSITION 设为inline时浏览器会在当前页面内打开 PDF 或图片设为attachment时强制下载。文件名用URLEncoder.encode处理是必须的否则中文文件名会变成一堆百分号编码或者直接丢失。InputStreamResource包装了文件流Spring 会在响应完成后自动关闭它你用new FileInputStream(file)不需要手动关流这一点和原生 Servlet 里的写法有差别新手容易在这里重复关闭流导致报错。存在一个常见的问题是 Word 文件点击预览时浏览器不认application/msword会变成下载。这是浏览器行为不是代码问题解决办法是提示用户使用 PDF 格式存档或者在预览接口里对 doc/docx 做转 PDF。小型项目里加一个转换依赖会增加复杂度我一般不推荐在实验里做直接告诉老师「Word 建议下载后查看PDF 支持在线预览」即可。4.2 借阅与归还状态机是这类系统最容易讲清楚的业务逻辑档案的借阅和归还不是两个孤立的接口它们共享一个状态机约束只有「在库」状态才能借出借出期间不能重复借归还时只有「借出中」状态的记录能还。如果不在代码里校验状态会出现同一个人借同一份档案两次或者归还一份没人借的档案这些是业务逻辑错误而不是编译错误测试时才会暴露。我用一个简单的 Service 方法来实现借阅Transactional(rollbackFor Exception.class) public void borrowArchives(Long archivesId, Long userId, Integer days) throws Exception { Archives archives archivesMapper.selectById(archivesId); if (archives null) { throw new RuntimeException(档案不存在); } // 状态校验1在库才能借2借出中不能重复借 if (archives.getStatus() ! 1) { throw new RuntimeException(档案当前不可借阅); } // 新增借阅记录默认借出中 BorrowRecord record new BorrowRecord(); record.setArchivesId(archivesId); record.setUserId(userId); record.setBorrowTime(new Date()); Calendar calendar Calendar.getInstance(); calendar.setTime(new Date()); calendar.add(Calendar.DAY_OF_MONTH, days); record.setDueTime(calendar.getTime()); borrowRecordMapper.insert(record); // 更新档案状态为借出中 archives.setStatus(2); archivesMapper.updateById(archives); }这段代码的关键判断是archives.getStatus() ! 1。你在设计状态值时最好和常量类对应起来不要把 1、2、3 散落在业务代码中。参数days表示借阅天数一般由管理员在前端指定默认给 30 天。Calendar.add(Calendar.DAY_OF_MONTH, days)是实现日期加天数的可靠方式不要用new Date(days * 24 * 3600 * 1000)这种方式它遇到夏令时或时区问题会计算错误。归还操作的逻辑是借阅的镜像查借阅记录里 status1 且 archives_id 对应的记录把 return_time 更新为当前时间同时把档案状态改回在库。这里需要注意一个细节如果一份档案被借出多次早前归还过查询时一定要按 borrow_time DESC 排序然后取第一条否则可能把历史借阅记录当成当前记录。SQL 可以写成SELECT * FROM borrow_record WHERE archives_id ? AND status 1 ORDER BY borrow_time DESC LIMIT 1。借阅流程做完后你要注意一个设计上的提升点超期未还。实验系统一般不会做定时任务来每天扫描超期记录但你可以做一个「超期列表」查询在档案列表页旁边显示一份视图只需要在借阅记录表里查due_time now AND status 1。这种查询不需要额外建表一个 SQL 就够但它能显著提升系统的完成度让人看出你考虑了真实业务场景。5. 避坑指南从编译报错到逻辑错误的五个高频问题5.1 Tomcat 启动后访问页面中文乱码现象浏览器里档案标题显示为「???」或一堆乱码但数据库里查出来是正常中文。原因有两层MySQL 连接 URL 没指定字符集或页面渲染时响应头没声明 UTF-8。最常见的是第一种JDBC 连接串里少了characterEncodingutf8这个参数。第二种是 Thymeleaf/JSP 页面没有写contentTypetext/html; charsetUTF-8。解决办法先改application.yml里的 datasource URL在连接串末尾加上?useUnicodetruecharacterEncodingutf8注意在 yml 里要原样写不用转义。然后在页面模板的 head 里加meta http-equivContent-Type contenttext/html; charsetUTF-8或者在 Controller 里加produces text/html;charsetUTF-8。改完后清一下浏览器缓存再试这一步很容易被忽略你改了半天代码没反应其实是缓存。5.2 文件上传时 Tomcat 默认限制 1MB现象上传一个几 MB 的 PDF 没有任何反应或者报 400 错误控制台显示 MaxUploadSizeExceededException。原因Spring Boot 内嵌 Tomcat 对单个文件上传大小有默认限制通常单文件是 1MB请求总大小是 10MB。这个限制是两层Tomcat 容器层和 Spring MVC 配置层只改其中一边不一定生效。解决办法在application.yml里配置spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB这两行的含义分别是单个文件最大 20MB、一次请求所有文件总和最大 50MB。如果你的实验环境要求上传扫描件20MB 足够用。改完之后如果仍然报错请确认你用的是 Spring Boot 2.x因为 1.x 版本配置项是spring.http.multipart.max-file-size位置不同网上很多老教程混淆这两者。5.3 删除档案只删了记录文件残留在服务器现象删除一条档案后列表里没有了但服务器的 upload 目录里文件还在越积越多。原因删除操作的业务逻辑里只执行了DELETE FROM archives WHERE id ?没有考虑 file_path 字段对应的磁盘文件。解决办法删除时先查询档案数据拿到 file_path然后删除这条记录最后用 Java 的Files.deleteIfExists(Paths.get(fullPath))删除物理文件。顺序是先查再删记录还是先删文件再删记录我建议先删记录再删文件因为如果先删文件后删除记录失败会出现数据库里有一条指向不存在文件的记录这样至少不会产生「孤儿文件」问题。如果你做了物理删除还要记得同时处理借阅记录表里关联的数据否则借阅记录里会出现一个不存在的档案 id联查时回到空引用。处理办法是外键逻辑删除不实际删 borrow_record而是把关联记录的 status 置为无效或者干脆在删除前检查是否有未归还的借阅记录有就禁止删除并提示。5.4 分页查询时每页数量不对或排序错乱现象PageHelper 分页查出来的列表总条数正确但每页显示的数据有重复或遗漏翻页后顺序不稳定。原因PageHelper 的分页参数是保存在 ThreadLocal 里的如果执行 SQL 前没有及时使用 startPage或者同一个线程里执行了多条查询分页参数会污染后面的查询造成莫名其妙的结果。解决办法遵守一个规则——PageHelper.startPage(pageNum, pageSize)必须紧跟在你需要分页的那条 select 语句之前中间不能有任何其他数据库查询。避免在查询方法内部再调用另一个 Mapper 方法因为那也会被带上分页效果。另外如果你在 XML 里写了多个if动态条件最后的 ORDER BY 必须存在且字段稳定否则数据库没有保证翻页顺序重复数据就会冒出来。5.5 拦截器放行了静态资源登录页样式全丢现象未登录时访问任意页面被重定向到 /login但登录页没有任何 CSS长相很难看。原因你拦截了所有路径/**把 CSS、JS、图片这些静态资源也拦截了。虽然你放行了 /login 这个页面路径但页面里引用的 /css/style.css 请求也被拦下重定向浏览器拿到的是登录页 HTML 而不是 CSS 文件。解决办法写拦截器注册类时显式排除静态资源路径。Spring Boot 里这样配置Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) // 拦截所有请求 .excludePathPatterns(/login, /css/**, /js/**, /images/**, /error); }.excludePathPatterns里的/css/**写法是 Ant 风格通配符表示匹配 css 目录下所有文件。这个问题在答辩时经常被老师追问你要能说清楚「为什么拦截器会导致样式丢失」的完整链路。另外一个关联问题是/error不放行的话404 或者 500 报错页也会被拦截重定向导致报错信息无法正常展示排错时看不到真正的异常堆栈影响非常大。6. 最后一步从「能跑」到「能答辩」的收尾技巧系统功能做完之后通常还有两到三天的富余时间。我建议用这段时间做四件性价比极高的事加一个操作日志表、做一份初始数据、准备一个「贯穿始终的设计亮点」、打磨演示流程。操作日志是最值得做的扩展点。你不需要做复杂的事只需在档案新增、修改、删除、借阅、归还五个动作里分别调用一个logService.record(userId, action, targetId)方法日志表就四个字段id、用户、动作、时间。答辩时当老师问到「怎么审计谁动过档案」你直接展示这个表效果比你讲十句代码都管用。初始数据非常关键。一个空荡荡的系统和录入了几十条数据并配有PDF模拟文件的系统演示观感完全不一样。你可以写一个数据初始化类在系统启动时检测档案表为空就自动插入测试数据包括技术档案、人事档案、合同档案几类每类配七八条记录其中包含各种状态的数据比如一份在库的、一份借出中的、一份刚创建的。你还可以故意造一条超期未还的记录用来演示超期查询功能这会让系统看起来「有故事」而不只是增删改查的壳子。设计亮点的选择我建议把「状态机 审计」绑定在一起讲。这个系统的核心逻辑不是页面怎么跳转而是档案状态流转做了约束在库才能借借出中不能被删除归还后自动恢复在库。你可以画一张状态流转表打印出来带过去三个状态、四个转移条件把这个讲清整个项目的高度就不是 CRUD 了而是有业务规则约束的信息系统。最后一步是演示流程的排练。尽量用一个独立测试账号演示完整流程登录 → 检索一份档案 → 预览 PDF → 借阅 → 查借阅记录 → 归还 → 再检索看状态变化。最后留两分钟打开 IDEA 里的代码定位到拦截器那一行给老师看「权限校验长这样」。我自己的习惯是哪怕功能已经全部跑通了也一定要提前在老师用的那台电脑或浏览器上再过一遍因为环境差异导致的翻车在演示时是最尴尬的一旦发生再解释「我本地是好的」会非常影响观感。这个方向本身是值得投入的因为档案管理系统的需求在企业里非常普遍你做的状态机设计、权限控制逻辑、文件存储策略都是真实项目里一模一样的模型。哪怕实验结束你可以只保留四张表和三个核心接口后续再往上加部门、加流程审批、加电子签章这个骨架不会塌。希望这些经验能帮你省下几个通宵让这次实验做得既有技术含量、又能从容收尾。本文还有配套的精品资源点击获取