做线上历史馆藏系统这个项目,刚开始我真没觉得它有多复杂,以为就是一个“套壳后台”——藏品表增删改查、用户登录、公告发布,拿一套通用CRUD模板糊上去就完事了。但真正动手梳理需求之后才发现,历史馆藏类业务有很强的领域属性:文物不是普通商品,它有朝代、材质、来源、保存状态这些绕不开的字段;线上展览也不是简单地把藏品堆在同一个页面里,而是涉及策展顺序、档期管理、上线状态这些逻辑。这个项目最终用SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0把前后端分离架构完整落地,代码和文档都整理齐全,适合正在做Java Web全栈项目(尤其是毕业设计、课程设计或中小型信息化管理系统)的人直接参考复用。它解决的问题很具体:让博物馆、校史馆、文化展馆能把线下藏品数字化,游客在线浏览分类、检索、看展览,馆员和管理员在后台维护藏品数据、策展、发资讯。
1. 项目定位与技术栈选型思考
1.1 线上馆藏系统到底在解决什么问题
这个系统的核心不是“管数据”,而是“把馆藏业务线上化”。我最初把重点放在CRUD上,后来发现真正的业务复杂度集中在三个层面。
第一是藏品数据的完整性。一件文物要录入的信息远不止“名字和图片”:文物编号、朝代、年代、材质、尺寸、来源地、保存状态、入馆登记信息、藏品简介,甚至是否需要3D展示标记。这些字段之间还有依赖关系,比如按朝代筛选、按材质统计、按保存状态批量标记。如果一开始只设计一张“物品表”,后期加字段会非常痛苦。
第二是展览的策展逻辑。一个线上展览可以包含多件藏品,同一件藏品也能出现在不同展览里,这本身就是多对多关系。还有展览的封面、简介、起止时间、排序、上架状态,这些字段决定了用户端首页的展示效果,也决定了管理员能不能按“进行中、未开始、已结束”来管理档期。
第三是用户权限的边界。游客只能浏览,馆员可以维护藏品和资讯,管理员还要管用户、管展览上下架。三种角色的操作边界如果不在后端做校验,前端按钮隐藏得再好看也是白搭。
所以这个项目适合什么场景?简单说就是:以“藏品为中心、以展览为线索、以权限为边界”的线上文博展示系统。参考学位论文或课程设计做这一类系统,用这套结构基本不会跑偏。
1.2 为什么锁定SpringBoot2+Vue3这套组合
技术选型的时候我对比过几个方案:SpringBoot2 + Thymeleaf服务端渲染,SpringBoot2 + Vue2 + Element UI,还有SpringBoot2 + Vue3 + Element Plus。最后选了后者,原因很现实。
SpringBoot2依然是中小型Java Web项目最稳的选择,3.x虽然新,但很多第三方库和教程的兼容性还没完全跟上。2.7.x版本搭配Spring Framework 5.3,不管是spring-boot-starter-data-redis还是spring-boot-starter-validation,踩坑资料一搜一大把,社区成熟度非常高。
Vue3这边,Composition API写起来确实比Options API更符合逻辑聚合的习惯,同一个功能的响应式状态、计算属性、方法可以放在一起,代码阅读性比Vue2时代高很多。配合Vite启动速度快,开发期体验非常好。Element Plus对Vue3的原生支持也成熟了,后台管理界面不需要自己造轮子。
MyBatis-Plus在这个项目里算是“省心担当”。它继承BaseMapper之后连基础SQL都不用写,分页、逻辑删除、字段自动填充都是内置能力。相比纯MyBatis,我估计这个项目至少省了30%无意义的XML代码。
MySQL8.0的选择不用纠结,utf8mb4是默认字符集,JSON类型和窗口函数将来要扩展统计分析都很方便,本地安装、云数据库支持也都是按这个版本配置的。
| 对比项 | SpringBoot2 | SpringBoot3 |
|---|---|---|
| 生态资料量 | 极多,踩坑经验丰富 | 正在积累 |
| 第三方兼容 | JWT、OSS等库适配成熟 | 部分库需升级 |
| 学习成本 | 低 | 中 |
| 对比项 | Vue2 + Element UI | Vue3 + Element Plus |
|---|---|---|
| 组合式API | 不支持 | 原生支持 |
| Vite | 配置较绕 | 无缝集成 |
| 长期维护 | 已停止更新 | 持续迭代 |
如果你只是自己学习或者做毕设,选SpringBoot2 + Vue3这个组合是最稳的,既不会像老技术那样显得过时,也不会因为太新而找不到参考。
2. 数据库设计与后端架构拆解
2.1 馆藏系统核心数据模型设计
表结构是整个系统的地基,我前后优化了三版。第一版把所有字段塞进一张藏品表,结果展览和藏品的关系完全没法表达,第二版拆出了展览表和关联表,第三版补上了公告、轮播图这些运营位。最终核心表如下。
- sys_user:用户表。字段包括
id、username、password、real_name、role、status、create_time、update_time、deleted。角色我直接压在单表里,管理员、馆员、游客三种角色用字符串区分,中小项目够用,没必要上复杂的RBAC五表模型。 - collection_category:藏品分类表。字段是
id、category_name、sort、status。分类要支持排序,因为首页和筛选页都会按这个排序展示。 - collection_info:藏品信息表。字段有
id、category_id、collection_name、collection_no、dynasty、material、size_desc、source_place、preservation_status、image_url、description、audit_status、sort、create_by、create_time、update_time、deleted。注意我没有单独存数字类型的size,因为文物尺寸往往是“高35.6cm,口径18.2cm”这种复合描述,用字符串反而灵活。 - exhibition_info:展览表。字段包括
id、title、cover_image、summary、start_time、end_time、status、sort、create_time、update_time。状态用0未开始、1展出中、2已结束三个整数表示,前端显示对应标签。 - exhibition_item:展览藏品关联表。核心字段是
id、exhibition_id、collection_id、sort。这张表解决的就是多对多关系,每个展览里藏品出现的先后顺序由sort控制。 - article_info:资讯文章表。字段有
id、title、cover_image、content、type、publish_time、status。内容用长文本,前端用富文本展示。 - banner_info:首页轮播图表。字段是
id、title、image_url、link_url、sort、status。
设计时我最想提醒的一点是:逻辑删除字段不要遗漏在关联表上。exhibition_item这种中间表我一开始没加deleted,后面想恢复误删的关联数据时完全没办法。所有业务表统一加上deleted字段,配合MyBatis-Plus的@TableLogic,删数据都变成更新操作,心里踏实很多。
时间字段统一用datetime,实体类用LocalDateTime,千万别用java.util.Date,跨前后端解析时LocalDateTime配合Jackson的yyyy-MM-dd HH:mm:ss格式最省心。
2.2 SpringBoot2工程结构与MyBatis-Plus落地用法
工程结构我按标准分层来拆:controller、service、mapper、entity、dto、vo、config、common。网上很多脚手架喜欢把Controller写得很肥,业务逻辑全堆在接口层,后期维护很难受。我习惯的做法是:Controller只做参数接收和结果封装,业务判断全部下沉到Service。
pom.xml里的核心依赖大概是这样:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>MyBatis-Plus有几个点用起来非常顺手,但也要注意踩坑。
分页插件是必须配的。我在MybatisPlusConfig里这样配置:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)这个是我实际项目里加的保护措施,防止有人恶意传一个current=1&size=99999把全表数据一把拉走。
查询条件用LambdaQueryWrapper,不用在代码里写死数据库字段名,避免字段改名后编译期才发现不了问题。藏品分页检索的Service层代码我贴一段简化版:
public PageResult<CollectionVO> pageCollections(CollectionQuery query) { Page<CollectionInfo> page = new Page<>(query.getCurrent(), query.getSize()); LambdaQueryWrapper<CollectionInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getKeyword()), CollectionInfo::getCollectionName, query.getKeyword()) .eq(query.getCategoryId() != null, CollectionInfo::getCategoryId, query.getCategoryId()) .eq(StringUtils.hasText(query.getDynasty()), CollectionInfo::getDynasty, query.getDynasty()) .eq(CollectionInfo::getAuditStatus, 1) .orderByAsc(CollectionInfo::getSort) .orderByDesc(CollectionInfo::getCreateTime); Page<CollectionInfo> result = collectionMapper.selectPage(page, wrapper); return convertToPageResult(result); }实体类自动填充也值得提一下。建实体的时候统一继承一个BaseEntity,里面放createTime、updateTime、deleted三个公共字段,然后实现MetaObjectHandler:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }这样所有插入和更新操作都不用手动维护时间字段,也不会出现时间不一致问题。
3. 前端Vue3核心实现与联调配置
3.1 后台管理界面搭建与路由权限处理
前端我用的工程组合是Vite + Vue3 + Element Plus + Pinia + Vue Router。初始化用npm create vite@latest选择Vue模板,然后单独安装Element Plus。
Element Plus建议按需引入,我用的是unplugin-auto-import和unplugin-vue-components这两个Vite插件。这样组件和API都会按需打包,首屏加载速度比全量引入明显快。
路由权限这块是前端容易糊弄的地方。我这个项目里做的是“动态路由 + 路由守卫”的方式。登录成功后后端返回当前用户的角色标识,前端根据角色过滤出可访问的路由,再用router.addRoute动态挂载。路由守卫统一做未登录拦截:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } })这里注意一个细节:next('/login')之后一定要return,否则会重复执行守卫造成死循环。我一开始没加,结果跳转登录页时控制台一直报警告,排查了半天。
Pinia状态管理我主要用来存用户信息和菜单权限。用户信息刷新页面后会丢失,所以我从localStorage里恢复token后,会再调一次/user/info接口拉取最新用户信息,避免刷新后菜单消失。
3.2 藏品展示与检索交互实现
用户端的藏品浏览页是重交互页面。左侧分类树,顶部关键字搜索框,中间卡片流,右下角分页器。整个页面我拆成CollectionList.vue主组件加三个子组件,分类树和数据列表通过ref和defineExpose联动。
藏品卡片我用了el-card加图片懒加载。图片统一走后端返回的相对路径,前端通过Vite的baseURL拼接完整地址。这里有个实际经验:不要把图片以Base64形式塞进MySQL,一张几MB的图就能拖垮接口响应,图片应该存OSS或服务器本地路径,数据库只存URL字符串。
搜索交互我当时用的是“点击搜索按钮触发查询”而不是“输入即搜索”,因为馆藏数据一般几百上千条,不需要像电商那样实时联想。每次查询前current强制重置为1,否则在第三页搜关键字会出现空结果,这是个很膈应的小bug。
与大比分页相关的前端参数我也统一做了封装:
const queryParams = reactive({ current: 1, size: 12, keyword: '', categoryId: null, dynasty: '' }) function handleSearch() { queryParams.current = 1 loadData() }判断搜索条件保留,也要在loadData里把queryParams整个对象序列化传给后端,这样才能做到分享链接时参数不丢。
4. 核心业务模块实操记录
4.1 藏品管理模块实现
藏品管理是后台用得最多的模块,界面分成三块:左侧分类树、中间列表、右侧新增编辑抽屉。列表展示编号、名称、朝代、材质、状态和操作按钮,用el-table组件,列配置全部写在模板里。
新增和编辑共用一个CollectionForm.vue组件,通过isEdit布尔值区分。表单校验是重点,我用Element Plus的rules做了三层校验:必填项(名称、编号、朝代、分类)、长度限制(名称50字、编号30字)、图片必传。唯一编号的校验不能只靠前端,后端Service里也要查一次是否存在。
图片上传用的是后端提供的/upload/image接口,前端el-upload组件设置action地址为后端接口,on-success里拿到返回的图片URL塞进表单字段。需要注意el-upload默认会把文件以multipart/form-data提交,后端Controller要用MultipartFile接收,并且一定要限制文件大小,我设置了10MB最大值,防止有人传超大图导致内存溢出。
藏品数据录入完成后,状态默认是审核中。这个设计是跟馆方聊过之后加的,文物信息必须经过馆员二次确认才能对外展示,不能业务员录完就立刻上线。审核接口其实就是一个状态字段更新,但它的存在让整个流程合理很多。
4.2 线上展厅/策展模块实现
线上展厅是区别于普通后台系统的亮点模块。后端设计了exhibition_info和exhibition_item两张表,前端策展页面左边是待选藏品库,右边是当前展览的已选列表,中间用穿梭框组件el-transfer双向移动。
选中一件藏品后,可以调整它在展览中的顺序。我实现方式是每行数据后面放一个“上移/下移”按钮,对应的接口接收exhibitionId和两个itemId,在Service层把两个关联记录的sort字段互换。
前端展示端我做了“当前展览”页和“展览详情”页。当前展览页只取status=1(展出中)并且当前日期在start_time与end_time之间的数据,按sort排序。展览详情页循环遍历exhibition_item关联表获取藏品列表,用横滑的卡片组展示。
线上展厅还有一个小细节:展期结束的展览不能直接物理删除,否则会影响历史统计,所以我只做逻辑删除。deleted=1的记录在管理后台默认隐藏,但统计报表里要按时间范围把已删除展览也计入“历史策展场次”。当时为了报表能查逻辑删除的数据,单独写了一个Mapper方法,指定deleted=0或deleted=1查全量,用自定义SQL而不是LambdaQueryWrapper。
4.3 登录认证与权限控制
认证方案我选了JWT,没有用传统的Session。流程是登录成功后,后端用SecretKey生成一个带过期时间的token,返回给前端存储在localStorage,后续每次请求在Authorization请求头带上Bearer token。
后端拦截器统一对/api/**做token解析:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; }权限控制我用了更轻量的方案:自定义@RequireRole("ADMIN")注解加拦截器。拦截器里拿到request.getAttribute("role"),跟自己需要的角色比对,不一致就返回403。没有引入Spring Security,因为这个小项目的角色模型比较简单,用Security反而要写大量的配置类。
这里有个管理端经常踩的坑:前端根据角色隐藏菜单按钮只是体验优化,真正的拦截必须放在后端接口。比如删除藏品的接口,游客登录状态下直接请求DELETE /api/collection/{id},后端拦截器必须拦下来。我测试的时候就干过这种事情,前端把按钮藏了,但用Postman直接调接口照样能删数据,后来才补上后端角色校验。
5. 部署上线与环境配置踩坑
5.1 本地联调常见问题
前后端分离项目联调时最容易出问题的是跨域。我开发时用Vite的proxy代理解决,生产环境用Nginx反向代理,所以后端不需要单独配CORS。Vite的vite.config.js里这样配置:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }需要注意changeOrigin必须为true,否则后端拿到请求的Host还是前端地址,如果后端有校验来源就会报错。
第二个高发问题是日期格式。后端返回的LocalDateTime默认序列化为ISO格式的数组,前端根本没法直接显示。我在后端统一配置了Jackson格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8配完这个之后,前后端传时间参数也要统一格式。我在DTO里用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")标注时间字段,前端传参时用dayjs格式化字符串,基本上就不再出现“时间偏移8小时”和“格式解析失败”了。
5.2 MySQL8.0环境配置要点
MySQL8.0安装本身不难,但有几个坑必须提前注意。
字符集问题。8.0默认就是utf8mb4,但如果你是从5.7迁移老库过来,建库的时候还是写上明确字符集最保险:
CREATE DATABASE museum_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;表字段里的description这种长文本字段,用text类型或varchar(2000)都行,但索引列千万别用text,会报BLOB/TEXT column错误。
连接配置里,驱动版本必须用8.0对应的com.mysql.cj.jdbc.Driver,并且加上时区参数:
spring: datasource: url: jdbc:mysql://localhost:3306/museum_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver如果不加serverTimezone=Asia/Shanghai,驱动默认用UTC时区,你插入的create_time会比北京时间少8小时。还有一个8.0特有的问题:默认认证插件是caching_sha2_password,老版本的mysql-connector-java驱动连不上,会报Unable to load authentication plugin。我用的是8.0.33驱动,就完全没这个问题,所以驱动版本别贪新也别太老。
5.3 部署体验与注意事项
部署我分两套:开发环境用mvn spring-boot:run直接跑,生产环境用mvn clean package打成jar包,配合java -jar启动。前端先npm run build生成dist目录,然后由Nginx托管。
Nginx配置里有两处要重点注意。第一是前端路由用了createWebHistory,刷新页面时Nginx会按路径去找文件,找不到就404,所以要加try_files $uri $uri/ /index.html;。第二是接口转发,把/api前缀的请求转发到后端服务:
location /api { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }上传的图片我放在jar包同级的upload目录,通过一个静态资源映射来提供访问。WebMvcConfigurer里加这样一段:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath = System.getProperty("user.dir") + "/upload/"; registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); }我踩过坑:如果直接把图片放在/static目录里然后重新打包,jar包一更新图片就没了。放外部目录才能持久保留。
6. 二次开发与扩展思路
这个项目做完之后,我觉得可以和实际业务做更深入的连接。比如给藏品加上“语音讲解”字段,用户在浏览藏品详情时播放一段音频介绍;又比如对接一个3D模型展示组件,把文物多角度模型嵌入详情页,观感会强很多。
技术层面也有几个值得扩展的方向。数据量到几万条之后,藏品列表的分页查询可以考虑加Redis缓存热门分类的结果,减轻数据库压力。文件的存储可以迁移到OSS,方便做CDN加速,也免得服务器磁盘扛不住大图。
如果你是拿这个项目做毕业设计或课设,可以再补两块内容:一是数据统计报表,通过collection_info表按朝代、材质、年代做聚合统计,用ECharts渲染出来;二是留言预约系统,让游客可以预约线下参观,增加一个reservation表和相应的管理端审核列表。这两个功能都是往现有表结构上叠加的,不会破坏原来的逻辑。
文档部分我建议把接口文档整理成一份完整的API清单,标明每个接口的请求参数、返回示例和权限要求。做系统的时候顺手记录,后面写论文的“系统实现”章节会轻松很多,而且答辩时老师会着重看接口设计的合理性。
关于这套系统,我个人实际使用中最大的体会是:技术栈选得再熟练,都不如把业务逻辑理顺有用。藏品、展览、用户、权限这条主链路走通,后面加什么功能都是水到渠成的事。如果让我再做一次这种项目,我反而会把更多时间花在“审核流程”和“展期状态机”这些细节上,这些才是让系统真正能被馆方认可的亮点。最后再分享一个小技巧:凡是涉及图片上传、逻辑删除、时间自动填充的模块,尽量在项目初期就把公共基类抽好,别等到写第三个模块的时候再回头补救,重构的代价比一开始设计要高得多。