做历史馆藏管理这块的项目,最磨人的往往不是前端动画有多酷、并发能扛多高,而是先把业务理顺:一件藏品从进馆登记到盘点、借展、修复、再入库,中间到底要过多少道手。我见过不少博物馆、档案馆、学校院系还在用Excel甚至纸质台账管藏品,编号靠手敲,出库就签个登记本,等年底盘点或者要组织专题展的时候,翻记录翻到崩溃。这个基于SpringBoot+Vue的线上历史馆藏管理系统,就是冲着“台账数字化、流程在线化、数据可统计”这三个目标去的。整套后端用Java + MySQL + MyBatis,前端用Vue,数据库表结构、接口、页面全部跑通,属于可以直接落地的完整源码项目。
这篇博文我会从数据库设计、后端接口、前端页面到打包部署,完整拆一遍这套系统的实现思路。适合三类人看:一是要做博物馆、档案馆、校史馆类管理系统开发的技术同学,二是正在选型或写毕业设计、课设的人,三是想把手里的Excel藏品台账系统化的文博信息化工作人员。文章里除了关键代码,还会把我在实际开发中踩过的坑和校验逻辑放在最后,建议收藏。
1. 项目背景与整体设计思路
1.1 历史馆藏管理到底在管什么
先搞清楚业务对象,写代码才不会跑偏。历史馆藏不是普通库存,它管的可能是文物、档案文献、标本、老照片、荣誉奖牌,甚至古籍善本。这些物品有几个共同特点:属性字段特别杂、单件价值高、状态流转多、每一条动线都要可追溯。
拿一件青铜器举例:它入馆时要有来源(考古发掘、征集、捐赠还是移交)、年代、质地、尺寸、完残程度、等级;入馆之后存放位置要明确到库房某排某架某柜;某天它被借去外地展览,系统里要记录借出时间、经手人、归还时间;发现它有病害了,还要建修复档案,记录谁修的、怎么修的、用了什么材料。
所以这个系统的核心不是简单的增删改查,而是围绕“藏品生命周期”建模:入库、在库、出库、借展、修复、回库,每个状态变化都要留痕。我设计时把状态字段和流转记录表分开,主表只存当前状态,所有历史动作单独记流水,这样既能快速查询“这件东西现在在哪”,又能完整还原“它这些年都经历了什么”。这也是线上管理系统相比纸质台账最实质的升级。
1.2 技术选型:为什么是SpringBoot+Vue+MySQL+MyBatis
技术栈选这套组合,我承认一开始有“求稳”的成分,但细想下来确实是最不容易翻车的搭配。
后端用SpringBoot,理由不用多讲,现在Java后端项目的事实标准。需要特别说明的是版本:我选的是SpringBoot 2.7.x,不是3.x。SpringBoot 3虽然新,但要求JDK 17,很多单位、学校机房的部署环境还停留在JDK 8,为了一个毕业设计或内部系统去升级整个基础环境不划算。2.7.x稳定、资料多、兼容MyBatis和绝大多数中间件,足够撑起这种体量的管理系统。
持久层用MyBatis,是因为这类系统SQL逻辑比较重。报表统计要写聚合查询,藏品列表要做多条件动态拼接,这些用MyBatis的XML Mapper写起来非常顺手,SQL是明确写出来的,性能好不好一眼能看出来。相比之下JPA虽然开发快,但复杂查询一旦涉及多表关联和动态条件,排查SQL问题会很痛苦。
前端用Vue 2 + Element UI,虽然Vue 3和Element Plus已经普及,但后台管理系统这个场景,Vue 2生态下的现成组件和示例代码最多,遇到问题搜索答案最快,对团队里不熟悉前端的人来说门槛也更低。数据库用MySQL 8.0,免费、够用、Navicat操作方便,该有的JSON、窗口函数也都有。
1.3 功能模块地图
整个系统拆成七个功能模块,分别是:登录鉴权、藏品管理、分类管理、出入库与借展管理、修复养护记录、统计报表、系统管理(用户、角色、操作日志)。藏品管理是核心,出入库和统计报表是特色,系统管理负责兜底。
登录鉴权我用了JWT,服务端不存session,前端拿到token存本地,请求时放到Header,后端用拦截器统一校验。这个方案在前后端分离架构里实现简单,也不影响后期扩展移动端或对外接口。统计报表模块我做了分类统计、年代分布、出入库趋势三个维度,页面用ECharts展示,目的就是让管理者不用再打开Excel手动透视。
2. 数据库设计与核心表结构
2.1 设计原则:围绕藏品状态流转建模
数据库设计是这类系统最值得“抄作业”的部分。我的核心思路是:主表瘦身、流水表补齐、字典表兜底。
主表只存藏品当前快照,比如名称、分类、状态、存放位置。所有会产生历史轨迹的操作,统一写入流水表,比如出入库记录、修复记录。这样做的直接好处是:查列表很快,不需要join一大堆流水;查详情时再按时间轴拉取流水,逻辑也清晰。
另外一个容易忽略的点是字典表。藏品的分类、等级、质地、来源这些字段,如果直接用字符串存,后面统计和筛选会非常痛苦——“青铜器”和“青铜 器”会被当成两个分类。我的做法是分类单独建表,等级、来源等固定枚举用代码里的常量字典,前端下拉选项统一从字典接口读取,保证录入数据的规范性。
2.2 核心表结构详解
系统一共设计了9张核心表,我挑几张关键的展开讲。
藏品主表artifact,字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键自增 |
| artifact_no | varchar(64) | 藏品编号,唯一索引 |
| name | varchar(200) | 藏品名称 |
| category_id | bigint | 分类ID,关联分类表 |
| dynasty | varchar(100) | 年代/朝代 |
| material | varchar(100) | 质地/材质 |
| length_cm / width_cm / height_cm | decimal(10,2) | 尺寸,单位厘米 |
| weight_g | decimal(10,2) | 重量,单位克 |
| level | varchar(20) | 等级:一级/二级/三级/一般 |
| source_type | varchar(20) | 来源:征集/捐赠/移交/发掘 |
| source_desc | varchar(500) | 来源补充描述 |
| status | tinyint | 当前状态:0在库 1出库 2修复中 3借展中 |
| location | varchar(200) | 存放位置,如“A区-02排-03架-04柜” |
| image_url | varchar(500) | 藏品照片地址 |
| description | text | 藏品描述 |
| deleted | tinyint | 逻辑删除标记,0正常 1删除 |
| create_time / update_time | datetime | 创建和更新时间 |
编号字段我单独说一句。artifact_no采用“馆代码-类别-年份-序号”的规则生成,比如GL-QT-2024-0001,前端录入时自动带出,不允许重复。这个编号是藏品的“身份证”,后面所有出入库、借展记录都通过它来关联,比用自增id更直观,也符合文博行业的习惯。
出入库记录表inout_record是状态流转的核心流水:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| artifact_id | bigint | 藏品ID |
| type | tinyint | 1入库 2出库 3借展出 4借展归 5修复中 6修复归 |
| operator_id | bigint | 经手人用户ID |
| target_place | varchar(200) | 去向地点,借展或出库目的地 |
| record_time | datetime | 操作时间 |
| remark | varchar(500) | 备注 |
设计这张表的时候,我把“出库”和“借展”拆成不同type,而不是单独建一张借展表,因为它们的动作本质相同:东西离开了库房。记录下目标地点和经手人,后面要统计“今年哪些文物出去过”“目前还在外面的有几件”,一条SQL就出来了。如果需要跟踪某次展览的完整构成,再用展览表和关联表去单独维护。
用户和权限表这里不多展开,就提一点:sys_user 表不存明文密码,存BCrypt加密后的哈希串,登录时用Spring Security的BCryptPasswordEncoder校验。角色表存角色编码,用户角色关系表做多对多关联,前端根据角色编码控制菜单显隐,后端在拦截器里做接口级权限判断。
2.3 字段设计的几个经验
吃几次亏之后,我总结了三条字段设计经验,对做这类管理系统都有用。
第一,所有业务表都带deleted逻辑删除标记和create_time/update_time,但注意逻辑删除字段不要加到业务唯一索引里,否则可能出现“删除一条后又插入同编号数据,再恢复时撞唯一索引”的问题。我处理的方式是:编号生成时带随机后缀,或者在恢复数据时检查重名并提示修改。
第二,金额、重量这类数值字段一律用decimal,不要用float/double。藏品重量、尺寸要参与统计分析,浮点数的精度误差在报表汇总时会暴露出来。尺寸我拆成长宽高三个字段,比写在备注里强得多,后面要做“按尺寸范围筛选”或展览布展参考就很方便。
第三,图片不要以base64塞进数据库,表会被撑爆,接口也会慢得一塌糊涂。系统里存的是图片URL,上传接口单独走文件上传,FTP、本地磁盘或OSS都行,数据库只存路径。这个小项目用本地目录加Nginx映射就够了,简单实用。
3. 后端实现:SpringBoot + MyBatis
3.1 工程结构与启动配置
后端工程我按标准分层结构组织:controller、service、mapper、entity、common、config、interceptor、dto。entity对应数据库表,dto用于前端接收参数和返回结果,common里放统一返回体Result<T>、异常处理、分页对象。分层的好处是职责清楚,前端对接接口时只需要看dto,不直接碰实体类,后期表结构微调也不影响接口稳定性。
配置文件application.yml里几个关键配置直接贴出来:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/museum_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.museum.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl连接串里几个参数说明一下:useSSL=false是关掉SSL握手,本地和局域网部署能省掉很多连接报错;serverTimezone=Asia/Shanghai解决MySQL驱动8.x和本地时区不一致导致的日期偏移问题;allowPublicKeyRetrieval=true专门解决MySQL 8连接时提示 “Public Key Retrieval is not allowed” 的报错。三个参数都是踩过坑之后才记住的。
MyBatis开启驼峰映射map-underscore-to-camel-case: true后,数据库下划线字段能自动映射到Javabean驼峰属性,比如create_time自动对应createTime,不用写一堆resultMap。不过多表关联查询我仍然建议写resultMap,下面会讲到。
3.2 MyBatis映射与动态SQL实战
这套系统里查询条件最多的就是藏品列表:按名称模糊查、按分类选、按状态选、按年代模糊查、按编号精确查,还要分页。用MyBatis动态SQL做这套查询,代码写出来很干净:
<select id="selectPage" resultMap="ArtifactResultMap"> SELECT a.*, c.name AS category_name FROM artifact a LEFT JOIN category c ON a.category_id = c.id WHERE a.deleted = 0 <if test="name != null and name != ''"> AND a.name LIKE CONCAT('%', #{name}, '%') </if> <if test="categoryId != null"> AND a.category_id = #{categoryId} </if> <if test="status != null"> AND a.status = #{status} </if> <if test="dynasty != null and dynasty != ''"> AND a.dynasty LIKE CONCAT('%', #{dynasty}, '%') </if> ORDER BY a.update_time DESC </select>注意这里用的是#{...}预编译参数,不是${...}拼接。#{}会生成占位符,MyBatis帮我们做参数绑定,能防SQL注入;${}是字符串直接替换,只在表名、排序字段这类不能参数化的场景用,而且要白名单校验。新手最容易在这上面犯迷糊。
多表查询我建议写显式resultMap,尤其是字段多的表。比如上面这个查询要关联分类名称,如果只靠驼峰映射,category_name这个别名没法自动变成实体里不存在的属性,必须靠resultMap里的额外关联字段接住。resultMap还能处理同名字段覆盖问题——artifact表有create_time,category表也有create_time,SELECT a.* 加 c.name 时,如果不用resultMap固定映射关系,MyBatis可能把分类表的时间字段冲到实体的时间属性上,就会出很隐蔽的bug。
统计SQL也是这套系统的一个亮点。按分类统计藏品数量:
<select id="countByCategory" resultType="java.util.Map"> SELECT c.name AS category_name, COUNT(a.id) AS total FROM artifact a LEFT JOIN category c ON a.category_id = c.id WHERE a.deleted = 0 GROUP BY a.category_id, c.name ORDER BY total DESC </select>MyBatis对返回Map这种结构支持很友好,前端ECharts要的就是这种[{category_name: '青铜器', total: 42}, ...]的JSON数据,后端不用二次加工。像“按年代分布”“按等级统计”就是把这套SQL换个字段再写一遍,工作量很小。
3.3 业务层与接口设计
接口设计遵循REST风格,统一返回体是Result<T>:code 0表示成功,非0表示业务异常;消息和data字段给前端提示和渲染。这样封装的好处是前端只需要在axios拦截器里统一判断code,不用每个页面各自处理异常分支。核心接口列表如下:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/login | 登录,返回token和用户信息 |
| GET | /api/artifact/page | 藏品分页列表 |
| POST | /api/artifact/save | 新增或修改藏品 |
| DELETE | /api/artifact/{id} | 逻辑删除藏品 |
| POST | /api/inout/record | 出入库登记 |
| GET | /api/inout/page | 流水分页列表 |
| GET | /api/exhibition/list | 展览列表 |
| GET | /api/stats/category | 分类统计 |
| GET | /api/audit/page | 操作日志 |
藏品的save接口里有个细节:新增和修改共用同一个接口,靠有没有id字段区分。新增时自动生成藏品编号,修改时校验编号是否被别人占用,防止重复。所有写操作都通过注解@CacheEvict清理藏品列表的缓存,避免用户看到旧数据。
出入库登记这步我强烈建议加事务处理。它的逻辑是:先写入一条inout_record,再更新artifact的状态字段。比如“借展出库”就是插入type=3的流水,同时把artifact的status改成3。这两步要么都成功,要么都失败,所以我直接在Service方法上加了@Transactional(rollbackFor = Exception.class)。rollbackFor要写成Exception.class而不是默认只回滚RuntimeException,否则一个检查异常可能导致流水写了、状态没改,数据就错位了。
3.4 权限拦截与操作日志
接口安全这块我做了一个轻量JWT方案:登录成功用 jjwt 生成token,密钥配置在yml里,过期时间设为2小时;写一个HandlerInterceptor拦截器,从Header里取Authorization: Bearer xxx,解析token拿到userId和角色编码,放入ThreadLocal供后续业务使用。拦截器在WebMvcConfig里注册,排除登录、刷新验证码这类公开接口。
操作日志我给了一个独立模块。实现方式很土但很稳定:在需要记录的操作入口(修改藏品、出入库登记、删除数据)手动调用日志服务,记录操作人、操作类型、目标ID、操作内容、IP和时间。没有用AOP切面,因为这类系统的操作类型五花八门,手动写反而更直观可控,也方便按业务定制日志内容。日志接口做了分页,管理员能在系统管理里按操作人、时间段检索,出现数据疑点时可以回溯是谁在哪天改了什么。
4. 前端实现:Vue
4.1 项目初始化与工程目录
前端工程用Vue CLI 4.x初始化,Vue 2.6 + Element UI + axios + vue-router + ECharts。目录结构划分如下:src/api放接口请求模块,src/router放路由配置,src/store放用户信息和菜单状态,src/views按模块放页面组件,src/utils放axios封装和公共方法。
Vue 2和Vue 3的选型我在前面提过一嘴,这里补充一个具体原因:Element UI对Vue 2的表格组件、表单校验、上传组件这套生态太成熟了,做后台管理几乎零成本。vue-element-admin这个开源脚手架也有很多现成方案可以借鉴。如果你团队里都是熟手,换Vue 3 + Element Plus完全没问题,但求稳落地的话,Vue 2这条路走得最顺。
4.2 路由与请求封装
路由配置用了动态路由和静态路由结合的方式。登录页、404页这些公开页面走静态路由,业务页面全部挂在一个叫Layout的父路由下面,组件里配合侧边栏菜单渲染。路由守卫在beforeEach里判断:没有token就强制跳登录页,有token但访问了不存在的路径就跳404。
request.js的封装是前端工程质量的关键。axios实例统一设置了baseURL,请求拦截器把token从store取出来放到Header,响应拦截器统一处理两件事:一是HTTP状态码非200的报错(网络问题、服务器500),二是业务code非0的异常(比如token过期返回401)。token过期时直接清空本地登录态跳登录页,这个逻辑必须写在拦截器里,否则每个接口报错都要在页面里重复处理,代码会冗余到爆炸。代码格式大致如下:
service.interceptors.request.use(config => { const token = store.getters.token if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { if (res.code === 401) { store.dispatch('user/logout') router.push('/login') } Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) } )4.3 核心页面拆解
藏品管理页面是系统的门面,它由三个部分组成:顶部搜索表单、中间数据表格、底部翻页器,外加一个新增/编辑弹窗。搜索表单用Element UI的el-form内联布局,包含名称、分类、状态三个筛选条件,点击查询后重新请求第一页数据。表格列展示编号、名称、分类、年代、状态、存放位置和时间,状态列用el-tag按颜色区分“在库/出库/修复中/借展中”,比干巴巴的文字醒目得多。
新增和编辑共用一个弹窗组件,表单里要用到级联选择器选分类、日期选择器选入馆日期、Upload组件传图片。图片上传这里有个细节:组件拿到的是上传成功的返回值里的URL,不能直接把本地临时路径提交给后端。图片上传接口和后端数据保存分开,先传图再存表单,这样即使表单没提交成功,图片也只多占一个文件空间,不会影响数据一致性。
ECharts统计页就是按分类统计、年代分布、等级分布画三个图,数据全来自后端统计接口。这个页面的难点不在ECharts配置,ECharts的option是照文档写就行,难的是说服管理者接受“饼图比Excel透视表快”这件事。技术上唯一要注意的是ECharts组件在数据更新后要调用实例的setOption而不是重新 init,否则图表会闪烁且动画异常。
4.4 打包与部署:Vue放进SpringBoot的单体部署方案
整个项目开发期是前后端分离跑两个端口,但上线部署时我把前端构建产物直接放进了SpringBoot的 static 目录,改成单体应用一份jar包跑完。具体做法:前端执行npm run build生成dist目录,把dist下的文件拷贝到后端的src/main/resources/static,重新打包SpringBoot,访问http://ip:8080就是整套系统。
这里有一个必须处理的坑:Vue路由如果用history模式,部署到SpringBoot后,用户在藏品管理页按F5刷新,会向SpringBoot请求/artifact这个路径,后端没有对应的Controller,直接返回404。解决方式有两种:一是把Vue路由改成hash模式,URL里会带#,刷新不会触发服务端路由;二是在后端加一个转发规则,把非接口路径全部转发到index.html。我在这个系统里选择了hash模式,省事、文档多、不挑部署环境,代价是URL里多一个#,对这个体量的内部系统来说完全可以接受。
如果访问量上来要单独部署前端,也可以用Nginx托管dist目录,后端接口走反向代理/api转发到SpringBoot,效果一样。服务器资源充足或需要独立扩容时再用这种方案,初期一个Tomcat实例足够了。
5. 常见问题与排查技巧实录
5.1 环境与版本问题
问得最多的就是启动报错。MySQL 8.x连接时如果报Access denied for user,先检查密码和账号权限,再看连接串有没有带useSSL=false&allowPublicKeyRetrieval=true,这俩参数少一个都可能在连接阶段报奇怪的错。很多人装了MySQL 8但习惯性套用5.x的驱动和连接串,踩坑就踩在这里。
SpringBoot版本太高也是高频问题。我一开始图新鲜试过SpringBoot 3.x,结果JDK 8环境下直接跑不起来,报的是UnsupportedClassVersionError。后来统一换2.7.x配JDK 8,所有依赖都用对应的starter版本,问题全消。给新手的建议是:不要盲目追求版本号,先确认目标部署环境的JDK和数据库版本,再倒推SpringBoot版本。Maven依赖下载慢的问题,在settings.xml里配置阿里云镜像能解决90%的烦恼。
5.2 MyBatis相关常见坑
SQL日志不打印。排查顺序是:先确认yml里有没有配log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,再看是不是打了日志但被控制台刷掉了。我习惯开发环境开StdOutImpl,生产环境换logback,避免SQL日志刷爆磁盘,量大的时候真的会占用好几个G。
多表关联查询字段映射不上的问题,前面已经说过,根因是驼峰自动映射只处理同名转换,解决方式就是用resultMap显式声明。另外提醒一句,resultMap里用column指定数据库列名,Java属性用property,两者对应关系最好和接口返回的DTO对齐,不要偷懒直接映射Entity,否则后续改DTO字段时容易漏改导致空值。
MyBatis缓存这里多说一句。一级缓存默认开启,同一个SqlSession里查两次相同SQL会命中缓存;二级缓存是跨SqlSession的,需要配置cache标签。但在这个系统的列表查询和统计场景里,我全部关闭了二级缓存,因为一旦执行更新操作,涉及多表联动的缓存清理策略不好控制,容易出现“改了数据但查出来还是旧值”的脏读问题。追求性能的时候,我宁愿在Service层用Spring Cache注解做显式缓存,指定key和过期时间,至少逻辑是可控的。
5.3 前后端联调问题
联调期报的最多的就是跨域。前端开发服务器跑在8081,后端接口在8080,浏览器直接拦截。我的处理方式是在后端写一个CorsFilter配置类,允许http://localhost:8081的跨域请求,并允许携带Authorization头。要注意的是跨域配置和JWT拦截器的执行顺序,如果拦截器先于跨域过滤器执行,预检请求OPTIONS会因为没有token而被拦截,联调时表现为“明明请求发出了却一直报跨域”。
前端还有个常见问题是表格数据不刷新。新增一条藏品后列表还是旧的,多半是忘记在提交成功的回调里重新调用列表接口。正确写法是:新增弹窗关闭的@close事件里刷新列表,或者提交成功后直接调用分页查询方法。我刚写这套系统时用的是第二种,后来发现弹窗关闭事件更可靠,因为用户在弹窗里按了关闭按钮但没有提交数据时,列表也应该重置搜索条件。
5.4 上线前要处理的事
最后一个部分讲讲上线前的经验,这都是代码之外但比代码更影响成败的事。
历史数据迁移是最容易被低估的环节。Excel台账里的字段命名千奇百怪,“出土地点”可能叫“出土地”、“出土位置”,年代写法有“汉”、“汉代”、“西汉早期”,不先做数据清洗,导进去就是一堆垃圾数据。我的做法是写一个导入模板,固定列顺序,让业务人员按模板填好,再用一个后台导入工具批量校验入库,校验规则包括分类是否存在、日期格式是否正确、编号是否重复,校验不过的文件下载错误报告逐条改。
初始账号和权限分配也要提前想清楚。管理员账号、藏品录入员、只读访客三类角色是标配,管理员能删数据,录入员只能新增和编辑,访客只能看列表。权限不是越细越好,太多角色反而增加维护成本,够用就行。最后别忘了改默认密码,不管哪个框架,默认的admin/123456上线第一天就会被扫漏洞的脚本盯上。
数据库备份我用的是Navicat的计划任务,每天凌晨全量备份一次,保留最近7天版本。馆藏数据不像电商订单那么频繁变动,一周内的备份足够应对绝大多数意外,但“不备份”这个选项是绝对不能存在的。
最后说点实在的
这套系统做完之后,我个人最大的体会是:管理类系统的技术难度通常不在算法和并发,而在“把业务规则翻译成数据结构和接口权限”这个过程。藏品状态流转、借展留痕、统计分析,每一个模块单独拎出来都不复杂,但它们组合起来就考验整体设计的一致性。
如果后面要扩展,我建议优先做两件事:一是给藏品加二维码标签,手机上扫码就能看到基本信息,盘点时效率提升非常明显;二是加一个简单的导入导出模块,因为业务方对Excel的依赖短期是戒不掉的,与其堵不如疏。希望这份源码和这篇拆解能帮你少走几步弯路,做出一套真正能用的馆藏系统。