
去年给一家文博机构搭线上历史馆藏系统我几乎没有犹豫就把技术栈定成了SpringBootVueMyBatisMySQL的前后端分离组合。项目交付之后回头看这个决定省了我大量不必要的麻烦——馆藏类系统本质上就是一个数据密集型后台管理系统它需要稳定的接口、灵活的检索、可控的权限而这一套恰好就是SpringBoot生态最擅长的范围。这篇文章不晒截图把需求拆分、库表设计、后端核心逻辑、前端页面、服务器部署还有几个印象深刻的坑完整走一遍。如果你正准备做毕业设计或者第一次上手前后端分离项目这个案例可以直接跟着做如果你想在自己服务器上快速跑一个能用的历史馆藏系统部署教程部分可以单独抄作业。1. 项目从Excel到在线化馆藏系统到底要解决什么问题1.1 真实使用场景中的三个痛点接手这个项目之前对方的馆藏管理工作基本靠Excel加共享文件夹。表面看流程能跑实际上问题已经攒了一堆。第一个痛点是数据一致性问题。Excel文件放在共享目录里几个录入员同时打开谁后保存谁覆盖。遇到过录入员A花一下午整理了一批新入藏文物编号结果录入员B随手关掉弹窗整批数据没了。这种事故发生后复盘都找不到责任人因为文件最后保存时间可能都变了。第二个痛点是资产状态不透明。文物不是一直在库房里放着会有借展、修复、盘点这些流转动作。在Excel体系里状态靠一个备注列手写借出去的文物忘记改备注是家常便饭。领导问那批唐代瓷器现在在哪个馆巡展往往要打电话发消息问一圈才能确认。第三个痛点是检索效率太低。按名称、朝代、材质、编号筛选在几千行Excel里还能勉强用筛选功能到了上万条记录就非常吃力。更别说封面照片和条目之间只能靠文件名对应图片一多就乱。这三个痛点其实代表了所有中小型资产管理系统的通病。所以做线上化改造时核心目标不是把Excel搬到网页上而是通过独立的数据库表、状态字段、检索接口让数据的准确性、实时性、可追溯性一次到位。1.2 功能模块拆分不做大而全只做核心闭环需求梳理会上对方提了很多想法比如3D文物展示、VR展厅、在线文物修复流程审批听起来都很炫。但我最后把第一期范围收在了六个模块理由很直接先把资产账本管清楚展示类功能后续随时可以往上加而数据基础不牢什么上层功能都是空中楼阁。一期落地的模块是这样划分的登录与权限管理员、录入员、访客三种角色。访客只能浏览和检索录入员可以新增编辑文物资料管理员额外拥有用户管理和借展审批权限。藏品台账管理文物基础信息的新增、修改、删除、图片上传、详情查看。这是整个系统的核心。分类与检索按文物类别、朝代、材质、状态做多条件组合查询支持分页。借展出入库管理登记借出、归还记录借出后藏品状态自动变为外借归还后恢复在库。统计面板按朝代、类别、状态统计藏品数量用柱状图和饼图呈现。操作日志记录谁在什么时间改了哪条数据便于追溯。这个范围对一期来说足够形成业务闭环。权限控制保证数据安全台账管理解决数据一致性问题检索解决效率问题借展记录解决资产透明问题日志解决追溯问题。后面每一个用户能在系统里完成的工作都对应一个明确痛点。2. 技术选型复盘SpringBootVueMyBatisMySQL为什么是最省心的组合2.1 后端SpringBootMyBatis的匹配逻辑我把后端定为SpringBoot理由不是因为它最新最火而是它在中小型项目中能极大压缩配置成本。SpringBoot自带内嵌Tomcat打包后一个jar直接跑不用单独装外部容器starter依赖体系把常用组件的兼容性提前处理好pom文件里加依赖就能用。持久层选MyBatis而不是JPA当时是认真对比过的。JPA确实写CRUD很快实体类一标注解就能自动生成SQL但麻烦也在这——一旦涉及到多条件动态检索、复杂的统计SQL自动生成的SQL要么没法用要么得写JPQL还得调方言。馆藏系统里按朝代类别材质组合筛选这种需求太常见了SQL的条件数量是动态的这正是MyBatis动态SQL的强项。XML里写where加if标签条件有就拼进去没有就自动跳过逻辑清楚效果直观。更重要的一点是SQL可控性。MyBatis里每条SQL都是自己写的性能情况心里有数。JPA在数据量上来之后经常出现莫名其妙的多表关联查询排查起来很痛苦。而MyBatis的SQL可以直接在Navicat里跑通再贴进XML每一步都可控。2.2 前端VueElementUI的工程化优势前端选Vue是顺理成章的。后台管理系统的页面结构高度相似左侧菜单、顶部栏、内容区表格表单。Vue的组件化开发模式非常适合这类页面一个藏品列表页可以拆成搜索组件、表格组件、分页组件、弹窗表单组件各自维护状态互不干扰。我用的UI库是ElementUI它对后台场景的覆盖非常完整。表格有自带的分页、排序、列宽拖动表单有校验规则上传组件直接支持图片预览省掉大量手写样式和交互逻辑的时间。页面大概的布局用栅格系统排一下标签页、面包屑、对话框都是现成的。组件选型的另一个考虑是文档和社区。ElementUI的中文文档很全遇到问题百度或者查Issue基本都能解决。对做项目赶工期的人来说这不是小事——一个组件要花半天研究用法项目节奏就全乱了。2.3 数据库MySQL 8.0在中小型馆藏项目里的定位数据库没有悬念选了MySQL 8.0。从部署难度、稳定性、团队熟悉程度几个维度看MySQL在中小型系统里依然是最稳的选择。馆藏项目的数据库性能要求并不苛刻一般地市级文博机构几千件到几万件文物这个量级里MySQL完全不是瓶颈。真正让我决定用8.0版本而不是停留在5.7的是几个实在的优势utf8mb4默认字符集支持所有文字符号遇到生僻字、特殊符号不会乱码JSON字段类型可以做灵活的扩展属性窗口函数对统计报表查询非常方便。比如统计每个朝代的藏品数量用ROW_NUMBER()窗口函数可以很轻松地做排名这在旧版本里要写子查询绕来绕去。有一点要提醒网上很多教程还在用MySQL 5.7的配置习惯如果你直接用8.0注意认证插件是caching_sha2_password老版本的客户端驱动可能连不上。服务器上安装时选择默认认证方式后端驱动用最新版JDBC就不会踩这个坑。3. 数据库设计与核心表结构文物台账的数据骨架3.1 六张核心表的字段规划数据库设计是这类型项目里最值得花时间的部分。表结构定好了后面接口和页面都是顺着字段长出来的。当时设计了六张表核心关系不算复杂但每个字段都对着业务场景抠过。先看用户表t_user字段类型说明idbigint主键自增usernamevarchar(50)登录名唯一索引passwordvarchar(100)密码加密存储nicknamevarchar(50)显示名称rolevarchar(20)角色admin/editor/viewerstatustinyint1启用 0禁用create_timedatetime创建时间密码存储没有用MD5MD5加盐容易被彩虹表撞库我用的是Spring Security里的BCryptPasswordEncoder每次生成的哈希都不一样安全性高不少。其次是藏品分类表t_category字段很简单id、parent_id、category_name、sort。parent_id是为了支持两级分类比如瓷器下面可以有青花瓷粉彩瓷。查询的时候用递归或者程序组装成树形结构。核心的藏品表t_collection字段最多这里只列关键部分字段类型说明collection_novarchar(50)文物编号唯一索引namevarchar(100)藏品名称category_idbigint分类ID逻辑外键dynastyvarchar(50)朝代如唐代、宋代materialvarchar(50)材质size_descvarchar(200)尺寸描述weightdecimal(10,2)重量克sourcevarchar(200)来源statustinyint1在库 0外借 2修复中cover_imagevarchar(200)封面图片路径descriptiontext详细介绍operator_idbigint最后操作人IDcreate_timedatetime创建时间update_timedatetime更新时间collection_no加唯一索引是我特别坚持的。文物编号相当于身份证号录入时有重号系统直接报错比事后去重效率高得多。cover_image字段存的是相对路径而不是Base64图片内容原因后面部署章节会说。借展记录表t_lend_record用来追踪文物去向collection_id、lend_org借展单位、lender借展联系人、borrow_date借出日期、return_date预计归还、back_date实际归还、status、remark。注意return_date和back_date是两个字段一个代表计划一个代表实际不能混用不然考核的时候说不清楚。最后一张操作日志表t_operation_log记录了用户ID、操作动作新增/修改/删除/借出/归还、目标资源ID、详情描述、操作时间。用AOP统一记录这个表在排查数据问题时能起大作用。3.2 索引、外键与字符集设计阶段的三个决策建表时我在索引上做了三件事。第一是collection_no唯一索引前面说过防重号。第二是(category_id, status)组合索引因为列表页最常见的筛选场景就是某个分类下有哪些在库藏品这个组合索引能直接命中。第三是create_time普通索引统计面板按时间维度分析数据时会用到。关于外键我刻意没有建物理外键只保留逻辑外键。这个决策可能会被学校里的数据库课程扣分但实际维护过项目的人都懂物理外键在删除数据、批量导入、分库分表时会带来一堆麻烦。比如要批量导入一批历史数据如果分类表还没导完带外键约束的藏品表根本插不进去。逻辑外键靠程序保证一致性配合事务可以满足这个项目的需求。字符集统一用utf8mb4_general_ci。utf8mb4不是默认的utf8它扩展了4字节字符的支持生僻字、emoji都能存。我特别用一件包含特殊符号的藏品测过乱码问题直接没了。排序规则选general_ci是为了处理中文时不区分大小写比如按名称排序时张大千和张大千的同名不同字情况不会出乱子。时间类型全部用datetime统一记录到秒。程序传值时用Java 8的LocalDateTime在MyBatis里加一个类型处理器映射存进MySQL就是标准的yyyy-MM-dd HH:mm:ss。之前见过有人用timestamp结果2038年问题且时区转换容易出错这里不推荐。4. 后端接口与业务逻辑从登录鉴权到藏品流转4.1 统一返回体、跨域与JWT拦截器后端接口设计第一步是定统一返回格式。所有Controller返回的数据都包一层ResultT结构是{code: 200, message: success, data: ...}。这么做的好处很实在前端axios响应拦截器里只需要判断一次code200走正常逻辑401跳登录页500弹出错误提示不用每个接口单独处理异常结构。所有异常统一用RestControllerAdvice捕获业务异常抛BusinessException全局处理器转成对应错误码。登录鉴权用的是JWT方案。用户登录成功后后端签发一个token返回给前端token里面带用户ID和角色。之后前端每次请求都在Authorization头里带上这个token后端写一个拦截器统一校验。拦截器里有一个细节值得说JWT解析出来用户信息后我没有直接塞进HttpServletRequest里拿而是用ThreadLocal来传递。因为写业务代码时需要频繁获取当前登录用户写request.getAttribute(loginUser)太啰嗦。定义一个UserContext工具类拦截器里解析完就UserContext.set(user)业务代码里直接UserContext.get().getId()请求结束在拦截器的afterCompletion里调用UserContext.clear()防止线程池复用导致数据串号。跨域问题开发阶段就碰到了。前后端分离后前端跑在http://localhost:8081后端跑在http://localhost:8080浏览器会拦截跨域请求。我写了CORS配置类允许指定来源访问并放行OPTIONS预检请求。到了生产环境前端和后端用Nginx反代到同一个域名下通过路径区分跨域问题自然消失这是后话了。4.2 MyBatis动态SQL实现多条件检索馆藏检索是系统的核心能力也是MyBatis动态SQL最典型的应用场景。前端藏品列表页有一排筛选条件编号、名称、朝代、分类、材质、状态。用户填几个就查几个一个都不填就是全量分页查询。Mapper XML里我这么写核心的检索SQLselect idselectCollectionList resultTypecom.example.entity.Collection select * from t_collection where if testcollectionNo ! null and collectionNo ! and collection_no like concat(%, #{collectionNo}, %) /if if testname ! null and name ! and name like concat(%, #{name}, %) /if if testdynasty ! null and dynasty ! and dynasty #{dynasty} /if if testcategoryId ! null and category_id #{categoryId} /if if teststatus ! null and status #{status} /if /where order by create_time desc /selectwhere标签会自动处理掉第一个条件前面的and这是最省心的写法。所有参数值都用#{}预编译传参可以防SQL注入。注意这里我在做模糊查询时没用${}拼%而是用MySQL的concat函数拼这样既安全又兼容。分页我用了PageHelper插件。用法很简单查询前写一句PageHelper.startPage(pageNum, pageSize)紧接着的查询会自动拼接limit语句返回的PageInfo里直接带着总条数和页数。注意这个静态方法只对下一句查询生效如果中间插了别的查询分页就错位了这是一个很容易踩的坑。很多面试题爱问MyBatis一级缓存二级缓存实际项目里我反而默认关掉了二级缓存。馆藏数据实时性要求高管理员刚改了一条记录立刻查到旧数据没法接受。而一级缓存作用范围只在同一个SqlSession里Spring管理下每次请求都是新会话基本用不上。SQL性能靠索引解决缓存不填这个乱。4.3 借展出入库的事务处理借展模块涉及两张表的联动操作新增一条借展记录同时把藏品状态改成外借。如果先插记录再改状态中间任何一步失败都会留下中间状态的数据。这里必须加Transactional事务注解。我把借出逻辑写成两个步骤先插入t_lend_record再更新t_collection的status字段为0。事务保证要么两步全成功要么全回滚。更稳妥的做法是接口里先校验藏品当前状态——只有在库的才能借出否则直接抛业务异常。归还是反向操作更新借展记录的back_date为当前日期、status改为已归还再把藏品状态改回1。归还时还要判断是否逾期逾期就在remark里加一条提示。这些逻辑放在LendService里接口只做参数校验业务复杂度都收敛在Service层。回滚有一个坑必须注意Transactional默认只在遇到RuntimeException时回滚如果方法里捕获了异常没抛出去事务是不会回滚的。所以Service里处理业务异常要么直接抛出要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()否则数据就悄悄错了。5. Vue前端实现列表、表单、图片上传与路由守卫5.1 工程初始化与axios封装前端我用vue create初始化项目选上Vue Router和Vuex这两个插件。装依赖这里有个容易卡住的点ElementUI的安装命令要加-S不然组件引入了但不在运行依赖里打包后样式全丢。axios封装成src/utils/request.js这段代码建议直接抄import axios from axios import { Message } from element-ui import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) request.interceptors.response.use( response { const res response.data if (res.code 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(未登录)) } if (res.code ! 200) { Message.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error { Message.error(error.message) return Promise.reject(error) } ) export default requestbaseURL设为/api是有讲究的。开发环境用vue.config.js里的devServer.proxy把/api转发到后端地址生产环境用Nginx做一样的转发。前端代码里始终只写相对路径切换环境不用改代码只需要改代理配置。这是前后端分离项目里很实用的约定。5.2 藏品管理页面的核心交互藏品列表页的交互模式是标准的三段式顶部搜索区、中间表格区、底部弹窗表单。搜索区的每个筛选控件绑定一个data属性点查询按钮时把这些参数传给后端接口表格重新加载。表格列里重点是cover_image字段的处理。图片不一定每件藏品都有我用el-image组件同时设置fitcover和插槽里的占位图。点击缩略图可以放大预览这是ElementUI自带功能preview-src-list传图片地址数组就生效。弹窗表单里的图片上传是单独处理的。el-upload设置action/api/collection/upload上传成功后端返回图片的相对路径前端把路径存进表单的coverImage字段。提交表单时图片路径和文字信息一起发给后端入库时写入t_collection表。表单校验规则里编号是必填且要求格式为字母数字组合用正则/^[A-Za-z]{2}\d{5}$/校验。名称、朝代必填重量必须是数字。ElementUI的rules属性加上prop绑定就能实现提交时自动校验不通过不会发请求。新增和编辑用了同一个弹窗组件区别只是初始数据是否为null。打开新增时清空表单打开编辑时用Object.assign回填数据。编辑提交时把id一起传过去后端根据id判断是插入还是更新。5.3 路由守卫和权限控制权限控制的实现分两层。第一层是路由级守卫在router.beforeEach里判断token是否存在不存在就重定向到登录页。第二层是角色控制在路由的meta里定义roles数组比如{ path: /collection/edit, component: CollectionEdit, meta: { roles: [admin, editor] } }守卫里取到当前用户的角色没有匹配就跳去403页面。游客角色能访问列表页和详情页但访问编辑页会被拦下来。按钮级的控制用自定义指令或v-if。我在列表页给新增编辑删除按钮加了v-ifisAdminOrEditor()的判断这个方法是根据后端返回的用户角色做布尔判断。有同学图省事只在前端藏起按钮其实做法不完整——后端的拦截器也必须校验接口权限前端隐藏只是体验优化后端拦截才是安全底线。6. 部署上线完整教程从本地jar包到Nginx反向代理6.1 MySQL初始化与环境检查部署前我先在服务器上装好三样东西JDK 8、MySQL 8.0、Nginx。JDK版本建议用8或11除非有特殊需求不要上更高原因后面专门讲。检查版本命令记一下java -version mysql --version nginx -v数据库初始化的完整流程是这样的把项目里的collection.sql上传到服务器执行mysql -u root -p collection.sql导入。SQL文件开头要有CREATE DATABASE IF NOT EXISTS springboot_collection DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后是USE语句这样一条命令就能把库表结构全部导好。导入后验证一下mysql -u root -p进控制台SHOW TABLES;看到六张表就算成功。再执行SELECT COUNT(*) FROM t_collection;确认表里有没有初始数据。建议SQL文件里面预置一个管理员账号用户名admin、密码在SQL里用BCrypt哈希值写死首次部署直接能登录。6.2 后端jar打包与启动后端打包前必须改好application.yml这里是最容易出错的地方。主要配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/springboot_collection?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password servlet: multipart: max-file-size: 10MB max-request-size: 50MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true upload: path: /data/collection/uploads/serverTimezoneAsia/Shanghai必须加不加的话连接MySQL 8.0会报时区错误。map-underscore-to-camel-case: true让数据库的create_time字段自动映射成Java的createTime这是我强烈建议开的配置省掉一大堆手动映射。上传路径我配了服务器上的绝对路径/data/collection/uploads/这个目录要提前创建好否则上传图片时报FileNotFoundException。打包命令很简单mvn clean package -DskipTests打包完成后target目录下会生成一个collection-system.jar。启动之前先确认端口没被占用然后nohup java -jar /opt/collection/collection-system.jar /data/logs/collection.log 21 nohup加让程序在后台运行日志重定向到文件里方便排查。启动后看一眼日志出现Started Application就说明起来了。这里不建议用java -jar直接跑一关终端进程就死了。如果服务器有systemd更规范的做法是写一个unit文件托管服务开机自启、异常重启都能自动处理项目级别推荐。6.3 前端打包与Nginx配置前端打包之前先把vue.config.js里的devServer.proxy确认好——这个只在本地开发用打包后的代码不会包含这个代理配置。然后执行npm run build生成的dist目录是整个静态站点。上传到服务器的/var/www/collection/目录接下来配置Nginx。Nginx配置是整个部署环节的关键我贴一份能直接用的server { listen 80; server_name your_domain_or_ip; root /var/www/collection/dist; index index.html; location / { 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/collection/uploads/; } }location /里的try_files可不是随便写的。Vue Router如果用了history模式前端路由在刷新时会向后端请求真实的URL路径比如/collection/1服务器上根本没有这个文件不配置try_files就会404。它的作用是请求路径匹配不到真实文件时统一回退到index.html由Vue Router接管页面。location /api/把接口请求转发到后端的8080端口这样浏览器里所有请求都走80端口不存在跨域问题。/uploads/映射到图片上传目录否则前端img标签访问封面图片会403。配置改完后nginx -t nginx -s reloadnginx -t检查语法通过后再reload千万不要不检查直接reload写错一个分号整个服务就挂了。6.4 部署后的联通检查部署完成后按这个顺序检查基本能定位所有问题先看后端日志有没有报错tail -f /data/logs/collection.log直接在后端服务器本地curl接口curl http://127.0.0.1:8080/api/collection/list?pageNum1pageSize10通了说明后端OK浏览器访问http://服务器IP能看到首页说明静态文件OK登录一次看Nginx的access.log确认/api/请求有没有到后端上传一张测试图片再用浏览器打开返回的图片URL确认/uploads/映射成功这套检查顺序的价值在于逐层缩小故障范围。后端没起来就不需要去看前端配置静态文件访问正常再怀疑接口转发一步一步来半小时以内肯定能定位问题。7. 开发中踩过的坑与排查思路7.1 刷新页面404前端路由history模式的经典坑这个问题出现的时机是第一次部署上线登录后随便点进一个藏品详情页按F5刷新页面直接白屏加404。原因前面提过history模式下浏览器把URL路径当成真实资源去请求了。排查思路其实很简单。打开Nginx错误日志看到一堆open() /var/www/collection/dist/collection/1 failed (2: No such file or directory)基本就是这个问题。当时第一反应是想在Vue Router里加base配置折腾半天没用。后来查资料才发现标准解法就是try_files $uri $uri/ /index.html;。这也解释了为什么开发环境一直没发现这个bug——Vue的devServer默认配置了history fallback任何路径都返回index.html问题被隐藏了只有部署到Nginx才会暴露。7.2 图片上传成功却访问不了第二个印象深刻的坑是图片上传。本地开发时图片上传后能正常显示部署到服务器后上传接口返回200但前端访问图片URL一直是404。逐层排查后发现问题出在上传路径上。本地开发时我配置的相对路径图片被存进了后端jar包所在目录的临时子目录里。服务器上每次重新部署都会覆盖临时目录重启后图片就全丢了。修复方案是配置外部独立目录upload.path: /data/collection/uploads/然后在Nginx加location /uploads/映射。再通过一个接口把数据库里的相对路径统一处理返回完整的URL前缀。这里特别提醒图片永远不要以二进制存入MySQL的Blob字段。数据库体积膨胀后备份和查询都会变慢而且图片这种大字段和结构化数据混在一起性能影响非常明显。文件存磁盘数据库只存路径是最标准的做法。7.3 版本选择的经验为什么没有追最新的SpringBoot 3最后聊一下版本选择。现在网上很多新项目直接上SpringBoot 3.x我也试过但最终还是把主力版本定在SpringBoot 2.7.x。原因有三点。第一是JDK版本门槛。SpringBoot 3要求JDK 17以上不少生产服务器还停留在JDK 8升级JDK本身就是一个有风险的动作涉及老项目兼容、运维脚本调整不是改个JAVA_HOME就完事。第二是依赖生态。SpringBoot 3把javax.*换成了jakarta.*MyBatis的starter和相关插件如果没跟上就会出现ClassNotFoundException。我刚上手时用的一个分页插件在SpringBoot 3下直接不能启动换成2.7.x马上就好。对业务项目来说稳定压倒一切。第三是MyBatis官方对SpringBoot 3的适配也是逐步完善的网上相关资料相对少。遇到问题搜出来的答案大部分还是2.x时代的做法参考价值会打折扣。这不是说SpringBoot 3不好而是做项目要分清追新技术和用成熟技术把业务落地的区别。馆藏系统这种偏传统的数据管理项目数据准确和运行稳定比技术栈版本新重要得多。如果做全新学习项目SpringBoot 3完全没问题如果是给客户交付系统选2.7.x配合成熟的MyBatis生态后期维护会省心很多。这套SpringBootVueMyBatisMySQL的组合恰好就是在够用、稳定、资料全这几个维度上达到了很好的平衡。