前后端分离的线上历史馆藏系统,听上去像是一个正规博物馆或文化机构的内部项目,但我用SpringBoot+Vue+MyBatis+MySQL这一套组合,从零写了个完整的源码版本,目的就是给那些想学前后端分离、又不想做烂大街的增删改查项目的同学一个更贴近业务场景的参照。这套系统麻雀虽小五脏俱全:前台有馆藏列表、分类筛选、检索、详情展示,后台有藏品管理、分类管理、轮播管理、数据统计,配上Nginx部署之后基本就是一个可上线的小型馆藏管理系统。如果你正要找项目练手、做毕业设计,或者想体验一下真实项目的工程化拆分方式,这篇文章会根据标题对应的完整源码和部署教程,把我的设计思路、表结构选型、接口拆分、前后端联调、Linux部署踩坑全部交代清楚。
1. 馆藏系统的业务骨架:为什么这四件套刚好合适
先聊聊选型逻辑,不然你直接去敲代码很容易迷失。这个系统的核心业务并不复杂:管理一批历史馆藏物件,每个物件归属某个朝代(或者某个时代背景),属于某个分类(陶器、瓷器、书画、青铜器、古籍等),拥有单独的编号、名称、材质、出土地点、尺寸、存量状态、简介、大图和小图。访客在前台看到的是一个有分类导向、可搜索、可浏览详情页的数字展厅;管理员在后台完成录入、编辑、封存、删除、批量上下架。
这种业务量不大、但数据关系清晰、交互场景多(查、改、传图、联查)的系统,正好是SpringBoot+Vue+MyBatis+MySQL的舒适区。
SpringBoot负责把后端业务和接口快速暴露出来,省去大量XML配置,一个Application类启动即可,对于这个规模的系统完全够用。Vue(我选用的是Vue 3 + Element Plus + Vite,标题里虽然只写了Vue,但实际这样搭体验最好)提供响应式页面和组件化开发,前台展示页和管理后台共享同一套组件体系,复用度高。MyBatis在这套系统里比JPA更合适,因为馆藏列表里有大量的多条件组合筛选,比如按朝代、按分类、按关键字、按存量状态,条件可能是动态拼接的,MyBatis的动态SQL在这种场景下就是杀手级能力。MySQL则承担全部数据存储,安装维护简单,InnoDB加上utf8mb4字符集,中文文物名、生僻字都能存稳妥。
这套组合对“个人开发者完成一个完整项目”来说,是成本最低、最容易查资料、踩坑后最快找到答案的路径。比微服务轻,比纯静态页面有诚意,拓展空间还很大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模与MyBatis映射:馆藏数据怎么设计才能耐查耐改
数据库设计是这类系统最容易被低估的部分。很多人上来就建一张藏品表,加一个分类字段、一个朝代字段,写的时候爽,等要做筛选和统计的时候就哭了。我的设计逻辑是拆成五张核心表:馆藏主表、分类表、朝代/时代表、轮播图表、管理员表。
馆藏主表(collection)的字段设计有一批细节值得讲。id用自增主键,不做复杂雪花算法,单机部署完全没必要。collection_no是展示编号,我设计成“分类首字母+年月日+序号”,比如TS20250301001。这个编号在录入时由后端自动生成,避免管理员逐个手写容易重复。title和subtitle分别存藏品名称和副标题,subtitle常用来存放别名或展览用名。era_id和category_id是两个外键字段,分别指向朝代表和分类表,这里我刻意没有在表间建物理外键约束,而是用业务层保证引用完整性。原因是:历史馆藏数据录入时常有先建藏品、后补详细属性的情况,物理外键容易导致输入顺序耦合;只要在服务层的插入/更新逻辑里做存在性校验,性能也更好。image_url、detail_images存图片路径,这里存相对路径,真实文件的绝对路径由配置项统一管理,避免数据库跟着服务器迁移而失效,稍后在图片上传部分细讲。description是长文本,用TEXT类型。status字段是数据管理的灵魂:0表示录入中、1表示已上架展示、2表示已下架封存,这套状态机让前后台的展示逻辑很干净——前台永远只查status=1的数据,后台列表可以看到全部。
朝代表(era)和分类表(category)的结构几乎一致:id、name、sort_order、remark。朝代表我额外加了一个period_desc字段,用来描述该朝代的起止时间和考古学特征,前台详情页里可以给访客一个背景知识的小卡片展示。建这两张表的核心意义在于:把“中文名称”这种不稳定的自然语义从主表里抽离出来,避免系统运营一段时间后出现“唐三彩”和“唐三彩 ”两个分类名并存的问题,统一维护、统一展示。
MyBatis映射那边的关键设计我单独说说。全局开启驼峰映射是必须的:map-underscore-to-camel-case=true,这样数据库里的category_name可以直接映射到实体类的categoryName,少写一半的resultMap。但在需要连表查询的场景,我用resultMap显式指定映射更稳妥。比如列表页要展示分类名称和朝代名称,不想到处用DTO,就在Mapper XML里定义如下所示的查询:
xml复制<resultMap id="CollectionVO" type="cn.example.entity.CollectionVO">
<id property="id" column="id"/>
<result property="title" column="title"/>
<result property="collectionNo" column="collection_no"/>
<result property="categoryName" column="category_name"/>
<result property="eraName" column="era_name"/>
<result property="status" column="status"/>
</resultMap>
<select id="selectPageList" resultMap="CollectionVO">
SELECT c.id, c.title, c.collection_no,
cat.name AS category_name,
e.name AS era_name,
c.status
FROM collection c
LEFT JOIN category cat ON c.category_id = cat.id
LEFT JOIN era e ON c.era_id = e.id
<where>
<if test="keyword != null and keyword != ''">
AND (c.title LIKE CONCAT('%', #{keyword}, '%')
OR c.collection_no LIKE CONCAT('%', #{keyword}, '%'))
</if>
<if test="categoryId != null">
AND c.category_id = #{categoryId}
</if>
<if test="eraId != null">
AND c.era_id = #{eraId}
</if>
<if test="status != null">
AND c.status = #{status}
</if>
</where>
ORDER BY c.sort_order DESC, c.id DESC
</select>
动态SQL的四个
3. 后端核心业务的实现逻辑:从分页查询到图片上传的完整链路
后端的Controller层不搞花活,就是标准的三层结构:Controller接收参数、Service处理业务、Mapper操作数据。但这个项目的具体业务逻辑里藏着几个值得展开的点。
第一个是分页查询的接口约定。Vue前台的列表页、后台的管理页都需要分页,每一页的数据量、当前页码、筛选条件都要随请求参数传递。我没有引入PageHelper插件,虽然那玩意确实快,但我觉得让新手看到手写分页的完整逻辑更有价值,而且这个系统数据量根本不需要插件级的优化。接口的Query对象长这样:
java复制public class CollectionQuery {
private Integer pageNum = 1;
private Integer pageSize = 10;
private String keyword;
private Integer categoryId;
private Integer eraId;
private Integer status;
}
Service层里用PageHelper还是手写LIMIT?我用的是手写LIMIT,因为MyBatis的Mapper XML里可以直接用两个变量进行分页:
xml复制LIMIT #{offset}, #{pageSize}
然后Service层里用ThreadLocal或者直接透传pageNum和pageSize,计算一下offset就行:offset = (pageNum - 1) * pageSize。这种做法的好处是SQL直观,调试时复制出来就能直接在Navicat里跑。配套返回一个PageResult对象,统一结构是:records、total、pageNum、pageSize四个属性。前端拿到total之后渲染分页组件,整个交互闭环就完成了。
第二个重点是图片上传的处理。历史馆藏的图片管理不是简单的“传个文件存到static目录完事”这么简单。我做了两个功能点:一是图片信息写入数据库用相对路径;二是后台管理的图片上传组件支持拖拽和多图。后端的上传接口接收MultipartFile,校验文件类型(只允许jpg、jpeg、png、webp、gif),重命名为UUID加后缀,再按照月份子目录存储,例如/uploads/202503/xxxxx.jpg。这个月度目录拆分是历史馆藏系统的刚需,因为录入活动通常集中在某段时间(临时展前的整理期),单目录下的文件数量可能爆发式增长,按月切目录方便后续做冷热归档。
图片存储路径不要丢在代码里写死,我建议放到application.yml中配置:
yaml复制file:
upload-dir: /data/collection/uploads
access-prefix: /uploads
后端提供静态资源映射,把这些目录暴露出来。当然,生产环境更好的做法是用Nginx直接做静态文件服务,后端只管文件写入和路径记录,这个方案在部署章节我会详细讲。
第三个是馆藏编号自动生成逻辑。上面提到封面展示编号要避免人工重复,我用Redis还是数据库?在这个系统里我直接用数据库查重加时间戳生成,因为并发量很低,没必要引入Redis。生成规则在CollectionServiceImpl里写了一个私有方法:
java复制private String generateCollectionNo(Long categoryId) {
Category category = categoryMapper.selectById(categoryId);
String prefix = category.getCode(); // 分类维护时录入的短码,如 TS(陶瓷)
String dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
int randomPart = ThreadLocalRandom.current().nextInt(100, 999);
return prefix + dateStr + randomPart;
}
这串编码拿到之后会先做一次唯一性校验,如果数据库中已存在则重新生成随机数再校验一次。虽然有概率碰撞(1/900),但在这个量级下已经绰绰有余。如果你硬要用雪花、UUID,也没问题,只是历史馆藏这种面向展览场景的公开编号,带分类语义和日期含义的短编号,在文案、海报设计上更实用。
第四个是事务和软删除。馆藏物件的删除我坚决不做物理删除。历史馆藏数据往往有复核、追溯的档案属性,今天删的东西明天可能就要从库房记录里找回来。所以删除操作的实现是UPDATE status=2,并记录deleted_time字段。真正危险的操作是“清空分类”:如果某个分类下还挂着藏品,直接删除分类会导致馆藏列表里的分类变成空引用。在CategoryServiceImpl里我用事务保护了这个动作:
java复制@Transactional(rollbackFor = Exception.class)
public void deleteCategory(Long id) {
Integer count = collectionMapper.countByCategoryId(id);
if (count > 0) {
throw new BizException("该分类下存在馆藏,无法删除,请先转移或封存馆藏");
}
categoryMapper.deleteById(id);
}
刚开始写的时候没加事务,删除分类之后馆藏页面出现一堆空分类名,排查了半小时才发现是漏了级联处理。所以这种业务规则一定要写在删除动作的前置校验里,宁可多查一次库,也不能留脏数据。
4. Vue端的信息架构与数据流:前台展厅和后台管理的双页面模式
前端这部分是整个系统看起来“像个产品”的关键。Vue项目我采用的是前后端完全分离的目录结构,设计成两个入口:前台展厅(面向访客)和后台管理(面向管理员)。
先看前台展厅的路由设计。首页展示轮播图、推荐馆藏、分类检索、朝代时间轴入口。馆藏列表页是一个核心页面,筛选面板放在顶部或者侧边,用户可以切换分类、朝代,输入关键词搜索。列表卡片上展示图片、名称、朝代、分类。点击卡片进入详情页,展示大图、多个细节图、规格参数、简介说明、出处信息。这些页面的数据来源都是后端接口,但页面之间的跳转参数通过Vue Router的query传递。比如从首页进入全部馆藏列表时,router.push({ path: '/collection/list', query: { categoryId: 3 }}),列表页的created钩子里读取route.query,塞到查询条件里再拉接口。
这里有个关键细节:筛选条件变更之后如何管理URL和状态的一致性。我在列表页用的是“响应式查询对象 + watch”的模式,搜索表单里的categoryId、eraId、keyword绑到一个reactive对象上,当用户点搜索按钮时更新当前页码为1,然后调一次loadData()。为了支持浏览器回退,还把筛选条件同步到URL query。这个体验优化的好处是:用户刷新页面后筛选条件还在,不至于一切状态归零。
后台管理端我用了一套相对独立的布局。路由前缀是/admin,通过登录态鉴权路由守卫控制访问。后台主要页面有仪表盘(统计馆藏总数、分类数、上架数)、馆藏管理表格(支持分页、筛选、批量上架下架)、馆藏编辑页(既是新增也是编辑,页面二合一)、分类管理页面、轮播图管理页面、管理员账号页。
在表格页面里,有一个值得分享的细节:每行数据的操作列里有“编辑”和“下架”按钮,“下架”操作我封装成了带确认弹窗的异步方法。确认弹窗用Element Plus的ElMessageBox.confirm,然后调接口,根据返回结果刷新列表并弹出结果提示。这套交互逻辑几乎适用于所有后台管理模块,代码写起来也是同一个套路:
javascript复制const handleOffline = (row) => {
ElMessageBox.confirm(
`确认将馆藏「${row.title}」下架封存吗?`,
'操作确认',
{ confirmButtonText: '确认下架', cancelButtonText: '再想想', type: 'warning' }
).then(async () => {
const res = await api.offlineCollection(row.id)
if (res.code === 200) {
ElMessage.success('已下架')
loadData()
}
})
}
无论前台还是后台,axios请求的封装必须统一。我是这样处理的:request.js里创建axios实例,设置baseURL为/env/,开发环境下Vite的proxy把它代理到本地后端端口,生产环境Nginx将/env/反向代理到SpringBoot服务。请求拦截器里携带localStorage中的token(后台管理需要),响应拦截器统一处理401跳转登录、500弹出错误提示。这样前端业务代码里只管成功响应,异常处理收敛在一个文件里,后续维护成本低很多。
关于图片展示,前台不能直接用后端返回的相对路径,因为如果在开发环境,前端端口是5173而图片服务是8080,浏览器直接拼相对路径肯定404。所以我让后端返回图片的完整URL:比如http://localhost:8080/uploads/xxx.jpg,或者更优雅的方法是后端返回相对路径,前端用process.env.VITE_APP_API_BASE拼接。我最终选择了后者:环境变量VITE_APP_API_BASE = 'http://localhost:8080',图片src的拼装写成一个工具函数,全局调用。部署到服务器之后只需要改这个环境变量,所有图片路径自动切到线上地址。
5. 前后端联调中的跨域问题与接口调试
前后端分离开发时,最烦人的就是跨域。我开发的第一个版本里,前端跑在5173端口,后端跑在8080端口,前端直接fetch不会通过,浏览器控制台里典型的CORS错误。当时我先在后端加了一个CorsFilter全局配置,允许所有来源访问,这样开发就能继续下去:
java复制@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);
}
}
加这个Filter时注意一个坑:如果项目里用了Spring Security,必须在SecurityConfig里把OPTIONS请求放行,否则预检请求会被拦截。这个系统我暂时没有引入Spring Security,管理员登录校验用的是拦截器加JWT的轻量方案,所以CorsFilter直接生效。但上线之后我就把这个全局跨域配置关掉了,改为生产环境用Nginx反向代理,前端请求同样域名下的/env/前缀,压根不存在跨域问题。跨域只是开发环境的产物,线上网关或Nginx配置好之后,这种配置就是多余的。
接口调试经验也值得记录。我习惯在Vite的proxy配置里加上日志,这样能直观看到请求代理打到哪个端口:
javascript复制// vite.config.js
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
}
}
}
如果前端代码里的请求地址写的是/api/collection/page,而后端Controller映射是/collection/page,rewrite这一段就是必要的。我在自己项目里为了方便统一,直接让前后端约定所有接口都带/api前缀,后端Controller的RequestMapping加一个/api统一前缀,这样前端请求地址和后端接口路径完全一致,proxy里不需要rewrite。实际项目里这种“少一个环节”的约定能省很多调试时间。
6. 部署上线的完整路径:从Docker到Nginx的一步步落地方案
部署部分通常是这类项目被卡住的重灾区,我在得到一份满意的源码运行效果后,花了大量时间把Linux服务器上的部署路径捋顺了。整体架构是:一台云服务器上装Nginx、MySQL、Java环境,后端打包成jar包通过systemd守护进程运行,前端通过Vite构建静态文件后由Nginx托管。发布流程非常简单:前端代码构建后把dist目录传到服务器的/opt/collection-web目录下,后端jar包上传到/opt/collection-server目录下。
后端打包时第一个注意点是application.yml里的数据源配置。本机运的是MySQL 8.0,服务器上如果也是MySQL 8.0,驱动和url基本一样;但要注意数据库的时区配置,MySQL连接串加上serverTimezone=Asia/Shanghai和useUnicode=true&characterEncoding=utf8,避免存进去的中文乱码。
数据库初始化这件事,直接执行准备的SQL脚本,按顺序创建数据库、创建用户授权、导入表结构和基础数据。用命令行执行时,如果SQL文件中包含emoji字符或特殊注释,要加--default-character-set=utf8mb4,否则utf8模式下生僻字会报错或者被转换为问号。
上传jar包后启动前,先用java -version确认版本,SpringBoot 3.x要求JDK 17以上,我用的是JDK 17,如果你的环境是JDK 8,那就要选择SpringBoot 2.x的版本。这个兼容性问题在新建项目时就要想清楚,别到部署当天就发现启动直接报UnsupportedClassVersionError。
Nginx配置是整个部署环节里最有技术含量的一步。我这个项目的Nginx配置长这样:
nginx复制server {
listen 80;
server_name your-domain.com;
root /opt/collection-web;
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/;
expires 7d;
}
}
这段配置里的三个location分别负责前端路由、后端接口代理、图片静态文件访问。try_files那行是Vue Router的history模式必需——如果URL直接访问某个路由,比如/about,服务器上并没有这个物理文件时,Nginx会把请求转发给index.html,由前端路由接管页面渲染。uploads那个location把图片目录挂载到/api之外的独立路径,并加上7天的浏览器缓存过期时间,让图片展示更快。
这套配置里我最想强调的是:生产环境不再使用后端内置的静态资源映射,而是Nginx直接读硬盘上的图片目录。这样做的好处是静态文件的读取效率高出很多,而且Nginx的expires缓存策略可以对图片自动加Cache-Control头,节约后端应用资源。相应的,后端上传接口里file.upload-dir配置要指向/data/collection/uploads,上传完成后,数据库里写入的路径仍然是/uploads/xxx.jpg,这样无论是开发环境还是生产环境,图片路径都不用改,只要保证这个相对路径能被Web服务器正确解析就行。
如果你习惯用Docker,也可以把后端和前端分别容器化,但我实话说,对于这种中小型系统,直接systemd加Nginx反而更省心。容器化引入的镜像构建、网络配置、数据卷挂载都是额外的学习成本,不小心还会出现容器内时区不准这种边缘问题。不反对Docker,只是觉得MVP阶段没必要。
7. 我在这个项目里踩过的坑:数据库、中文乱码、头像裁剪
最后总结一下实际踩过的几个坑,如果你打算原样复刻或者二次开发,这些能帮你省下大量的debug时间。
第一个坑是MySQL的utf8mb4字符集。最初建库的时候我用了utf8,测试数据里存一个生僻字或者特殊符号就出现报错“Incorrect string value”。这个问题的解决方案是:建库时指定CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,连接串里也保证useUnicode=true&characterEncoding=utf8。另外别忘了看表本身的字符集,库设置了还不够,已经建好的表要用ALTER TABLE转换字符集,否则老数据仍然保持原来的编码格式。这个坑特别容易在历史馆藏数据里爆出来,文物名称里带生僻字实在太常见了。
第二个坑是前后端日期格式不一致。后端LocalDateTime默认序列化格式是ISO标准格式,前端表格里直接显示成“2025-03-01T10:30:00”,丑且不符合中文后台的审美。解决方法是全局配置Jackson的格式化:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
加了time-zone这个配置也很关键——我有个同事的项目因为缺了时区配置,凌晨0点创建的数据被存成了前一天,排查了很久才发现是UTC和东八区的八小时偏移。这个配置顺手写上,省得夜班维护时再怀疑人生。
第三个坑是图片上传大小限制。SpringBoot默认的单文件上传上限是1MB,而馆藏文物图片通常都是3-5MB的高清大图。如果没有修改配置,上传时接口会直接抛MaxUploadSizeExceededException,前端表现就是上传进度闪一下然后报错,非常迷惑。正确姿势:
yaml复制spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
前端上传组件上也要同步做大小校验,在upload组件before-upload钩子里判断file.size是否超过5MB,超过就直接拦截并提示压缩后再传。双向校验才是完整的用户体验。如果涉及超大图片,还建议在服务端压缩一次,我目前用的是Thumbnailator,把超过1920像素的图片等比压缩,既能满足详情页展示清晰度,又能把磁盘占用控制住。
第四个坑是Vite构建后的路径问题。默认构建产物里的静态资源路径是绝对路径/,如果你把前端部署到域名的子路径下(比如https://example.com/collection/),就需要在vite.config.js里设置base:'/collection/',否则页面整体白屏。我这次是直接部署在域名根路径,所以不需要改;如果你用了子路径部署,这个配置会救你一命。
还有一个小点是后台登录的会话保持。我用JWT方案,登录成功后把token存在localStorage,每次请求通过拦截器自动带上;刷新页面后通过token解析用户信息并渲染顶栏用户名。但要注意Vue Router的beforeEach里不要对每个页面都校验token,只在/admin路由前缀下校验,否则游客浏览前台页面也会被误伤。JWT的过期时间我设置的是24小时,后台管理页面的操作密度不高,这个时间长度足够用。
8. 源码使用建议和二次开发的拓展思路
拿到完整源码之后,建议不要直接上来就改代码,按这个顺序做:先看README里的部署文档,把项目跑起来;然后用超级管理员账号登录后台,录入几个分类和朝代,再录入几个馆藏信息,把整个录入、编辑、上下架流程走一遍;接着去前台把筛选、详情、轮播都点一遍,搞清楚前后端交互的完整链路;最后再打开代码,对照我上面说的表结构、接口逻辑、页面组件,把重要链路画个脑图,到了这一步再去动代码,你会发现改起来心里非常有底。
二次开发的方向我给几个思路。第一个是加一个“策展专题”模块:后台创建专题,选择一批馆藏关联进去,前台首页展示专题卡片,点进去就是策展形式的页面,这个功能非常适合馆藏系统的运营场景。第二个是加收藏和分享功能:前台访客可以收藏感兴趣的藏品,生成个人分享卡片,这个功能如果配合微信小程序,传播效果很好。第三个是数据大屏:后台加一个展示页,使用ECharts制作分类占比环形图、朝代分布柱状图、历年新增趋势线,这些数据在后台已经都有,只是缺少一个可视化的出口。
如果你接触这套源码是为了毕业设计或者简历项目,我建议你把重心放在“为什么这样设计”上,面试官最常问的无非是为什么做前后端分离、为什么用MyBatis不用JPA、动态SQL怎么实现的、为什么Nginx要做反向代理。这些我在文章里实际都写到了,读懂了,表达出来就是自己的项目经验。
我在整个开发过程中最大的体会是:做一个完整项目,百分之六十的时间是在处理数据和边界情况,真正的核心代码反而是最顺理成章的那部分。馆藏系统的CRUD本身不难,难点在于数据的一致性、状态的管理、部署时的环境适配。这些才是工作里真正值钱的经验。
