简介:这是一份面向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 + MySQL,IDE 用 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)、分类id(category_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里的核心设计判断有两个。第一是状态用 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;charset=UTF-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 id="selectByCondition" resultType="com.demo.entity.Archives"> SELECT * FROM archives <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="status != null"> AND status = #{status} </if> <if test="startTime != null"> AND create_time >= #{startTime} </if> <if test="endTime != null"> AND create_time <= #{endTime} </if> </where> ORDER BY create_time DESC </select>几个细节要讲清楚。<where>标签会自动处理掉第一个条件前面的 AND,所以你不必担心条件组合时多出来 and 导致语法错误。LIKE 语句里我写的CONCAT('%', #{title}, '%')而不是'%${title}%',前者是预编译参数绑定,能防 SQL 注入,后者是字符串拼接,存在注入风险,这个差异在答辩时被问到的概率很高。时间比较用的是>=和<=,这是 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 ResponseEntity<Resource> 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)这种方式,它遇到夏令时或时区问题会计算错误。
归还操作的逻辑是借阅的镜像:查借阅记录里 status=1 且 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 连接串里少了characterEncoding=utf8这个参数。第二种是 Thymeleaf/JSP 页面没有写contentType="text/html; charset=UTF-8"。
解决办法:先改application.yml里的 datasource URL,在连接串末尾加上?useUnicode=true&characterEncoding=utf8,注意&在 yml 里要原样写,不用转义。然后在页面模板的 head 里加<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">或者在 Controller 里加produces = "text/html;charset=UTF-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 里的代码定位到拦截器那一行,给老师看「权限校验长这样」。我自己的习惯是哪怕功能已经全部跑通了,也一定要提前在老师用的那台电脑或浏览器上再过一遍,因为环境差异导致的翻车在演示时是最尴尬的,一旦发生再解释「我本地是好的」会非常影响观感。
这个方向本身是值得投入的,因为档案管理系统的需求在企业里非常普遍,你做的状态机设计、权限控制逻辑、文件存储策略,都是真实项目里一模一样的模型。哪怕实验结束,你可以只保留四张表和三个核心接口,后续再往上加部门、加流程审批、加电子签章,这个骨架不会塌。希望这些经验能帮你省下几个通宵,让这次实验做得既有技术含量、又能从容收尾。
本文还有配套的精品资源,点击获取