☰
基于SpringBoot+Vue3+MyBatis的档案管理系统设计与部署实战
2026/10/19 2:58:25 网站建设 项目流程

做档案管理系统这个项目,其实比想象中要麻烦得多。传统Excel管理档案,随着数据量增长,文件一多就乱,查找、权限、版本全都是问题。我这次把一套完整的Java SpringBoot+Vue3+MyBatis档案管理系统源码从零梳理了一遍,从需求拆分、表结构设计、前后端接口联调到打包部署,该踩的坑一个没落下。这套技术栈是目前国内中小型管理系统里最常遇到的一套组合:前后端分离 + MySQL存储,既有代表性,又足够轻量。不管你是准备毕业设计,还是想快速上手企业级项目开发,都值得花二十分钟把这里面的设计思路完整走一遍。

1. 项目整体设计思路与需求拆解

1.1 档案管理系统不只是"增删改查"

很多人看到"档案管理系统"这个标题,第一反应是:不就是对一张档案表做增删改查吗?真正做起来才会发现,业务上至少有五个维度需要同时处理。

第一是分类。档案必须有可配置的档案分类,比如人事档案、合同档案、技术文档,分类还要支持树形层级,否则后期检索直接失控。第二是权限。不同角色能看到的档案范围必须不一样,普通用户只能看自己创建或借阅的档案,管理员才能看全部。第三是借阅归还流程。档案实体借出去之后要有状态流转,是"在库"还是"已借出",借了多久、谁借的、什么时候还,都需要记录。第四是操作留痕。谁在什么时间改过哪份档案、删除过哪个分类,必须能追溯,不然出了问题根本没法查。第五才是录入、修改、查询这些基础操作。

所以系统最终拆出来的功能模块,比单纯一张表要复杂:登录认证、用户权限、档案分类、档案管理、借阅管理、操作日志,基本是六大部分。

1.2 为什么一定要用前后端分离

早几年做管理系统,基本都是SpringBoot + Thymeleaf模板一把梭,后端同时管数据和页面渲染。这种方式对于小项目很快,但页面交互一复杂就非常难受。

这套项目采用前后端分离,后端只提供JSON接口,前端用Vue3独立构建页面,两边可以完全并行开发。档案管理系统的查询页面经常要调整交互,比如查询条件加一个、表格列改一列,分离之后只改前端就行,后端接口完全不用动。Vue3的Composition API处理复杂列表页、弹窗表单、文件上传这些场景,比Vue2的Options写法更直观,逻辑可以按功能聚合,而不是按数据、方法、计算属性硬拆。

另外,前后端分离还有一个隐性好处:接口可以复用。将来如果要做移动端或者小程序,后端接口无需重写,直接对接新前端就行。这也是现在企业里普遍采用这种架构的真实原因。

2. 系统核心模块与数据库设计

2.1 核心功能模块划分

动手写代码之前,先把模块边界划清楚。这里不能想到哪儿写到哪儿,否则后面接口会越拆越乱。

模块核心功能
系统登录账号密码登录、JWT签发、退出登录
用户与权限用户管理、角色分配、菜单权限控制
档案分类分类树维护、档案类型关联
档案管理档案新增、编辑、删除、条件查询、详情预览
借阅管理借阅登记、归还处理、借阅记录查询
操作日志用户操作记录、登录日志

每个模块对应独立的Controller、Service、Mapper文件,不做大杂烩。这一点很重要,很多新手喜欢把所有接口塞进一个Controller,看起来省事,后面维护一次就哭一次。

2.2 数据库表结构设计

数据库是整个系统的地基。我把核心表拆成六张:系统用户表、角色表、用户角色关联表、档案分类表、档案主表、借阅记录表,另外再加一张操作日志表。

用户表要特别注意密码字段不能存明文,我用VARCHAR(100)来存BCrypt加密后的密码。档案主表是核心,字段包括档案编号、标题、分类ID、内容、文件URL、状态、创建人、创建时间等。档案编号要有唯一性,我单独加了唯一索引。

CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), status TINYINT DEFAULT 1, deleted TINYINT DEFAULT 0, create_time DATETIME, update_time DATETIME ); CREATE TABLE archive_category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT DEFAULT 0, name VARCHAR(100) NOT NULL, sort_order INT, deleted TINYINT DEFAULT 0 ); CREATE TABLE archive ( id BIGINT PRIMARY KEY AUTO_INCREMENT, archive_no VARCHAR(50) NOT NULL, title VARCHAR(200) NOT NULL, category_id BIGINT, content TEXT, file_url VARCHAR(255), status TINYINT, create_by BIGINT, create_time DATETIME, update_time DATETIME, deleted TINYINT DEFAULT 0, UNIQUE KEY uk_archive_no (archive_no), KEY idx_category (category_id), KEY idx_create_time (create_time) );

所有业务表都加了deleted字段做逻辑删除,而不是物理DELETE。为什么?档案数据往往需要审计追溯,物理删掉之后痕迹就没了。逻辑删除虽然每次查询都要带上WHERE deleted = 0,但对数据安全来说完全值得。

主键统一用BIGINT自增,看起来不够炫,但足够稳定。档案编号单独走业务编码规则,比如类型前缀+日期+流水号,避免用户拿主键当业务号用。

2.3 索引和冗余设计心得

档案表最核心的查询场景是:根据档案编号、标题、分类、时间范围做分页查询。所以索引至少要有三个:archive_no唯一索引、category_id普通索引、create_time普通索引。

实际开发中,我建议列表接口不要在数据库做太多关联查询。档案表要显示创建人姓名和分类名称,我的做法是直接LEFT JOIN用户表和分类表。这两张表数据量不会太大,关联成本可控。冗余字段反而是个坑,比如在档案表里冗余一个分类名称,一旦分类改名,所有档案都得批量更新,很容易出漏网之鱼。

借阅记录表单独建一张,记录借阅人、借出时间、应还时间、实际归还时间、状态,每次归还都更新记录。千万不要在档案表上堆一堆"当前借阅人""借出时间"字段,否则历史记录全丢。

3. 后端核心实现:SpringBoot + MyBatis

3.1 分层结构与Mapper XML配置

后端项目我按标准的三层架构拆包:Controller负责接口和参数校验,Service负责业务逻辑,Mapper负责数据库操作。实体类、DTO、VO分开存放,避免把查询参数和返回结果混在一起。

com.example.archive ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── vo └── config

用MyBatis时有一个非常容易踩的坑:Mapper接口和XML文件必须能对得上。接口方法名、XML里语句的id、命名空间一丁点都不能错。XML文件我统一放在src/main/resources/mapper/目录下,然后在application.yml里配置扫描路径:

mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.archive.entity

如果项目启动后提示Invalid bound statement,九成是这里没配好,或者XML文件没有编译到target目录。

3.2 用户认证与JWT权限控制

登录模块我用JWT做无状态认证。用户提交用户名和密码,后端用BCrypt校验密码,校验通过后生成一个带过期时间的Token,返回给前端。前端每次请求在Header里带Authorization: Bearer token,后端通过拦截器统一校验。

JWT的好处是后端不用保存会话状态,水平扩展时非常方便。核心代码就是注册一个拦截器:

@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或登录已过期"); } Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", userId); return true; } }

然后配置拦截器排除登录接口和静态资源:

registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns("/api/auth/login", "/upload/**", "/error");

权限控制则是基于角色的:用户登录后,后端查询出其角色集合和权限标识,在需要控制的接口上加自定义注解和AOP,或者直接在Service里判断角色。小型档案系统用AOP做接口级权限控制就够了,不需要引入重型权限框架。

3.3 档案分页与条件查询

档案列表是我最重视的接口。查询条件有四个:关键字、分类ID、创建时间范围、当前页码。分页用MySQL的LIMIT实现,在Service里计算offset和pageSize,Mapper里用动态SQL拼接条件。

Mapper XML核心片段:

<select id="selectArchivePage" resultType="com.example.archive.vo.ArchiveVO"> SELECT a.*, c.name AS category_name, u.real_name AS create_by_name FROM archive a LEFT JOIN archive_category c ON a.category_id = c.id LEFT JOIN sys_user u ON a.create_by = u.id WHERE a.deleted = 0 <if test="keyword != null and keyword != ''"> AND (a.archive_no LIKE CONCAT('%', #{keyword}, '%') OR a.title LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null and categoryId != 0"> AND a.category_id = #{categoryId} </if> <if test="startTime != null"> AND a.create_time &gt;= #{startTime} </if> <if test="endTime != null"> AND a.create_time &lt;= #{endTime} </if> ORDER BY a.create_time DESC LIMIT #{offset}, #{pageSize} </select>

注意时间比较在XML里要写成&gt;=和&lt;=,直接写>=会报XML解析错误。这个问题排查过一次就长记性了。

分页接口要返回总数,所以我另外写了一个selectArchiveCount,同一个条件再查一次COUNT。这个方案在数据量不大时完全没问题,等将来档案量到百万级,再考虑换成MySQL分析和缓存优化。

3.4 文件上传与静态资源映射

档案系统必然要上传PDF、图片、Word文件。后端接口用MultipartFile接收文件,存储到服务器指定目录,文件名用UUID重新生成,避免中文名和重名问题。

@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("请选择文件"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + ext; Path dir = Paths.get(uploadPath); if (!Files.exists(dir)) { Files.createDirectories(dir); } file.transferTo(Paths.get(uploadPath, fileName).toFile()); return Result.success("/upload/" + fileName); }

文件存好之后要能通过URL访问,Spring Boot默认不会把本地磁盘目录映射成静态资源,需要在配置类里手动加:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourcePatterns("file:" + uploadPath + "/"); }

这里有个重要细节:uploadPath最好配置成绝对路径,不要用相对路径。我在本地调试时用./upload看起没问题,但用java -jar部署的时候,工作目录一变,文件就找不到了。

4. 前端核心实现:Vue3 + 组件库

4.1 Vue3工程初始化与核心依赖

前端我用Vite创建的Vue3项目。相比Webpack,Vite开发环境下冷启动快很多,改代码的响应速度也是即时反馈,体感舒服太多。

创建一个新项目的命令是:

npm create vite@latest archive-ui -- --template vue cd archive-ui npm install vue-router@4 pinia element-plus axios

组件库选了Element Plus,和Vue3配合很顺畅,表格、表单、分页、弹窗这些管理系统的常见组件开箱即用。状态管理用Pinia,比Vuex写法简洁,足够处理用户信息和全局状态。

4.2 Axios请求封装与Token注入

所有后端请求我都统一封装在一个request.js模块里,不直接散落在页面中调用Axios。拦截器做两件事:请求头带上Token,响应里处理公共错误码。

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( res => { const code = res.data.code if (code === 200) return res.data.data if (code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('登录已过期')) } ElMessage.error(res.data.msg) return Promise.reject(new Error(res.data.msg)) }, err => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(err) } ) export default service

这里的关键是:统一处理401跳转,页面里就不会到处写"判断登录过期"的逻辑。

4.3 档案列表页与分页逻辑

档案列表页是前端最复杂的页面,我用了Vue3的reactive来管理查询参数和分页数据,把表格数据、加载状态、搜索表单放在一起,逻辑才清晰。

import { reactive, ref, onMounted } from 'vue' const queryForm = reactive({ keyword: '', categoryId: null, startTime: '', endTime: '' }) const page = reactive({ current: 1, size: 10, total: 0 }) const tableData = ref([]) async function loadData() { const params = { ...queryForm, page: page.current, pageSize: page.size } const res = await getArchivePage(params) tableData.value = res.records page.total = res.total } onMounted(loadData)

每次搜索时把page.current重置为1,再调用loadData(),分页事件时只更新页码和条数。表格用el-table,分页用el-pagination,组件本身的current-change和size-change事件与page参数绑定。

4.4 档案新建/编辑表单与上传组件

档案表单用el-form加校验规则,字段比较多,我用一个formRef统一校验。文件上传用el-upload组件的http-request方法自定义上传逻辑,这样能拿到后端返回的文件URL,再把它赋值给表单字段。

async function handleUpload(options) { const formData = new FormData() formData.append('file', options.file) const url = await uploadFile(formData) form.fileUrl = url options.onSuccess(url) }

编辑回显时,文件URL要从后端返回的档案详情中取出来,用一个el-image预览或el-link展示文件名。注意:el-upload的默认行为会把文件立刻上传,所以在编辑场景下我要先判断fileUrl是否已有值,有值就只做展示,不让组件再发请求。

5. 本地运行与部署调试全流程

5.1 从MySQL初始化到后端启动

很多拿着源码跑不起来的人,问题都出在初始化步骤上。我习惯按顺序做:

  1. 在MySQL里创建空数据库,字符集选utf8mb4,排序规则选utf8mb4_general_ci。
  2. 执行项目里的sql/init.sql脚本,它会自动建表并插入默认管理员账号。
  3. 修改后端application.yml里的数据库地址、用户名、密码。
  4. 在项目根目录执行mvn spring-boot:run,或者用IDE直接启动主类。

这里有个容易被忽视的坑:utf8mb4和utf8mb4_general_ci一定要选对,否则中文用户名、中文标题可能写入失败。以前老项目用utf8,遇到特殊字符会直接报错,其实都是字符集惹的祸。

5.2 前端启动与代理配置

前端启动前要装依赖,然后配置文件里的接口地址。开发环境我一般不在前端写http://localhost:8080,而是用Vite的代理转发:

server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

这样前端请求/api/archive/page,代理会自动转发到后端的/api/archive/page,跨域问题在开发阶段就消掉了。

5.3 前后端分离部署的跨域问题

前后端分离项目部署后跨域问题会比开发环境更明显。生产环境我用Nginx做反向代理,把前端静态文件和/api接口请求放在同一域名下,从根源上消除跨域。

Nginx配置核心是:

location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

前端打包后的dist目录交给Nginx托管。这里我再强调一遍:如果后端同时开启了@CrossOrigin和Nginx代理,会出现重复的响应头,偶尔引发奇怪的登录失效问题。所以生产环境我建议后端不要开全局CORS,让Nginx统一处理。

6. 常见问题与排查技巧实录

6.1 Mapper XML 提示 Invalid bound statement

这个问题我在好几个项目里都看到过。现象是启动后调用查询接口,MyBatis报Invalid bound statement (not found)。原因基本是三类:

  • XML里namespace没有写Mapper接口全类名。
  • XML文件没放在配置的mapper-locations路径下。
  • Maven构建时没有把resources目录下的XML同步到target。

第三个最隐蔽。如果你发现target目录里根本没有XML文件,就在pom.xml里加一段配置,把src/main/resources下的xml和properties文件强制打包:

<build> <resources> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.xml</include> <include>**/*.properties</include> </includes> </resource> </resources> </build>

6.2 前端表单提交后端一直报 400 或 415

前后端联调时最常见的问题之一是:前端把表单数据作为application/x-www-form-urlencoded提交,后端接口却用@RequestBody接收JSON,于是报415。反过来,前端用Axios默认提交JSON,后端用@RequestParam接收,也会300错误。

我的规范是:POST的JSON接口统一用@RequestBody,前端提交时显式用JSON.stringify和Content-Type: application/json。我封装好的request.js里会统一处理,页面里不要再手动改请求头。

另一个坑是时间字段。前端传2024-01-01,后端用LocalDateTime接收会报无法转换。解决方法是前端传字符串,后端用@DateTimeFormat注解,或者在DTO里直接用String接收后再转换。

6.3 文件上传提示文件大小超限

Spring Boot默认最大上传文件是1MB,上传一个几MB的PDF就报错。需要在配置中放大上限:

spring: servlet: multipart: max-file-size: 20MB max-request-size: 25MB

如果项目用了Nginx,还需要改Nginx的client_max_body_size,否则请求会在Nginx层就直接被拒。这个大小要和后端配置协调,我一般设置成后端的两倍。

6.4 数据库时间查询结果差了8小时

这个问题在国内开发环境下非常典型。MySQL连接串里如果只写了serverTimezone=UTC,时间默认就是UTC时区,比北京时间晚8小时。解决方案是改成:

spring: datasource: url: jdbc:mysql://localhost:3306/archive?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

同时,如果后端是Spring Boot 2.x以上,LocalDateTime序列化到前端时也需要统一格式,我通常在application.yml里配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

最后分享一点个人体会

这个项目整体做下来,我最大的收获不是学会了某个框架API,而是真正理解了"需求拆分比代码实现更重要"。像档案分类树、借阅状态机、逻辑删除这些看似很基础的设计,如果一开始没想清楚,后面返工的成本会非常高。

我自己的体验是:先花半天把表结构设计好,再花半天把接口文档列出来,剩下写编码的时间其实很顺畅。前后端分离项目最容易卡住联调环节,但只要请求封装、代理配置、Token方案这三件事从一开始就统一规范,流程整体会顺很多。

后续如果要在这个源码上继续扩展,我建议优先加两层能力:一层是Redis缓存热点档案和用户登录态,减少数据库压力;另一层是全文检索引擎,对付大量档案的模糊搜索会快得多。但当前这套SpringBoot+Vue3+MyBatis的基础架构已经把核心流程完整跑通,踩坑经验也都摆在上面了,照着动手做一遍,前后端分离的整套套路基本就吃透了。

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

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

立即咨询