☰
SpringBoot+Vue档案管理系统实战:从权限设计到部署上线
2026/10/20 3:52:23 网站建设 项目流程

1. 一个真实的档案管理需求:从纸质堆到线上流转

先说这个项目是怎么来的。前阵子一个朋友找到我,说他们单位(某企业的资料室)还在用最原始的方式管理合同、报表、项目文档,几百个文件夹塞满三个铁皮柜,找一份三年前的合同要翻半个多小时,有时候借走了还没人登记,文件丢没丢全靠记忆。我听完之后的第一反应是:这不只是"做个网站把文件传上去"那么简单。档案管理系统的核心任务是解决三个问题——文件在哪、谁能看、借没借走,而这三个问题对应的正是后台的数据建模、权限控制和借阅流程设计。

所以当看到"基于SpringBoot+Vue的档案管理系统"这类标题时,不能只把它当成一个增删改查的练手项目。它是非常典型的Java全栈实战场景:前端需要处理复杂的检索条件和文件预览,后端要处理多表关联查询、文件存储路径管理和权限校验,数据库要应对大字段文件的元数据存储。这套技术组合——SpringBoot做后端脚手架、MyBatis做数据持久层、MySQL做存储、Vue做前端界面——恰好是当前国内中小型企业内部系统最常见的打底方案。学会这套骨架,你不仅能做出档案管理,稍微改改就能变成合同管理系统、固定资产管理系统、甚至是简版的OA办公平台。

这篇文章我准备按照我实际开发这个项目的顺序来讲:先讲需求拆解和技术选型的逻辑,再讲数据库设计和后端接口的实现细节,然后讲前端页面与接口联调,最后讲部署上线和生产环境会遇到的问题。全程只讲我踩过的坑和验证过的做法,不贴大段无用的官方文档,也不堆概念,尽量让你看完能直接照着写。

先给读者一个画像:如果你是刚学完SpringBoot和Vue基础语法、想找一个有完整业务逻辑的项目练手的人,或者你在公司里被安排开发内部管理系统、正愁没有参考实现的人,这篇文章都适合你。它不要求你有高深算法基础,但希望你至少懂Java基本语法、MySQL增删改查和Vue的生命周期——如果这些还不太熟,文中我也会标注哪些地方需要先补课。

2. 技术选型为什么是这套组合:SpringBoot、MyBatis、MySQL、Vue的取舍逻辑

选技术栈和买菜一样,新鲜是一方面,关键得看做什么菜。档案管理系统属于典型的"传统企业级Web应用",它不像互联网高并发系统那样需要微服务、Redis缓存、消息队列一堆东西伺候,也不像算法项目那样需要GPU和向量数据库。它的特点是逻辑清晰、表结构稳定、权限严格、并发量低但数据敏感度高。围绕这些特点,这套技术组合其实每一样都踩在点上。

2.1 后端框架:SpringBoot解决了什么麻烦

很多新手纠结要不要学Spring Cloud,或者觉得SpringBoot是不是过时了。档案管理系统这种项目用SpringBoot就是最合理的,原因很简单:SpringBoot把以前SSH这种老框架大量繁琐的XML配置全部自动化了,你只需要在application.yml里写几十行配置就能跑起来一个可用的Web服务。它内置Tomcat,java -jar一条命令部署,不用单独装容器。对中小型内部系统来说,开发效率和维护成本是最重要的,SpringBoot在这两点上都做得很好。

有人可能会说Spring Boot 3.x已经出来了,用新不用旧。我的建议是:如果是从零开始新写,可以用SpringBoot 3.x配合JDK 17,但前提是你对Jakarta EE命名空间的迁移足够熟悉——3.x里javax.*变成了jakarta.*,网上很多老教程里的代码直接复制会报错。如果不想折腾兼容性问题,SpringBoot 2.7 + JDK 8/11这套组合非常成熟稳定,社区问答也多,遇到问题一搜就有答案。我做这个项目时用的是2.7.18,主要是考虑到部署机器上可能只有JDK 8,省去换环境的口舌。

2.2 持久层与数据库:MyBatis加MySQL的经典组合

数据持久层选了MyBatis,而不是Spring Data JPA,这一点在档案管理系统场景下我觉得是有意为之。MyBatis的核心优势是SQL由你完全掌控。档案系统的查询条件非常灵活——按档案编号、按标题、按分类、按创建时间、按保管期限,这些条件要组合起来做多条件检索。JPA虽然可以用Specification拼接,但那种方式在复杂查询下可读性很差,一旦SQL出了问题,排查成本很高。MyBatis的XML文件里写动态SQL,<where>、<if>标签把条件拼得明明白白,SQL怎么执行的一目了然。

再有就是档案系统的数据模型经常会有多表关联,比如档案表关联分类表、关联创建人、关联借阅记录。MyBatis的resultMap可以非常精细地控制这种关联映射,嵌套查询也好、联表查询也好,都不会产生JPA那种N+1问题的"惊喜"。至于MySQL,那就更没什么好纠结的了。项目规模放在这里,MySQL免费、轻量、文档全,团队里会的人也多,在选型会议上根本不需要花太多口舌。

2.3 前端框架:Vue 3还是Vue 2,选哪个更合适

前端选Vue是个稳妥的决定。档案管理系统这种后台管理系统,页面结构高度模板化:左侧菜单、顶部导航、中间内容区,表格加表单是主角。Vue的单文件组件和响应式绑定特性让这类页面开发效率很高。具体选Vue 3还是Vue 2,我是这么看的:如果团队之前没有Vue 2的存量项目,直接上Vue 3 + Vite,组合选项式API的时候上手难度也不大;如果团队里大家习惯了Vue 2的写法,临时切换的成本也需要评估。

我做这个项目时选了Vue 2.6 + Element UI,原因比较实际:一是这个项目的工期紧了,同事们对Vue 2更熟,拿起来就能干;二是Element UI的表格、表单、树形组件一套下来,做后台界面几乎是开箱即用,没有太多造轮子空间。Vue 3生态的Element Plus当然更好,但当时迁移过来还有不少组件细节不一样。现在你再让我选,我会选Vue 3 + Element Plus,毕竟新项目起步还是别给自己埋技术债。前端状态管理我用的Vuex 4,如果在Vue 3里就配合Pinia,写法更轻快。

2.4 一个容易被忽略的点:版本兼容性

列一份我实际用到的版本清单,方便后面比照:后端是SpringBoot 2.7.18、MyBatis 2.3.1(这是mybatis-spring-boot-starter的版本)、mysql-connector-j 8.0.33。前端Vue 2.6.14、vue-router 3.6.5、vuex 3.6.2、element-ui 2.15.13。构建工具用的Maven 3.8.8和npm 8以上。这套组合我至少完整跑通过两次,不会出现莫名其妙的三方库冲突。新手最忌讳的就是在官网复制最新的SpringBoot版本然后配上最老版本的MyBatis starter,启动报错一连串,还以为是代码问题——十有八九是版本匹配问题。

3. 后端落地:从数据建模到核心接口

思路理顺了,就开始写代码。后端这块我按实际开发顺序拆解:先建数据库表结构,因为一切业务都构建在数据模型之上,尤其档案系统涉及文件元数据和借阅流转,表设计如果一开始想不清楚,后面返工很痛苦。表设计完之后就是实体类、Mapper接口、Service、Controller一条线。

3.1 数据模型:五张核心表的设计思路

档案管理系统虽然可以做得很大,但核心表不会太多。我把第一版设计收敛到五张表:用户表sys_user、角色表sys_role、档案分类表archive_category、档案信息表archive_doc、借阅记录表archive_borrow。另外还有用户角色关联表sys_user_role、角色权限表sys_menu等,权益模型相关的放后面讲,先说主业务。

档案信息表是绝对的主角,字段上需要沉淀这些信息:

字段名类型说明
idbigint主键,自增
doc_codevarchar(64)档案编号,业务上要唯一,比如GD-2025-0012
titlevarchar(200)档案标题
category_idbigint关联分类表
file_pathvarchar(500)文件存储路径(相对路径)
file_typevarchar(20)文件类型:pdf / jpg / docx / xlsx
file_sizebigint文件大小,单位字节
secret_leveltinyint密级:1公开,2内部,3机密
statustinyint状态:1在库,2借出,3已销毁
descriptiontext档案描述
create_bybigint创建人
create_timedatetime创建时间
update_timedatetime更新时间

这里有几个值得展开的细节。首先是doc_code,不能直接让用户随便填,而是要在后端生成。我建议的规则是"前缀+年份+序号",比如前面提到的GD-2025-0012。生成逻辑是查询当年已有记录数,加一后补零,这条SQL放在Service层用事务控制,避免并发时生成重复编码。其次是file_path,我强烈建议数据库只存相对路径,绝对路径别入库。原因很简单——系统将来部署的服务器路径会变,你把C:/aaa/bbb/ccc这种写进数据库,换机器就全部作废。我统一存/uploads/2025/03/xxx.pdf这种形式,读取时再拿配置的根路径去拼全路径。

分类表相对简单,关键在可以做无限级树形目录。字段用id、parent_id、name、sort_order即可。前端借助Element UI的el-tree组件渲染成树,后端提供一个buildTree方法把平铺List转换成嵌套结构。这个树形结构在档案系统里很有用,因为一个企业的档案分类往往是"部门-年度-类型"的多层级关系。

借阅记录表archive_borrow是这个项目业务上的点睛处,字段包括id、archive_id、borrow_user、borrow_time、expected_return_time、actual_return_time、status(1借出中,2已归还,3逾期)。通过这张表和档案主表的status字段配合,就能回答我开头说的"档案借没借走"这个问题。借出时先把档案的status改成2,同时插入一条借阅记录;归还时反过来改状态、更新返回时间。这里我用了一个@Transactional事务,保证两条表的数据一致。

3.2 MyBatis动态SQL:多条件检索的权威解法

档案列表页面必备的功能就是多条件检索,这也是MyBatis最能体现优势的地方。我的ArchiveDocMapper.xml里写了一个pageList查询,核心结构大致是这样:

<select id="pageList" resultType="com.example.archive.entity.ArchiveDoc"> SELECT d.*, c.name AS categoryName FROM archive_doc d LEFT JOIN archive_category c ON d.category_id = c.id <where> <if test="docCode != null and docCode != ''"> AND d.doc_code LIKE CONCAT('%', #{docCode}, '%') </if> <if test="title != null and title != ''"> AND d.title LIKE CONCAT('%', #{title}, '%') </if> <if test="categoryId != null"> AND d.category_id = #{categoryId} </if> <if test="secretLevel != null"> AND d.secret_level = #{secretLevel} </if> <if test="status != null"> AND d.status = #{status} </if> <if test="startDate != null"> AND d.create_time &gt;= #{startDate} </if> <if test="endDate != null"> AND d.create_time &lt;= #{endDate} </if> </where> ORDER BY d.create_time DESC </select>

这里面最核心的就是<where>加<if>的组合。<where>标签会自动处理一个聪明逻辑:如果所有条件都不生效,它不会输出多余的WHERE;如果第一个条件生效,它会自动去掉多余的AND前缀。这样无论用户从哪个条件下手查询,生成的SQL都是合法的。写动态SQL的时候别忘了空白字符问题,比如你写完AND后面直接换行,拼接时两个关键字挤到一起就报错了,格式化规范一点能少很多低级的调试时间。

分页方案我用的是PageHelper插件。集成很简单,引入依赖后,在Mapper方法前写一行PageHelper.startPage(pageNum, pageSize),下一个查询就会自动加LIMIT,返回的数据用PageInfo包装。我以前见过有人自己在SQL里拼LIMIT #{offset}, #{pageSize}的,不是不行,但每次都手动算offset很烦,而且PageHelper还能顺带统计总数,省事不少。有一点要提醒:PageHelper.startPage只对紧随其后的第一条查询生效,你在Service层调用时要确保这条查询就是你要分页的那条,中间别插别的查询,否则分页会作用到错误的SQL上。

3.3 文件上传与预览:后端要处理好这两条链路

档案系统绕不开文件上传。后端接收MultipartFile的逻辑不复杂,Controller里大概是这样:

@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file, @RequestParam("categoryId") Long categoryId, @RequestParam("title") String title) { if (file.isEmpty()) { return Result.error("上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); // 校验扩展名白名单 if (!allowExtSet.contains(ext)) { return Result.error("不支持的文件类型"); } String datePath = new SimpleDateFormat("yyyy/MM").format(new Date()); String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; String relativePath = "/uploads/" + datePath + "/" + fileName; String fullPath = uploadRootPath + relativePath; File dest = new File(fullPath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); // 再写一条档案记录到数据库... }

文件重命名这步是很多教程没细说但实践里又特别重要的点。我用的UUID随机名,防止用户上传同名文件时互相覆盖,同时也能避免文件名里的特殊字符在各种依赖环节出问题。扩展名过滤必须有,我这边限定pdf、jpg、png、doc、docx、xlsx、zip这几种,并把允许的扩展名放在配置项里,方便后续调整。如果不过滤扩展名,一个HTML文件传上去再被直接访问,就可能出现安全风险。

预览这块,PDF和图片是最好处理的。前端用<iframe>或者object标签指向文件访问接口,浏览器自带渲染能力;doc/xlsx这类Office文件没法直接预览,比较务实的方案是让用户下载后打开,或者引入在线预览服务转换为HTML。如果项目预算为零,我建议先把下载功能做好,预览能力不强行上。非要线上转码,要引入OpenOffice或LibreOffice服务进行文档转换,部署复杂度直接上升一个台阶,不值当。

3.4 用户与权限:RBAC模型的落地实现

内部管理系统和公开网站的最大区别就是权限要求严格。档案系统里,普通员工只能看到公开和自己创建的档案,部门主管能看到部门内的,管理员才能看全部并管理系统配置。我用的是最常见的RBAC模型(用户-角色-权限),后端基于SpringMVC拦截器实现接口级别的权限控制。

具体落地方式是:登录成功后生成一个JWT令牌返回前端,前端每次请求在请求头里带上Authorization: token。后端自定义一个拦截器,在preHandle里解析令牌、提取用户ID,并从Redis或数据库查用户的权限标识列表。再按请求路径做匹配,没权限的直接返回403。路径匹配我用的是Spring的AntPathMatcher,比如配置里写/api/admin/**需要admin角色,/api/archive/add需要archive:add权限。权限标识放在sys_menu表中,这个表同时承载前端菜单的渲染,一套数据两处使用。有一说一,这种权限方案应付几百人的内部系统绰绰有余,不用上Spring Security。

权限这块特别容易出的问题是:前端把按钮藏起来就以为安全了。大错特错。恶意用户完全可以绕过前端直接调接口,所以后端每个重要接口都必须做权限校验。我在实际中踩过一次:文件下载接口忘了加校验,本来应该只有有权限的人才能下载的合同附件,任何人知道URL路径就能拉走。后来给下载接口也加上了鉴权拦截,才把这洞补上。你的后端点位一定要记得覆盖查询、下载、导出这类平时觉得不起眼的方法。

4. 前端实现:档案检索、上传与权限控制的完整链路

后端接口设计好了,前端的工作就是让这些能力真正变得"可用"。档案管理系统的前端页面数量不多,但每一块的交互都不算简单。我按实际开发顺序讲:先是登录和路由权限控制,这是入口;然后是多条件检索表格,这是日常使用频率最高的页面;再是上传表单和文件预览,这是业务闭环的起点和终点。

4.1 登录状态与路由守卫:为什么刷新页面不会丢登录态

登录流程是这样的:用户在登录页输入账号密码,前端用Axios发POST到/api/login,后端验证成功后返回一个JWT令牌和用户基本信息。前端拿到令牌存两处:localStorage存一份(用于刷新后恢复登录)、Vuex/Pinia内存中存一份(用于组件内快速读取用户信息)。Axios实例在请求拦截器里统一从localStorage取token,塞到请求头里。

路由守卫负责拦住未登录的访问。Vue Router的beforeEach全局前置守卫里判断:如果要跳转的页面不在免登录白名单里(比如登录页、404页),就去check本地有没有token,没有就直接next('/login')。有token,但刷新后Vuex里的用户信息被清空了,就调用getUserInfo接口重新拉一次用户信息和权限标识,拉完之后把路由动态加载出来。

动态路由这套机制我第一次写的时候卡了很久,原理上其实就是:登录成功后,后端返回当前用户能访问的菜单列表,前端用router.addRoute()动态添加这些路由。这样做的意义是,不同角色登录后看到的菜单天然不一样,普通员工进不来管理员页面。需要注意的一点是,退出登录时要把动态添加的路由全部重置掉,否则下一个用户登录进来会发现上一任的路由还在。常规做法是把createRouter封成一个函数,每次退出登录重新createRouter替换实例。

4.2 检索表格重点:请求参数与状态同步

档案列表页是整套系统里最"重"的页面。筛选区(档案编号、标题、分类、密级、状态、日期范围)加上表格区加上分页器,构成一个经典的三段式布局。核心交互逻辑是:任何筛选条件变化或页码变化,都会触发重新请求接口;表格数据、总条数、当前页码用响应式状态管理起来。

这里我说一个我写得很顺手的做法:把查询参数收敛成一个对象,比如:

data() { return { queryParams: { docCode: '', title: '', categoryId: undefined, secretLevel: undefined, status: undefined, startDate: '', endDate: '', pageNum: 1, pageSize: 10 }, tableData: [], total: 0 } } methods: { handleQuery() { this.queryParams.pageNum = 1; // 搜索时回到第一页 this.fetchList(); }, handleReset() { this.queryParams = { docCode: '', title: '', categoryId: undefined, secretLevel: undefined, status: undefined, startDate: '', endDate: '', pageNum: 1, pageSize: 10 }; this.fetchList(); }, fetchList() { getArchiveList(this.queryParams).then(res => { this.tableData = res.data.list; this.total = res.data.total; }); } }

有个小细节特别容易忽略:点击搜索按钮时,一定要先把pageNum重置成1。不然用户在第5页输入关键词搜索,接口返回的结果集是按第5页偏移量算的,第一页的数据反而看不到,看起来就像搜索失效了。这类交互问题在各种管理系统里都非常普遍,动手写的时候养成"所有查询操作都回第一页"的肌肉记忆能省很多事。

表格列里我加了状态标签渲染,用el-tag映射状态码:在库是绿色、借出是橙色、销毁是灰色。密级列单独加强显示,机密级用红色标签,让用户扫一眼就能识别高敏感档案。操作列放查看、编辑、借出、删除四个按钮,具体显示哪些根据按钮级权限控制指令v-permission判断,这个指令我后面会讲。

4.3 文件上传与预览:处理组件坑、弹窗刷新问题

上传档案弹窗是新增入口,对话框里套一个el-form,表单里包含档案标题、分类选择器和上传组件。上传我用Element UI的el-upload组件,设置:action="uploadUrl"、:headers="uploadHeaders",其中uploadHeaders是从本地取token拼的。组件的on-success回调里拿到后端返回的档案数据,把生成的docCode、filePath、fileSize这些信息回填到表单隐藏字段中,最后点“确定”时提交的是完整数据。

有一个地方是Element UI上传组件的老坑:el-upload默认用自己封装的方式发请求,如果你在Axios拦截器里统一处理了请求错误,上传失败时是不会走你的拦截器的。所以上传组件要单独设on-error回调,在里面this.$message.error('上传失败'),不然用户上传失败页面毫无反应,很懵。另一个问题是文件数量,需要limit限制、:on-exceed钩子提示"仅能上传一个文件"。这些边界小交互,虽然不影响核心功能,但直接决定使用体验,内部系统最终面向的是非技术用户,细节尤其重要。

文件预览我用的是新窗口打开方式:点击"预览"按钮,window.open一个新标签页,地址是/preview?filePath=xxx&token=xxx,这个页面是个很薄的Vue页面,里面放个<iframe :src="fileUrl">。因为浏览器不能直接访问需要鉴权的文件接口(没法自定义header),所以预览页里先用token换一个带有效期的临时访问地址(后端生成一个短时效的签名URL),再塞给iframe。这个小方案虽然不是最优雅的,但完全可行,也绕开了"blob下载后预览内存爆掉"的坑。

5. 前端权限指令与公共组件的封装

做内部管理系统,前端代码的复用性和一致性也很重要。档案系统页面之间相似度很高,如果把公共逻辑封装成组件和指令,开发后半段会非常省力。这章节讲两个我自己觉得价值最高的封装:一个是按钮级权限指令,一个是通用分页查询组合。

5.1 自定义指令实现按钮级权限

后端把权限标识返回给前端之后,前端除了控制路由,还要控制按钮显隐。我的做法是注册一个全局自定义指令v-permission,用法是:

<el-button v-permission="'archive:add'" type="primary">新增档案</el-button>

指令的inserted钩子里从Vuex读取当前用户的权限列表,如果发现传入的权限标识不在列表里,直接el.parentNode.removeChild(el)把这个按钮从DOM里移除。注册指令的代码放permission.js文件里,在main.js里全局引入:

Vue.directive('permission', { inserted(el, binding) { const requiredPermission = binding.value; const userPermissions = store.state.user.permissions; if (!userPermissions.includes(requiredPermission)) { el.parentNode && el.parentNode.removeChild(el); } } });

用removeChild而不是v-if判断,好处是业务代码里不需要为每个按钮都写一段权限判断逻辑,模板干净得多。坏处没那么明显,但有一个要注意:如果父节点用v-if动态渲染,inserted时机可能晚于组件生命周期,某些极端情况下按钮移除后组件内部还引用着它,会报一个空节点警告,这时候改用mounted钩子更稳。总体而言,指令方式在内部系统里够用,不用上复杂的权限组件库。

5.2 通用分页查询逻辑:减少重复代码

列表页不止档案列表一个,用户管理、借阅记录管理同样需要分页查询。我把"查询条件 + 分页 + 请求接口"的逻辑用混入(mixin)抽出来了一份:

export default { data() { return { queryParams: { pageNum: 1, pageSize: 10 }, tableData: [], total: 0, loading: false } }, methods: { fetchList() { this.loading = true; const apiFunc = this.listApi; apiFunc(this.queryParams).then(res => { this.tableData = res.data.list; this.total = res.data.total; }).finally(() => { this.loading = false; }); }, handleQuery() { this.queryParams.pageNum = 1; this.fetchList(); }, handleReset() { this.resetQueryParams(); this.fetchList(); }, handleSizeChange(size) { this.queryParams.pageSize = size; this.fetchList(); }, handleCurrentChange(page) { this.queryParams.pageNum = page; this.fetchList(); } } }

各个页面使用时,只需要在组件里定义各自的listApi方法和resetQueryParams方法,再覆盖必要的业务钩子即可。借阅记录列表甚至可以直接复用,只额外加一个"归还"操作按钮。这个封装让整个前端项目的列表页代码量减少了大概三分之一。但要提醒一点:mixin的data在多个组件中会被独立实例化,不会出现数据串扰,这里可以放心。复杂项目里mixin的可读性受人诟病,但这种两三层的小项目里,mixin是性价比最高的复用方案。

5.3 Axios拦截器的设计:统一错误处理的边界

Axios拦截器我分两层写。请求拦截器统一加token、设置超时时间;响应拦截器做三件事:第一,判断HTTP状态码,401代表令牌过期,清掉登录态跳登录页;第二,解包业务码,后端接口返回统一格式{code: 0, data: ..., msg: '...'},如果code不是0,弹Message.error提示;第三,网络错误统一提示"网络异常,请稍后重试"。

这里有一个设计边界值得思考:哪些错误在拦截器里提示,哪些应该在页面里单独处理?我的经验是,列表查询失败这种通用错误就在拦截器统一弹;而像文件上传失败这种业务性强的,在on-error里单独处理。如果所有错误都在拦截器里统一拦截,会遇到错误提示不够精确的问题,比如"当前用户没有下载权限"和"网络错误"弹一样的提示,用户会一头雾水。所以我在拦截器里遇到code非0时会先看看响应数据里有没有msg字段,有就弹msg,没有才弹通用提示。这样后端在接口里可以精确返回"文件已被借出"这类具体信息,前端不用写大量if判断。

6. 联调、部署与生产环境:从能在本机跑通到真正可用

代码写得再漂亮,联调和部署阶段才是真正掉头发的地方。我在这个项目上遇到的奇葩问题不少,挑几个有代表性并且以后你再做也会碰到的来讲。

6.1 前后端联调最常见的问题:跨域与请求路径

本机开发时前端运行在9528端口(Vue CLI默认),后端在8080端口,跨域问题基本躲不掉。后端解决方式是在配置里加一个CorsFilter:

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

用addAllowedOriginPattern("*")而不是addAllowedOrigin("*"),是因为后者在allowCredentials(true)时会被浏览器拒绝。这里的*表示允许全部来源,如果考虑到安全,可以收敛成前端的具体地址,但内部系统开发阶段放宽一些能减少沟通成本,上线前再收紧。

联调阶段另一个坑是请求路径对不上。后端Controller定义的路径是/api/archive/list,前端Axios的baseURL设成http://localhost:8080,但请求函数里写的是/archive/list,结果永远404。我后来统一约定:Axios的baseURL不带前缀,所有业务请求在各自API文件里写全路径/api/...,后端全部接口都以/api开头,这样两边的心智一致,排查问题一看URL就知道是谁的问题。

6.2 打包部署到Linux:Nginx与Jar包的配合

开发完要上线,我用的是最经典也最好维护的部署方式:后端打Jar包直接用systemd托管,前端打包成静态文件交给Nginx。

后端打包命令mvn clean package -DskipTests,打出来的jar放到服务器的/opt/archive-system/目录。用systemd服务托管的好处是启动即自动拉起、崩溃自动重启、日志统一由journald管理。服务配置大致是:

[Unit] Description=Archive System Backend After=network.target [Service] User=archive WorkingDirectory=/opt/archive-system ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar archive-system.jar --spring.profiles.active=prod Restart=always RestartSec=10 [Install] WantedBy=multi-user.target

--spring.profiles.active=prod表示加载application-prod.yml里的生产配置,这里会把数据库地址、文件上传根路径换成正式环境的值。内存参数给了512M到1G,这种内部系统足够了,不用一上来就2G。

前端打包:npm run build生成dist目录,把dist目录上传到服务器/var/www/archive,然后配Nginx。Nginx配置有两个关键点,一个是静态文件服务,另一个是代理后端请求:

server { listen 80; server_name archive.example.com; location / { root /var/www/archive; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /uploads/ { alias /data/archive-uploads/; } }

try_files $uri $uri/ /index.html这个配置对Vue Router的history模式是必须的,否则你直接访问/archive/list这种路由时Nginx会去找对应文件然后报404。有/uploads/这个location是为了让上传的文件能通过HTTP直接被浏览器访问,alias指向服务器存储目录。生产环境如果你的系统在Tomcat前面,还会涉及session cookie等问题,但我们用JWT令牌,完全无状态,跨域、Cookie这些问题都不存在。

6.3 生产环境还要想的几件事:备份、日志和文件系统

部署完不代表事情就完了。档案系统里都是重要数据,备份策略绝不能少。我做了三层备份安排:

  1. 每日凌晨用mysqldump全量备份数据库,保留最近14天的备份文件;
  2. 上传文件夹/data/archive-uploads用rsync同步到另一台机器或另一块磁盘;
  3. 每周把数据库备份文件打包加密后异地存放。

mysqldump的定时任务写法很简单,crontab里配一行:

0 2 * * * mysqldump -u backup_user -p'xxx' --single-transaction archive_db > /backup/archive_$(date +\%Y\%m\%d).sql

--single-transaction参数很重要,它能在备份时不锁表,避免白天业务还在写入时备份导致数据不一致。数据库密码不要直接写在crontab里,可以封装成脚本调用配置文件,这点是安全习惯。

日志方面,SpringBoot自带logback,我配置了按天滚动和大小切割,避免单个日志文件无限增长。生产环境排查问题时,journalctl -u archive-system和tail -f /var/log/archive/error.log两条命令是最高频的操作。有一次客户反馈导入档案很慢,查日志发现是一个查询没有走索引,全表扫描几万条数据,后来在category_id和create_time上加了联合索引就好很多。所以生产环境观察慢SQL日志很重要,MySQL的slow_query_log也建议开起来。

6.4 几个部署后的细节优化:缓存、压缩和文件路径

上线一段时间后,我做了几个小而关键的优化。第一个是前端放Nginx后开gzip压缩,静态资源体积差不多小了60%,内网环境体感不明显,外网访问差距很大。配置是:

gzip on; gzip_min_length 1k; gzip_types text/plain text/css application/javascript application/json image/svg+xml;

第二个是后端接口加了一层简单的SpringCache缓存,把档案分类树这种几乎不变的数据缓存起来,减少数据库重复查询。缓存时间设置了30分钟,因为分类可能偶尔调整,时间太长用户看不到变化会误解。

第三个是文件访问路径的优化。最初我把上传路径和前端静态资源路径耦合在一起,后来发现一旦磁盘满了想迁移文件会很痛苦。于是把所有上传文件挪到了独立的/data/archive-uploads目录,Nginx的alias指向它,数据库里的相对路径不用动,迁移时只要同步新目录再改Nginx配置就行。这个调整让我免去了一次数据迁移的灾难——有一次系统盘空间不足,直接把整个上传目录转移到数据盘,业务几乎无感。

7. 梳理几个我这次开发中最想重点强调的"坑"

整个项目从零开始到上线,前后花了差不多三周的空余时间。如果要我总结五个最值得告诫后来者的经验,我会选下面这些,它们全部来自真实踩坑记录,每一条都付出了调试时间的代价。

7.1 数据库时间字段精度不一致导致数据"消失"

开发时本地MySQL是8.0,生产环境是5.7。本地新增档案的时间戳保存的是毫秒精度,而生产环境表字段是datetime(秒精度),导致查询"创建范围"时边界数据总差几秒查不出来。后来统一把时间字段改成datetime(3),并在实体类的LocalDateTime上注好格式化,问题彻底解决。这个坑很小,但排查过程很费劲,因为大部分时间都花在核对"为什么这条记录查不到"上,最后发现是时间精度问题,哭笑不得。

7.2 MyBatis的resultType与resultMap用混导致字段映射缺失

早期图省事,关联查询直接写resultType="com.example.ArchiveDocVO"。单表查询没问题,但多表联查时,categoryName这个字段时而能查出来,时而查不出来。后来才意识到,MyBatis开启驼峰命名映射只对单表有效,多表查询中如果两个表都有id字段,MySQL返回的id列在后端映射时会被同一个属性接收,结果完全错乱。解决办法是用resultMap显式指定每一列的映射关系,并为多表重复字段起别名。

7.3 前端表单校验时机:不要只在提交时校验

档案系统的分类选择用的是el-cascader(级联选择器),如果不填就提交,后端会收到一个null,SQL里category_id = null会导致查询结果为空而不是报错,看起来像"数据被删了"。后来我在提交前统一做validate校验,并在el-form-item的rules里配好必填规则。更重要的一点是:校验不通过时应该滚动到第一个错误字段,这个交互在小屏幕上尤其关键,不然用户不知道到底哪里填错了。

7.4 文件上传大小限制与超时设置

SpringBoot默认上传大小限制是1MB,而档案系统的扫描件经常是几十MB。这个默认值没改的话,上传大文件会一直报MaxUploadSizeExceededException。我在application.yml里加:

spring: servlet: multipart: max-file-size: 200MB max-request-size: 200MB

同时Nginx的client_max_body_size也要对应调大,不然请求到Nginx这层就被拒了。这个坑在前后端联调时看起来像是"按钮点了没反应",实际上错误信息被异常处理器吞掉了,花了不少时间才从后端日志里翻出真正错误。

7.5 数据库连接池别用默认值

SpringBoot默认的HikariCP连接池配置对于本机开发够用,但生产环境并发一上来就出现连接池耗尽的问题。报错是Connection is not available, request timed out after 30000ms。我的调整是把最大连接数改成50,空闲连接超时改成5分钟,连接最大存活时间改成20分钟。数据库的max_connections也要对应调整,不然连接池配了50但MySQL只允许100,两个服务一跑就超限了。

8. 写在最后:这套项目的扩展思路与源码的价值

开发完这套档案管理系统之后,我最大的感受是:它不是一个"做完就扔"的练习项目,而是一个高度可复用的系统骨架。档案、合同、证照、图书、固定资产——凡是"登记信息+上传文件+权限管控+借还记录"模式的业务,在这套系统上改改字段就能用。我后来在另一个场景下接新需求时,直接复用了一套用户权限模块、文件上传模块和列表查询模块,花了两天就完成了新功能开发,省下的时间非常可观。

再分享一个我在做这类项目时养成的习惯:数据库设计阶段多花半小时画出表和表之间的关系,写代码阶段能少走两小时弯路。档案表和借阅记录表之间的外键关系、状态流转的约束,如果一开始没想清楚,后面大概率会代码改了三轮还有外联查询漏掉。第二个习惯是给每个接口写一个简短的Markdown说明文档,标明入参、出参、错误码含义。这个文档不仅是给同事看的,也是给自己一个月后维护时看的,否则你根本记不住当时为什么把这个接口设计成嵌套结构。

如果你准备自己动手写这个系统,我的建议很简单:不要直接去搜一堆源码复制粘贴,先按我说的方法把数据库表建好,再用Postman把后端接口逐个测通,最后再开始写前端页面。这比反复"前端写完才发现后端接口不好用"要高效得多。等你把这条链路完整跑通,SpringBoot、MyBatis、Vue这套全栈技术在你心里就不再是零散的知识点,而是一条清晰的业务主线。遇到任何模块问题,你都能顺着这条线定位到代码位置,这时候,你才算真正掌握了这个项目的底层逻辑。

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

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

立即咨询