
档案管理系统在Java Web里算是出现频率最高的题目之一毕业设计、课程设计、还有企业内部的小型业务系统都在做这套东西。需求大同小异管理员登录、档案录入、分类管理、条件检索、附件上传下载。但真正能把这套系统做完整、做规范的人其实不多很多人卡在前后端联调、鉴权逻辑、组合查询这几个环节。这篇文章把我从零实现一个SpringBootVue档案管理系统的过程完整拆开涉及MySQL表结构设计、MyBatis动态SQL、后端登录鉴权、前端组件联动以及项目跑通之前最容易翻车的几个配置点。整套系统基于JavaMySQLMyBatis实现配合完整源码思路适合正在做毕设或者刚学完JavaWeb想找一个完整项目练手的朋友。1. 为什么档案管理系统绕不开SpringBootVue这套组合拳1.1 先看清这类系统的真实需求档案管理系统本质上就是一个“登录后做增删改查”的后台管理系统。它的典型使用场景是这样的档案管理员登录系统把纸质档案信息录入到系统里包括档案编号、标题、类型、密级、存放位置等。查询人员通过关键字、分类、时间范围等条件组合检索档案快速定位到某一卷档案。系统支持上传扫描件、电子附件并记录档案的借阅、归还等流转信息。这类系统的典型特征是并发量不高、业务逻辑清晰、单机部署就能跑。它最看重的是开发速度、可维护性和基础功能的可靠性。所以这套系统的技术选型必然要遵循“够用就行”的原则而不是越复杂越好。1.2 技术栈分工SpringBoot、MyBatis、MySQL各管什么SpringBoot解决的是后端工程化的问题。以前用SSM框架开发光配置文件就能把人折磨死web.xml、spring.xml、springmvc.xml、mybatis.xml一个都不能少而且部署还要单独装Tomcat。SpringBoot把这些东西全部折叠成自动配置一个SpringBootApplication注解加上内嵌的Tomcatjava -jar直接启动。对于档案管理系统这种中小型项目来说SpringBoot大幅缩短了项目初始化时间和配置成本。MySQL是数据存储层。档案信息、管理员账号、分类字典这些数据结构化程度高事务要求明确用关系型数据库最合适。MySQL在中小型系统里性能足够部署也简单不会太吃服务器资源。MyBatis是数据库访问层它和SpringBoot配合起来非常顺。它最大的特点是“半自动化”SQL由程序员自己写MyBatis负责参数映射和结果集映射。这就意味着只要你的SQL功底过关任何复杂的查询逻辑都可以精确控制。1.3 为什么不用全自动ORM、不引入更多中间件我在设计这套系统时没有用JPA/Hibernate这样的全自动ORM也没有选择MyBatis-Plus更没引入Redis、MQ这类中间件理由很实际。全自动ORM框架的问题是“自动得越多失控的角落越多”。档案管理系统的查询经常是多条件动态组合可能按标题模糊查也可能按分类、密级、时间范围交叉查。用Hibernate时这种动态查询要么写复杂的Specification/QueryDSL要么干脆用原生SQL绕开框架——那框架的优势就没了。MyBatis的动态where和if标签处理这种场景是天然优势。MyBatis-Plus虽然现在很流行但很多学校和企业的老项目还在用原生MyBatis而且从学习角度看先把原生MyBatis的SQL映射机制搞明白再看MyBatis-Plus就很容易了。所以这套系统的数据访问层选择了原生MyBatis把最核心的CRUD和动态SQL逻辑讲清楚。完整源码里也是这个思路。至于Redis缓存、MQ消息队列这些档案管理系统在单机低并发的场景下根本用不上。引入它们只会增加学习和部署成本属于典型的过度设计。2. MySQL表结构设计先把档案系统的“地基”打对2.1 三张核心表管理员表、分类表、档案主表数据库设计是整套系统的地基。表结构设计不好后面写代码时到处别扭。档案管理系统最少需要三张核心表管理员表、档案分类表、档案信息主表。管理员表结构如下CREATE TABLE admin ( id int(11) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(255) NOT NULL COMMENT 密码哈希存储, nickname varchar(50) DEFAULT NULL COMMENT 昵称, role varchar(20) DEFAULT admin COMMENT 角色admin/operator, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT管理员表;密码字段我用了varchar(255)因为哈希后的字符串会变长MD5是32位BCrypt会更长留够余量。用户名加上唯一索引避免重复账号。create_time和update_time通过数据库自动维护代码里不需要手动set时间。档案分类表相对简单CREATE TABLE category ( id int(11) NOT NULL AUTO_INCREMENT COMMENT 分类ID, category_name varchar(100) NOT NULL COMMENT 分类名称, description varchar(255) DEFAULT NULL COMMENT 分类描述, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT档案分类表;档案主表是核心中的核心字段设计需要多花心思CREATE TABLE archive ( id int(11) NOT NULL AUTO_INCREMENT COMMENT 档案ID, archive_no varchar(50) NOT NULL COMMENT 档案编号业务唯一, title varchar(200) NOT NULL COMMENT 档案标题, category_id int(11) DEFAULT NULL COMMENT 所属分类ID, archive_type varchar(30) DEFAULT NULL COMMENT 档案类型文书/科技/财务/人事等, confidentiality varchar(20) DEFAULT 普通 COMMENT 密级普通/秘密/机密, retention_period varchar(30) DEFAULT NULL COMMENT 保管期限永久/30年/10年, location varchar(200) DEFAULT NULL COMMENT 存放位置库房-排-列-架, file_path varchar(255) DEFAULT NULL COMMENT 附件存储路径, uploader_id int(11) DEFAULT NULL COMMENT 上传人ID, is_deleted tinyint(1) DEFAULT 0 COMMENT 逻辑删除标记0未删1已删, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, remark varchar(500) DEFAULT NULL COMMENT 备注, PRIMARY KEY (id), UNIQUE KEY uk_archive_no (archive_no), KEY idx_title (title), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT档案信息主表;2.2 字段设计里的几个关键选择第一个关键选择是档案编号。档案编号是业务上的唯一标识比如“WS-2024-001”它跟自增主键id的定位完全不同。用户查询时输入的是档案编号不是数据库的id所以archive_no必须设置唯一索引并且业务写入时需要先查重。第二个关键选择是逻辑删除。直接DELETE FROM确实简单但档案数据往往有审计价值删了就找不回来。所以我用is_deleted字段做标记查询列表时统一WHERE is_deleted 0删除操作只是把标记置为1。这样数据安全性和查询简单性都保住了。第三个关键选择是密级和保管期限用varchar而不是数字字典。有的同学会把这种字段做成int然后在前端映射但档案领域这些分类相对固定直接用可读字符串反而更直观代码里也少一层转换。缺点是占空间稍大但档案表数据量不可能大到在乎几个字节的程度。2.3 索引与组合查询SQL的设计思路组合查询是档案系统最常用的功能按标题模糊查、按分类查、按密级查、按创建时间范围查往往是几个条件同时存在。MyBatis的动态SQL解决“条件是否存在”的问题MySQL索引解决“查得是否快”的问题。archive_no是唯一索引精确查档案编号时走索引title是普通索引配合LIKE %关键字%虽然没有最左前缀效果但表内数据量到几十万条时MySQL还是会利用索引做扫描优化category_id的普通索引则服务于分类筛选。实际查询时的SQL形态是这样的SELECT id, archive_no, title, category_id, archive_type, confidentiality, retention_period, location, file_path, create_time, remark FROM archive WHERE is_deleted 0 AND archive_no LIKE CONCAT(%, #{keyword}, %) AND category_id #{categoryId} ORDER BY create_time DESC LIMIT #{offset}, #{pageSize}这套表结构在真实项目中跑下来单表几万条数据量时查询完全无压力MySQL根本不需要优化。3. 后端链路从登录鉴权到档案增删改查的完整实现3.1 分层结构与包目录规划后端我用的是经典的四层结构Controller接收请求、Service写业务逻辑、Mapper负责数据库操作、Entity映射表结构。再加上config、interceptor、common几个辅助包完整包结构如下com.example.archive ├── controller # 接口层AdminController, ArchiveController, FileController ├── service # 业务层AdminService, ArchiveService, FileService ├── mapper # MyBatis Mapper接口 ├── entity # 实体类Admin, Archive, Category ├── config # 配置类WebMvcConfig, MyBatisConfig ├── interceptor # 拦截器LoginInterceptor ├── common # 通用类Result, JwtUtil, UploadUtil └── ArchiveApplication.java分层不要太花哨Controller不写SQL、不写业务判断只负责参数接收和结果返回。Service层做逻辑判断Mapper层只做数据库操作。这种分层方式写起来清晰改起来也容易定位问题。3.2 登录认证与拦截器配置密码哈希Token登录认证是管理系统的第一个安全关口。我采用的方案是密码存哈希、登录成功发JWT Token、拦截器校验Token。这是目前单系统最标准的做法。密码入库前哈希处理不会把明文密码直接写进数据库。示例代码PostMapping(/login) public Result login(RequestBody Admin admin) { Admin dbAdmin adminService.findByUsername(admin.getUsername()); if (dbAdmin null) { return Result.error(用户名或密码错误); } // 密码校验这里用MD5做的哈希实际生产建议BCrypt String inputPwd DigestUtils.md5DigestAsHex(admin.getPassword().getBytes()); if (!dbAdmin.getPassword().equals(inputPwd)) { return Result.error(用户名或密码错误); } String token JwtUtil.createToken(dbAdmin.getId(), dbAdmin.getUsername()); return Result.ok().put(token, token).put(nickname, dbAdmin.getNickname()); }提示示例源码里我用的是MD5加固定盐的简单方案因为完整项目要同时覆盖MySQL 5.7和8.0兼容性好。做正式系统时建议换成BCrypt它对暴力破解的抵抗能力强很多。有了Token之后需要通过拦截器统一校验。登录接口放行其他接口必须携带合法Tokenpublic class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; // 跨域预检请求直接放行 } String token request.getHeader(Authorization); if (token null || !JwtUtil.verify(token)) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } return true; } }拦截器在WebMvcConfig里注册配置好默认拦截路径和放行路径Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/admin/login); } }这里有个细节需要注意CORS跨域预检请求OPTIONS必须放行否则前端从Vue的3000端口访问后端的8080端口时预检请求被拦截器挡住浏览器会报跨域错误。3.3 MyBatis动态SQL组合条件查询和更新这是整个后端最有含金量的部分。档案列表查询的筛选条件不固定用户可能只输标题也可能同时选分类和密级。如果为每种组合写一条SQL数量会爆炸。MyBatis的where加if就是为这种场景准备的select idselectArchiveList resultTypecom.example.archive.entity.Archive SELECT id, archive_no, title, category_id, archive_type, confidentiality, retention_period, location, file_path, create_time, remark FROM archive where is_deleted 0 if testkeyword ! null and keyword ! AND (archive_no LIKE CONCAT(%, #{keyword}, %) OR title LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND category_id #{categoryId} /if if testconfidentiality ! null and confidentiality ! AND confidentiality #{confidentiality} /if if teststartTime ! null and startTime ! AND create_time gt; #{startTime} /if if testendTime ! null and endTime ! AND create_time lt; #{endTime} /if /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /selectwhere标签会自动去掉第一个多余的ANDif标签负责判断条件是否成立。动态SQL的意义在于查询逻辑全留在SQL层Java代码只需要一个Map或者一个查询对象传进来干净利落。更新场景的set标签也是同理编辑档案时有可能只改标题有可能连存放位置一起改不可能每次更新都把整行所有字段写一遍update idupdateArchive UPDATE archive set if testtitle ! nulltitle #{title},/if if testcategoryId ! nullcategory_id #{categoryId},/if if testconfidentiality ! nullconfidentiality #{confidentiality},/if if testlocation ! nulllocation #{location},/if if testremark ! nullremark #{remark},/if /set WHERE id #{id} /update3.4 文件上传与路径存储档案附件上传用SpringBoot的MultipartFile处理。上传接口接收文件把文件保存到服务器指定目录然后生成一个可访问的URL或相对路径存入数据库的file_path字段PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(上传文件不能为空); } // 原始文件名通过工具处理防止路径穿越问题 String originalFilename UploadUtil.sanitizeFilename(file.getOriginalFilename()); String fileName UUID.randomUUID().toString().replace(-, ) _ originalFilename; // 按照年月分目录存储避免单个目录文件过多 String datePath new SimpleDateFormat(yyyyMM).format(new Date()); File dir new File(uploadDir File.separator datePath); if (!dir.exists()) { dir.mkdirs(); } File dest new File(dir, fileName); file.transferTo(dest); String filePath /uploads/ datePath / fileName; return Result.ok().put(filePath, filePath); }提示文件名一定要处理。直接用用户上传的原始文件名拼路径万一文件名里带../或特殊字符就可能出现目录穿越问题。实际生产中通常用UUID重新命名既避免文件名冲突也避免安全风险。下载附件时配合一个静态资源映射规则或者提供下载接口从数据库读出file_path再拼出服务器磁盘路径通过ResponseEntity输出文件流。3.5 分页查询的具体做法列表页必须有分页。这里有两种方案一种是手动分页SQL里写LIMIT #{offset}, #{pageSize}另一种是用PageHelper插件。我的源码里用的是手动分页原因有三个一是手写LIMIT最直观新手一看就明白分页原理二是避开PageHelper的一些坑比如必须先startPage再执行查询、多表查询时容易分页分到错误的结果集三是这个系统的查询SQL本身就写了动态条件直接传offset和pageSize进去最简单。Controller层接收pageNum和pageSize两个参数计算offsetint offset (pageNum - 1) * pageSize;前端传的pageNum从1开始数据库的offset从0开始这个转换逻辑虽然简单但经常有人写错导致第一页数据重复或者丢行。如果前端用的是el-pagination组件它的current-page就是从1开始的正好对应。4. Vue前端从登录页到档案列表页的落地细节4.1 前端框架选型Vue2Vue CLIElement UI还是Vue3ViteElement Plus完整的档案管理系统源码里前端我建议直接用Vue3 Vite Element Plus。这是当前主流方向Vite的启动速度比Webpack快一大截Element Plus基于Vue3重写组件风格和Element UI基本一致。当然如果学校或者项目方指定了Vue2 Vue CLI Element UI也完全没问题核心交互逻辑是一样的。Vue3的setup语法让代码更简洁组合式API对逻辑复用也更友好我还是建议新项目直接用Vue3。4.2 Axios请求封装与跨域代理前端所有的HTTP请求都通过Axios发起但不要在每个页面直接调axios.get必须封装一层统一出口。封装的request.js里主要做两件事一是基础配置设置统一的baseURL和超时时间。我习惯把接口前缀设为/api这样后端Controller的RequestMapping是/api/archive前端请求也是/api/archive前后端完全对齐。二是拦截器。请求拦截器统一把登录Token从localStorage里取出来放到Authorization请求头响应拦截器统一处理返回值接口直接拿到data部分遇到401状态码自动跳回登录页service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(登录已过期)) } return res }, error { return Promise.reject(error) } )开发环境下的跨域问题由Vite的代理解决。Vite配置文件里这样写server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/archive/listVite开发服务器会把它转发到http://localhost:8080/api/archive/list。关键是changeOrigin必须设置为true否则后端收到的请求头里的Host还是前端地址部分严格校验Host的容器会出问题。4.3 路由守卫与登录态管理前端路由用Vue Router管理。路由守卫做的事情用户没有登录或者Token已过期访问任何页面前都会被拦下来跳转到登录页。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else if (!token) { next(/login) } else { next() } })登录页拿到Token和昵称后把Token写进localStorage把昵称存到Vuex或者Pinia里然后跳转到首页。这样每次刷新页面时导航栏和用户信息还能正常展示不需要重新登录。4.4 档案列表页的联动逻辑档案列表页是核心页面它的逻辑包含三个联动点搜索表单、表格、分页组件。搜索表单是以查询参数的形式绑定在响应式数据上。点击“搜索”按钮时把页码重置为1然后调用列表接口。点“重置”时清空表单字段重新查询。表格列需要配置好prop和label数据直接从archiveList数组渲染。分页组件用el-pagination要设置current-page、page-size、total并在current-change和size-change事件中重新加载数据。这里有一个容易错的地方分页组件改变页码后查询条件必须仍然保留。如果只是简单地把页码传给接口而搜索条件在翻页时丢失了用户翻到第3页会看到完全不符合条件的数据。解决方案是把查询条件放在同一个响应式对象里每次拉数据都用同一个对象。4.5 新增/编辑弹窗的校验与回显新增和编辑我用同一个弹窗组件通过一个dialogType字段区分是“新增”还是“编辑”。打开弹窗时如果是编辑需要根据当行的archiveId从后端拉详情数据回显到表单如果是新增就清空表单。表单校验规则集中在rules对象里。档案编号archive_no、标题title是必填字段前端用el-form的rules配置校验同时后端接口也要做非空校验。前后端校验必须同时存在前端的校验提升用户体验后端的校验保证数据安全——因为接口是可以被直接调用的绕过前端页面就绕过了前端校验。所有弹窗表单字段提交之前需要手动调用validate方法全部通过后再调用新增或更新接口。提交时注意把createTime这种后端维护的字段排除掉只提交业务字段。5. 跑通项目的环境准备与常见坑位5.1 版本匹配关系一张表说清很多同学代码写得没问题最后跑不起来纯粹是版本不匹配。这里整理一份我在源码里测试过的版本组合组件推荐版本说明JDK1.8或111.8最稳定11兼容性更好Maven3.6.33.6以下容易和JDK11起冲突SpringBoot2.7.x2.x版本对JDK8支持最好不推荐直接上3.xMyBatis2.2.2SpringBoot官方提供的mybatis-spring-boot-starterMySQL5.7或8.08.0要额外注意时区问题Node.js16.20Vite5要求Node版本不低于16Vue/ViteVue3.4 Vite5保持和最新稳定版一致SpringBoot 3.x出来之后很多同学一上来就创建3.x项目结果发现javax包全部改成jakarta老代码大量报错。档案管理系统这种项目没必要追新SpringBoot 2.7.x配JDK8是最稳的组合。5.2 MySQL8时区、驱动、连接池这三个老熟人MySQL 8.0使用过程中最常见的三个配置问题一是驱动类名变了。MySQL 5.7时代用的com.mysql.jdbc.Driver在8.0里已经废弃必须改为com.mysql.cj.jdbc.Driver。二是时区问题。用8.0如果不在连接串里指定时区会直接报错The server time zone value is unrecognized。处理方式是在连接URL后面加参数spring.datasource.urljdbc:mysql://localhost:3306/archive_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue也要加上否则8.0的caching_sha2_password认证方式下一些连接工具可能会提示公钥检索失败。三是连接池。Spring Boot 2.x默认使用HikariCP性能很好。唯一要注意的是maximum-pool-size和minimum-idle这两个参数档案系统配置maximum-pool-size10就够了没必要开着几十个连接占资源。5.3 前端代理不生效和接口404的排查思路接口返回404时很多人第一反应是检查Controller路径。但我在完整源码调试中发现前端代理配置错误才是第一高发的404来源。前端的/api前缀转发到后端后端的Controller也要有/api前缀这个网才兜得住。比如RestController RequestMapping(/api/archive) class ArchiveController { ... }如果后端RequestMapping写成/archive而前端代理转发到的是http://localhost:8080/api/archive后端找不到这个路径自然返回404。另一个坑是修改vite.config.js的proxy配置后必须重启前端开发服务器才能生效。Vite不会像修改业务代码那样自动热更新配置文件很多人改了配置发现没反应其实是没重启。5.4 文件上传、路径分隔符、Linux部署的差异文件上传大小限制是第二个高频坑点。SpringBoot默认单文件最大1MB档案附件的PDF、扫描件动辄几十MB必须在application.yml里放开限制spring.servlet.multipart.max-file-size50MB spring.servlet.multipart.max-request-size50MB如果只调大max-file-size而没调max-request-size一次性上传多个文件时请求总体积可能超限也会报错。路径分隔符的问题主要出现在Windows和Linux的差异上。Windows下用\\Linux下用/。代码里混合拼接路径本地开发好好的一部署到Linux服务器就找不到文件。我的处理方式是在所有文件路径拼接时统一使用File.separator或者干脆全部用相对路径加/由后端静态资源配置处理映射。部署到Linux时只需要修改上传目录的绝对路径配置代码不用动。最后把前端打包后的dist目录放到后端resources/static下面打成单应用jar包一个SpringBoot进程就同时提供了前端页面和API服务。档案管理系统在中小型场景下一台服务器就能完整运行整个架构从数据库到前端页面全链路闭合。我在实际交付时基本都是这个方案维护成本低用户用起来也简单。如果你需要给这套系统再加点功能优先建议先做导出Excel和档案密级权限拆分这两块是在实际使用中被提得最多的需求扩展起来也不会破坏现有结构。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。