☰
SpringBoot+Vue打造线上历史馆藏系统:从数据库设计到部署全解析
2026/10/2 14:59:50 网站建设 项目流程

不知道你有没有发现,博物馆、校史馆、民俗馆这几年都在集中做一件事——把展厅里的实物搬到线上。青铜器、书画、老照片、口述史影像,光靠线下展柜只能覆盖一小部分观众,真正要传播和沉淀,还得靠一套能检索、能浏览、能管理的线上数字馆藏系统。

我最近正好完整做了一套“线上历史馆藏系统”,技术栈是 SpringBoot + Vue + MyBatis + MySQL,前后端分离架构。整套源码、表结构、部署脚本都已整理完毕,这篇文章我想把从设计到上线的全过程拆开来讲清楚,包括关键表设计、接口权限、文件存储、视频播放、SprintBoot 和 Vue 打包后的部署细节,以及我踩过的坑。不管你是要交毕业设计,还是想给单位做一套数字馆藏平台,这套完整源码 + 部署教程的思路都可以直接参考复现。

1. 项目定位与技术选型:为什么是这套组合

1.1 线上历史馆藏系统的核心诉求

先想清楚一个问题:线上馆藏系统到底要解决什么?拿我做这套系统时梳理的需求来说,核心就三条:

  • 藏品可见性:把文物、史料、影像以数字化的形式展示给公众,游客不用到现场也能浏览。
  • 管理效率:馆方人员要能对藏品信息、分类、展陈状态、借调记录进行统一维护,替代之前的 Excel 表格。
  • 可控的开放度:不是所有藏品都要对游客开放,部分可能涉及版权或处于修复中,所以要有角色权限控制。

围绕这三条需求,系统很自然地划分成了两个端:管理后台(馆方用)和门户端(游客用)。这也是我选择前后端分离的根本原因——两个端的界面形态、交互逻辑、更新节奏都不同,强行揉在一个单体页面里,后期修改成本很高。

1.2 技术栈选型的取舍逻辑

技术选型这块,我需要给出我的真实想法,而不是无脑堆新技术。

  • SpringBoot:目前做 Java 后端项目效率最高的起步框架,不需要繁琐的 XML 配置,内嵌 Tomcat,直接打成 jar 包就能跑。我选的是 2.7.x 版本,后面会专门讲为什么不要一上来就追最新版。
  • Vue:前端这块,Vue 在国内的生态成熟度、中文文档质量、上手曲线都比其他框架更适合中小型团队。用 Vue Router 做路由管理,Vuex 做全局登录态管理,再配合 Element UI 做后台界面,搭建速度非常快。
  • MyBatis:它是半自动 ORM 框架,SQL 由自己控制,在做复杂的多表关联查询(比如“藏品 + 分类 + 借调记录 + 多媒体资源”这种多对多关系)时,比 JPA 那种全自动框架要更可控,SQL 也能针对大表做手工优化。
  • MySQL:稳定、社区资料丰富、部署简单,对于馆藏这种以结构化数据为主、并发量不会特别夸张的系统来说,完全够用,而且后续如果要接全文检索或数据分析,生态也不会受限。

尽量别选那种网上教程少、团队没人用过的新技术。项目的第一目标是稳定交付,不是为了炫技。

1.3 整体架构与目录规划

项目整体结构见下。我先说目录规划,这是很多初学者最容易忽略的——结构清晰,后面维护和写论文/说明文档都会轻松很多。

historical-collection-backend (SpringBoot) ├── src/main/java/com/museum │ ├── controller # 接口层 │ ├── service # 业务层 │ ├── mapper # MyBatis数据访问层 │ ├── entity # 实体类 │ ├── config # 配置类 │ ├── common # 通用工具/返回结果封装 │ └── security # 登录认证与权限 ├── src/main/resources │ ├── mapper # MyBatis XML文件 │ └── application.yml historical-collection-frontend (Vue) ├── src │ ├── api # axios接口封装 │ ├── views # 页面组件 │ ├── router # 路由配置 │ ├── store # vuex状态 │ └── components # 公共组件

前后端分离的含义在目录上就能体现:前端只关心页面渲染和用户操作,后端只提供 JSON 数据和权限校验。两者通过 RESTful 接口通信,互不干扰。你要改首页排版,不需要动后端;你要调整藏品字段校验,也不需要碰前端。

2. 数据库模型设计:藏品、展柜与用户权限如何落表

2.1 核心表结构

很多项目做到一半推倒重来,问题多半出在表结构没设计好。馆藏系统的核心是“藏品”,但藏品的属性并不是一个简单的单表能装下的。我做表设计的时候,至少运营了五个核心表外加三张中间表。

藏品主表 collection_item

字段类型说明
idbigint 主键藏品ID
collection_novarchar(50)藏品编号,唯一索引
namevarchar(100)藏品名称
dynastyvarchar(50)朝代/年代
materialvarchar(50)材质
size_descvarchar(200)尺寸描述
statustinyint1-展览中 2-库房 3-修复中
is_publictinyint是否对外展示
create_timedatetime入馆时间

分类表 category:因为历史藏品必然涉及多级分类(一级如“书画”“青铜”“陶瓷”,二级如“书法”“绘画”),我用了 parent_id 自关联,这样前端可以做成树形结构。

用户表 sys_user:账号、密码(BCrypt加密存储)、手机号、角色标识。

角色表 sys_role 和用户角色关联表 sys_user_role:多对多关系,这也是我后面做动态路由的权限依据。

多媒体资源表 collection_media:一条藏品可以对应多张图片、多个视频,所以单独成表,用 collection_id 关联。这里要特别提醒,不要再把图片字段设计成 varchar 存一个路径那么简单,因为实际运营中你会发现,一个藏品可能有实物照片、细节图、历史照片、3D模型、语音讲解等多种格式,必须一对多设计。

对了,一开始我把“借调记录”也塞进了主表,后来发现完全不对。借调是一个频繁变更的业务动作,和藏品的基本属性生命周期完全不同,必须独立成表 collection_loan_record,记录借入方、借出日期、预计归还日期、经办人。

2.2 MyBatis 映射与关联查询的关键处理

表设计好后,真正的难点在 MyBatis 的关联查询上。因为你要在页面列表里显示“藏品名称 + 封面图 + 分类 + 状态”,而数据分散在四张表。

我的做法是在 collection_item 实体里加了一个冗余的categoryName字段和coverUrl字段,但不建表字段,只用来接收联表查询结果。在 XML 里这样写:

<resultMap id="CollectionItemVO" type="com.museum.entity.CollectionItem"> <id column="id" property="id"/> <result column="name" property="name"/> <result column="categoryName" property="categoryName"/> <result column="coverUrl" property="coverUrl"/> </resultMap> <select id="selectPageWithInfo" resultMap="CollectionItemVO"> SELECT ci.id, ci.name, c.name AS categoryName, (SELECT m.file_url FROM collection_media m WHERE m.collection_id = ci.id AND m.is_cover = 1 LIMIT 1) AS coverUrl FROM collection_item ci LEFT JOIN category c ON ci.category_id = c.id <where> <if test="keyword != null and keyword != ''"> AND (ci.name LIKE CONCAT('%', #{keyword}, '%') OR ci.collection_no LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND ci.status = #{status} </if> </where> ORDER BY ci.create_time DESC LIMIT #{offset}, #{pageSize} </select>

为什么封面图要用子查询而不是 LEFT JOIN media 表?这是经验教训。一条藏品的媒体文件可能是 5 张、10 张,如果直接 LEFT JOIN,会查出重复行,分页总数也会出错。用子查询取第一条,正好避免这个问题,而且 MySQL 对这种带 LIMIT 1 的关联子查询优化得还不错。

MyBatis 里<where>标签非常值得养成习惯,它能自动处理第一个条件前的 AND,省去拼接 SQL 时的各种 if 判断烦恼。

2.3 索引设计别偷懒

我见过太多人做完表结构,一行索引都不建,等数据到几万条才来叫慢。收藏品系统需要考虑的索引至少包括:

  • collection_no唯一索引
  • category_id普通索引
  • collection_media表的collection_id索引
  • collection_loan_record表的collection_id和loan_status组合索引

MySQL 联合查询的性能瓶颈基本都在关联字段的索引上。这里不用追求过度设计,覆盖最频繁的查询路径就可以。

3. 后端核心实现:SpringBoot 接口层与业务层的拆解

3.1 Maven 工程搭建与配置细节

后端我用 Maven 做依赖管理。这里有一个非常容易踩的坑——SpringBoot 版本高到离谱,导致依赖冲突查半天查不出来。

我最终用的是 SpringBoot 2.7.18,这个版本是 2.x 的最后一个维护版本,稳定性和第三方兼容性都验证得差不多了。选版本的时候可以遵循一个经验:不要选刚发布的大版本(如 3.x 刚出的时候),要选那个大版本里的小版本号最大的。

pom.xml核心依赖很简单:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

这里要多说一句,mybatis-spring-boot-starter的版本不受 SpringBoot 父工程管理,必须自己指定。很多人直接不写 version,然后启动报一堆奇怪的 Bean 注入错误,就是因为它默认拉了一个和你 SpringBoot 完全不兼容的版本。

application.yml里的关键配置是这样的:

spring: datasource: url: jdbc:mysql://localhost:3306/museum_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: yourpassword 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 server: port: 8080

配置里三个非常容易忽略的参数:

  • useSSL=false:MySQL 8 默认走 SSL,而本地测试环境根本没配 SSL 证书,不关掉会连不上。
  • serverTimezone=Asia/Shanghai:不设时区,查询时间字段会和本地差 8 小时。
  • allowPublicKeyRetrieval=true:MySQL 8 使用 caching_sha2_password 认证时,连接工具需要先拿到 RSA 公钥,不配置这个会报Public Key Retrieval is not allowed。

3.2 登录认证与权限拦截:基于 JWT 的一套简洁方案

这个系统有管理员、内容编辑、游客三种角色,Or 准确说,游客不需要登录也能看公开藏品,管理端才需要登录。所以权限必须区隔开。

我的方案是 JWT + 拦截器。用户登录成功后,后端签发一个带用户ID和角色标识的 token,前端把 token 存在 localStorage 里,每次 axios 请求都带上Authorization: Bearer <token>。

后端拦截器做两件事:

  1. 校验 token 是否有效,无效直接返回 401。
  2. 读取用户角色,判断接口是否允许访问。管理端接口统一放在/api/admin/**路径下,游客能访问的/api/portal/**不校验登录。

拦截器核心逻辑:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); if (uri.startsWith("/api/portal") || uri.equals("/api/auth/login")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } // 解析token,校验通过则放行 try { Claims claims = Jwts.parser().setSigningKey(secretKey) .parseClaimsJws(token.replace("Bearer ", "")).getBody(); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } } }

要注意,拦截器只是第一道防线,管理端接口的 Service 层里还要再做一次角色判断,防止有人绕过网关直接调用内网接口。真正的安全不是一个 token 就完事,而是分层防御。

3.3 文件上传:用 MinIO 保存藏品多媒体文件

馆藏系统最核心的资源,除了结构化数据,就是图片和视频。一开始我想直接把文件存在服务器的某个目录,后来发现不行——前后端分离部署时,后端可能会被放到一台独立服务器,Nginx 静态代理未必能方便地指到同一个目录;而且文件多了以后,单个目录文件数量过大会影响访问速度。

所以我加入了 MinIO(对象存储服务)。它是开源的,兼容 S3 协议,自己一台服务器就能跑,不用依赖云厂商。SpringBoot 集成 MinIO 的关键代码:

// 在 pom.xml 引入 minio 依赖 <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency> @Configuration public class MinioConfig { @Value("${minio.endpoint}") private String endpoint; @Value("${minio.access-key}") private String accessKey; @Value("${minio.secret-key}") private String secretKey; @Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } } // 上传接口 public String upload(MultipartFile file, String bucketName) throws Exception { boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); if (!bucketExists) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); } String objectName = System.currentTimeMillis() + "_" + file.getOriginalFilename(); minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return endpoint + "/" + bucketName + "/" + objectName; }

我把图片资源放到collection-imagesbucket,视频放到collection-videosbucket,规则清晰,后续要迁移到云存储也方便,因为接口就是 S3 兼容的。

文件上传一定要做类型和白名单校验,不能只靠前端拦截。后端要检查文件的 content-type 和大小,否则有人直接拿脚本往你 MinIO 里传恶意文件,图片目录就会变成文件托管站,这是非常实际的安全隐患。

3.4 视频播放的实现:m3u8 流媒体方案

线上历史馆藏系统不只是图片展示,很多馆藏会有视频资料,比如口述历史采访、文物修复过程、展厅全景漫游。但是直接上传 mp4 放在浏览器里播,有几个问题:

  • 大 mp4 文件首屏加载慢。
  • 拖进度条时需要整文件缓冲,用户体验差。
  • 移动端兼容性不统一。

所以视频模块我采用了 m3u8 切片方案。核心思路是把视频切成一个个很小的 ts 分片,再生成一个 m3u8 索引文件,前端用 HLS 协议播放。这个方案的优势是加载快、拖动进度条秒开、断点续播天然支持。

我当时选了 ffmpeg 来做切片,命令大概是:

ffmpeg -i input.mp4 -c:v h264 -c:a aac -hls_time 10 -hls_list_size 0 -f hls output.m3u8

这里几个参数的意思是:

  • -hls_time 10:每个分片 10 秒。
  • -hls_list_size 0:生成的 m3u8 列表保留所有分片,不删除旧文件,这个必须加,否则播放到一半请求不到旧分片。
  • -c:v h264:用 H.264 编码,兼容性最好,浏览器不需要额外装解码器。

前端 Vue 播放 m3u8,我一开始试过video.js,配置略重,后来改用hls.js这个轻量方案,几行代码就能跑:

import Hls from 'hls.js'; if (Hls.isSupported()) { const hls = new Hls(); hls.loadSource(videoUrl); hls.attachMedia(videoElement); } else { // 兜底方案:用原生 video 播放,但兼容性会差一些 videoElement.src = videoUrl; }

如果你想让编码杂一些的视频也能播放,建议用 H.264 + AAC 这个老少通吃的组合。

4. 前端实现:Vue 动态路由与组件化开发

4.1 环境搭建与工程初始化

前端这块我使用 Vue CLI 建的工程。之所以用 Vue CLI 而不是 Vite,理由可能有点“土”,但很实际——我当时用 Vite 搭过一次,构建确实快,但某些旧依赖在 Vite 下会报兼容性警告,折腾成本高于收益。稳定的项目,工具链成熟比速度快更重要。

Node 版本建议 16.x 以上,npm 装依赖容易出各种问题,惯用的解决办法是先清理缓存再装:

npm cache clean --force npm install

如果遇到 node-sass 装不上的情况,稳妥的办法是用 sass 替换,或者干脆换dart-sass。这个项目我直接用了 sass 的现代编译版本,避免一路踩原生模块编译的坑。

4.2 动态路由与权限控制

管理端不同角色看到的菜单和页面不一样。比如内容编辑只能进入藏品管理界面,而系统管理员还能看到用户管理界面。这种需求如果只靠前端硬编码路由,加一个角色就要改一次代码,太笨了。

我采用“后端返回路由表,前端动态添加”的方案。用户登录后,后端根据其角色返回允许访问的路由配置:

[ { "path": "/admin/collections", "component": "collection/index", "meta": { "title": "藏品管理", "roles": ["admin", "editor"] } }, { "path": "/admin/users", "component": "system/user", "meta": { "title": "用户管理", "roles": ["admin"] } } ]

前端拿到这份配置后,用router.addRoute动态注册:

const buildRoutes = (menus) => { const compMap = { 'collection/index': () => import('@/views/admin/collection/index.vue'), 'system/user': () => import('@/views/admin/system/user.vue') }; return menus.filter(item => item.meta.roles.includes(userStore.role)) .map(item => ({ path: item.path, component: compMap[item.component], meta: item.meta })); }; const routes = buildRoutes(menuData); routes.forEach(route => router.addRoute('admin', route));

有个细节值得注意:component字段在前端匹配组件时,我使用的是import()懒加载,而不是直接写死组件引用。这样可以保证“该用户访问不到的路由,在打包产物里也不包含对应代码”,从代码层面也隔离了权限,而不是仅仅隐藏菜单。

另外,每次刷新页面后状态会丢失,所以刷新时要做一次 token 校验和路由重新加载,否则刷新后就 404 或者跳回登录页。

4.3 藏品展示页面的实现

门户端的藏品展示页是用户最直接接触的部分,我在设计上走了“卡片瀑布 + 分类筛选 + 搜索”的路线。

列表接口的分页查询我用的是自定义的分页参数,没有引入 PageHelper。原因有两个,一是这个系统分页查询逻辑并不复杂,手写 LIMIT 更可控;二是 PageHelper 的拦截器偶尔会碰到多数据源 SQL 解析出错的问题,引入它徒增复杂度。

前端核心交互代码:

// 列表请求参数 const queryParams = { pageNum: 1, pageSize: 12, categoryId: null, keyword: '' }; const fetchList = async () => { const res = await getCollectionList(queryParams); collectionList.value = res.data.records; total.value = res.data.total; }; // 搜索框防抖处理 const debouncedSearch = debounce(() => { queryParams.pageNum = 1; fetchList(); }, 300); // 简单防抖函数 const debounce = (fn, delay) => { let timer = null; return function(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; };

搜索输入做防抖的目的很简单——用户每敲一个字就请求一次接口,用户体验反而差。300 毫秒的防抖既能保证实时性,又不会把后端打崩。

藏品详情页我做了“左右结构”:左边是图片轮播和视频播放区,右边是藏品的详细信息列表(编号、朝代、材质、尺寸、描述)。这种布局很常规,但非常贴合博物馆类网站的浏览习惯。

4.4 关于 Vue 显示 PDF 的一个处理

收藏品系统中经常有“藏品档案 PDF”需要在线预览,比如文物修复报告、鉴定证书。Vue 里直接展示 PDF,我的经验是:

  • 浏览器原生支持 PDF 预览的,直接把 PDF 地址塞进<iframe>就行,Chrome、Edge 都能显示。
  • 如果想要完整的播放控制、缩放、下载限制,可以用pdf.js。
  • 注意跨域问题。如果 PDF 放在 MinIO 里,而 MinIO 和前端域名不一致,需要给 MinIO 配置跨域规则。

我最终的做法是给 MinIO 配置了 CORS 规则,让/documents/**路径允许跨域访问,前端用<iframe>嵌入 PDF 地址,效果很稳定。

5. 部署全流程:从本地到云服务器

5.1 服务器环境准备

部署这块,我的建议是不要在一开始就追求 K8s、Docker Compose 那套,先把单机部署跑通,后面再容器化不迟。

服务器环境需要准备的东西:

  • JDK 1.8 或 11
  • Maven 3.6+
  • Node.js 16+
  • Nginx
  • MySQL 8.0
  • MinIO

数据库部署我用的是 rpm 方式安装的 MySQL 8.0。这里有一个易错点,MySQL 8 安装完默认的 root 密码不在日志里,而是在/var/log/mysqld.log里,需要:

grep 'temporary password' /var/log/mysqld.log

然后登录修改密码:

ALTER USER 'root'@'localhost' IDENTIFIED BY 'YourNewPassword';

注意 MySQL 8 默认密码策略要求至少 8 位且包含大小写字母和特殊字符,如果只是个人项目想设简单密码,需要先降低策略:

SET GLOBAL validate_password_policy = LOW;

5.2 后端打包与启动

后端打包之前,先把application.yml里的数据库地址、MinIO 地址从本地 localhost 改成服务器实际 IP。

然后使用 Maven 打包:

mvn clean package -DskipTests

打包完成后,jar 文件在target/目录下。启动命令:

nohup java -jar historical-collection-1.0.0.jar \ --spring.profiles.active=prod \ > app.log 2>&1 & echo $!

关键参数解释:

  • --spring.profiles.active=prod:加载application-prod.yml的配置,这样可以做到本地环境和生产环境配置隔离。
  • nohup ... &:让进程在后台运行,关掉终端也不会被杀掉。
  • > app.log 2>&1:把标准输出和错误输出都写进日志文件,排查问题用。

启动后验证接口:

curl http://localhost:8080/api/portal/collection/list

如果返回 JSON 数据且没有报错,后端就起来了。

5.3 前端构建与静态资源托管

前端的构建很简单:

npm run build

构建产物在dist/目录。有两种部署方式,我最初用方式一,后来切到了方式二:

方式一:前端产物复制到 SpringBoot 的 static 目录

把dist/的内容复制到后端src/main/resources/static/下,重新打包。这样做的好处是不需要单独配 Nginx,一个 Java 服务跑所有。但缺点也很明显——前端改动后需要重新打包后端,耦合了。

方式二:Nginx 托管前端,反向代理后端

这是标准的前后端分离部署方式。前端构建产物放在/usr/share/nginx/html/historical/,Nginx 配置:

server { listen 80; server_name museum.example.com; root /usr/share/nginx/html/historical; index index.html; # 前端路由 history 模式,必须配置 location / { try_files $uri $uri/ /index.html; } # 后端 API 反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; 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 /collection/video/ { proxy_pass http://127.0.0.1:8080/collection/video/; proxy_buffering off; proxy_read_timeout 300s; } }

这里try_files $uri $uri/ /index.html;这句非常关键。因为 Vue Router 用了 history 模式,如果用户直接刷新/collection/detail/123这个地址,Nginx 会去找这个物理路径,肯定是 404。这句配置的作用就是兜底到index.html,再让 Vue Router 接管路由。

proxy_buffering off是我针对视频流加的一个优化,关闭代理缓冲后,播放器可以边下边播,不会等 Nginx 缓冲完整个响应,对 m3u8 分片播放很友好。

5.4 把 Vue 打包文件放进 SpringBoot 的取舍

热搜词里有“vue打包放进springboot中”,我再补充说下这个方案。如果你不想部署 Nginx,或者你的服务器只有一台且内存紧张,完全可以采用方式一。实现很简单:

npm run build cp -r dist/* ../backend/src/main/resources/static/ cd ../backend mvn clean package -DskipTests java -jar target/app.jar

这样访问http://localhost:8080/index.html就能看到前端页面。

但要接受一个现实:前端和后端从此绑定在一个进程里,前端发版必须重新打包,而且静态资源无法利用 Nginx 做缓存和压缩。所以这个方案适合个人项目、毕业设计演示或者临时环境,正式运营我还是建议用方式二。

6. 常见问题与排查实录

6.1 MySQL 连接错误:留给新手的几个经典报错

这个项目里我前后遇到过几个 MySQL 连接问题,都很有代表性。

报错一:Public Key Retrieval is not allowed

解决方式在配置里已经写了,allowPublicKeyRetrieval=true必须加上。遇到这个错,先不用怀疑服务器防火墙,先看你的 JDBC URL 是不是少了参数。

报错二:The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized

这是中文系统下 MySQL 的时区设置问题。解决方案是在 JDBC URL 加serverTimezone=Asia/Shanghai。

报错三:Navicat 连接 MySQL 8 报caching_sha2_password错误

MySQL 8 默认认证插件是caching_sha2_password,老版本 Navicat 不支持。有两种解法:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password';

或者升级 Navicat 到新版本。但要注意,这两种方案在 MySQL 8.0.34 之后,mysql_native_password插件默认被禁用了,需要先启用再修改,不然会报Plugin 'mysql_native_password' is disabled。

6.2 SpringBoot 版本太高引发的兼容问题

说句实在话,一个项目里最耗时间的往往是“版本冲突”。我遇到过把 SpringBoot 升到 3.0 后,MyBatis starter 直接跑不起来的情况。原因很简单:SpringBoot 3 基于 Jakarta EE 9,包名从javax.*改成了jakarta.*,很多旧版 starter 根本没有适配。

所以我的建议非常明确:

  • 如果项目要稳,用 SpringBoot 2.7.x。
  • 如果要上 3.x,必须确认所有依赖都支持 Jakarta。

另外还有一个常见的坑是spring-boot-maven-plugin的版本和 SpringBoot 版本不匹配,启动时提示找不到主类。解决办法是显式指定插件版本。

6.3 Vue 打包后资源路径 404

npm run build后,默认的资源引用路径是绝对路径/js/app.js。如果你部署在域名的子路径下,比如http://ip:80/historical/,那这个绝对路径会直接 404。

解决方式很简单,在vue.config.js里设置:

module.exports = { publicPath: process.env.NODE_ENV === 'production' ? './' : '/' };

把 publicPath 改成相对路径./,这样打包出来的资源引用会变成相对路径,不管你把前端文件放在哪个子目录都能正常加载。如果在 Nginx 下做代理,还可以配置server_name和alias的方式统一处理。

6.4 MyBatis 缓存问题与排查

MyBatis 的缓存,一开始我差点掉进坑里。MyBatis 有一级缓存(SqlSession 级别)和二级缓存(namespace 级别)。

一级缓存默认开启,在一个 SqlSession 中,两次相同的查询不会请求数据库。这在事务环境下没问题,但如果同一个 SqlSession 跨多个请求复用,就可能查到旧数据。实际上 Spring 管理的 SqlSession 每次请求都是新的,所以一级缓存导致的问题很少见。

二级缓存默认是关闭的。网上很多教程说“开启二级缓存提高性能”,但实际上在这个馆藏系统里,我建议别开。因为二级缓存生效的前提是对该表的查询和修改都走同一个 namespace,一旦出现多表联查,缓存的一致性就非常难保证。举个例子,collection_item改了数据,但查询走的是关联了collection_media的联表 SQL,结果这个联表 SQL 命中的缓存没被清掉,展示端就出现了脏数据。

所以如果要做缓存,我的建议是在 Service 层用 Redis 做显式缓存,自己控制失效策略,而不是依赖 MyBatis 的二级缓存。控制权在自己手里,才睡得着觉。

6.5 视频播放不了:m3u8 常见问题速查

视频这块我也踩了不少坑,集中说一下:

表现原因解决
黑屏但能拖动进度条视频编码不是 H.264用 ffmpeg 重编码为 h264
播放到一半卡住Nginx 代理缓冲导致分片请求超时设置proxy_buffering off和proxy_read_timeout 300s
手机浏览器无法播放部分手机浏览器不支持 hls.js 的 MSE可以引入mpegts.js或者在后端加一层转码为 mp4 的兜底接口
跨域导致无法加载 m3u8MinIO 或视频服务器未配置 CORS给存储桶配置允许跨域访问的规则

最让我头疼的是第二种“播放到一半卡住”。排查时用浏览器的网络面板看了半天,发现是 Nginx 默认的 60 秒超时导致加载 ts 分片失败。把超时时间调大后,问题立即消失。经验就是:视频响应的超时时间必须单独设置,不能套用普通 API 的超时配置。

7. 部署上线后的下一步扩展方向

这套系统做完并稳定运行后,我发现要让它真正服务于“线上历史馆藏”的定位,后续还可以考虑几个扩展方向,也分享给大家做参考。

全文检索。当藏品数据量达到几千条以后,MySQL 的 LIKE 查询会越来越吃力,尤其是“名称 + 描述 + 背景故事”全文检索场景。可以引入 Elasticsearch,或者先用 MySQL 的全文索引过渡。藏品系统的检索体验直接决定用户留存,这块值得投入。

数据统计与可视化。馆方很关心“哪些藏品最受欢迎”“哪个时间段访问量最高”。可以对接一个轻量级的数据采集( eg. 访问日志写入 MySQL),然后在管理后台用 ECharts 做访问趋势、分类热度、藏品浏览 Top10 的可视化看板。这个功能做出来后,系统从“管理工具”变成了“决策工具”,价值会再上一个台阶。

多媒体文件的转码服务。目前视频切片是离线用 ffmpeg 手动处理的。如果要支持运营人员自助上传视频后自动切片,需要加一个异步任务队列(比如用 RabbitMQ + ffmpeg 做转码任务),上传完成后自动生成 m3u8。这个架构本身也不复杂,值得做。

权限和审计日志。博物馆行业的馆藏数据有一定敏感性,管理端的每次操作(修改、借调、删除)都应该留痕。加一张操作日志表,在 Service 层用 AOP 切面统一记录操作人、操作时间、操作内容,对后续追责和合规也有帮助。

我个人在实际操作中的体会是,一个馆藏系统真正难的不是技术,而是对业务的理解。技术痛点其实就那么几个——表设计、权限控制、文件存储、部署,解决了就顺畅了。而业务上的持续优化——如何让一件文物背后的故事被更多观众看到,如何让馆藏管理员使用起来更顺手——才是这个系统活下来的根本。希望大家在自己的项目里也能把这两点结合起来,不要只做一个“能跑”的 demo,而是做成一个“有人用”的平台。

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

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

立即咨询