☰
SpringBoot2+Vue3+MyBatis-Plus馆藏管理系统开发实战与架构复盘
2026/10/9 9:27:56 网站建设 项目流程

去年接了一个挺典型的文博行业项目——给一家地方文化机构做线上馆藏管理系统。这类项目看起来就是标准的增删改查,但真正做起来,业务上的约束条件比普通后台管理系统多不少:文物信息的字段结构严格、出入库必须留痕、图片和档案要能长期保存、查询条件组合多变。最终我确定的技术栈是 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,整套源码带文档交付,前后端加起来大概二十几个模块,前后开发周期约六周。

这篇文章就把整个项目的核心设计和开发过程完整复盘一遍,适合正在做类似管理系统、或者打算用这套技术栈接项目的人参考。我不会只贴代码,更重要的是把每一步的选型逻辑、业务建模思路、以及开发过程中实际踩过的坑都讲清楚。

1. 为什么需要线上馆藏系统:业务场景与需求梳理

1.1 传统管理方式的问题

很多中小型文博机构的信息化程度其实很低,藏品信息还停留在 Excel 表格和纸质登记卡上。我接触到的实际情况是:文物编号靠手写,领出去办展览要翻纸质的出入库登记本,找一件藏品的存放位置得先找台账,再打电话问保管员。真要用的时候,要么信息对不上,要么记录缺失。

这种模式有几类很典型的问题。一是查询效率极低,想按年代、质地、级别筛选一批文物,Excel 里的数据要么字段不全,要么格式混乱,筛出来的结果根本不敢直接用。二是记录容易丢失,纸质台账年久破损、人员交接造成断档,都是常见的事。三是没有权限控制,任何人都能翻阅全部档案,敏感信息难以管控。

1.2 系统的核心边界:管什么、不碰什么

做这个项目,第一件事不是写代码,而是把业务边界划清楚。我根据客户的实际需要,把系统边界定在这几块:

  • 藏品档案管理:文物的基础信息、图片、级别、质地、年代、来源、尺寸、重量等,支持新增、编辑、查询和导出。
  • 库房位置与状态管理:每件藏品有明确的保管位置(库房-柜-层)和当前状态(在库、出借、修复、下架)。
  • 出入库登记:任何藏品离开库房都必须留记录,记录经办人、时间、事由和去向。
  • 修复记录:藏品经过修复时登记修复内容、修复人和修复日期。
  • 用户与权限:不同角色看到和操作的范围不同,比如普通工作人员只能查看和编辑藏品信息,管理员可以管理用户,访客只能浏览公开信息。

系统不碰的内容也讲清楚了:不做自动化的温湿度监控对接,不做三维扫描展示,也不做复杂的财务流程。边界划定了,开发才不会越做越多。

1.3 角色与功能清单

整个系统根据使用对象拆成三类角色:

角色核心功能
系统管理员用户管理、角色权限分配、系统参数配置、数据备份入口
业务管理人员藏品档案维护、出入库登记、修复记录、位置调整、图片上传
访客/浏览用户按条件检索藏品公开信息、查看详情,无修改权限

这个权限模型并不复杂,但已经能覆盖绝大多数中小型馆藏机构的日常管理需求。权限部分我用了基于角色的访问控制,接口层面通过 SpringBoot 拦截器和注解实现,页面层面通过 Vue3 的路由守卫控制,两部分配合起来权限校验才算完整。

2. 技术栈选型:从零搭建时的取舍与理由

2.1 后端为什么选 SpringBoot2

现在 Java 后端生态里,SpringBoot3 已经在逐步普及,但我在这个项目里仍然选了 SpringBoot2,主要原因有两个。

第一是生态兼容性稳定。MyBatis-Plus 对新版 SpringBoot3 的支持一度有过兼容坑,虽然现在基本都跟上了,但一些中间件、工具库、文档方案在 SpringBoot2 上最成熟稳定,跑到生产环境不容易闹脾气。

第二是团队技术栈熟悉。项目交付后客户方还要二次开发和维护,他们现有团队最熟悉的就是 SpringBoot2 的用法。选择技术栈不能只看技术新旧,还得考虑后续维护成本。SpringBoot2 配合 JDK8,是目前 Java Web 交付项目里最省心的组合之一。

2.2 ORM 选择 MyBatis-Plus:通用 CRUD 的价值

这个项目有大量的单表 CRUD 操作,如果全部手写 MyBatis XML,会产出大量重复的selectById、insert、updateById之类的代码,既耗时又难维护。

MyBatis-Plus 在这里的价值非常直接:它内置了BaseMapper<T>提供通用 Mapper 方法,普通的单表增删改查一行代码都不用写 SQL。在这个项目中,我统计过,约 60% 的接口直接复用它的insert、selectPage、updateById、deleteById,真正需要手写 SQL 的只有多表联查和复杂条件统计的部分。

同时,MyBatis-Plus 的分页插件PaginationInnerInterceptor做得很好用,几行配置就能让selectPage返回正确的分页结果。这种通用 CRUD 服务能力,在管理类系统的开发里确实省时间,而且是标准的、可持续维护的省时间方式。

2.3 数据库为什么用 MySQL8.0

MySQL8.0 已经不算是新版本了,但它在这个项目里有几个实际收益。

一是默认字符集是 utf8mb4,可以直接存表情符号和生僻字。馆藏系统里经常出现各种生僻字(文物名里有大量古代字),这在 MySQL5.7 时代是个坑,utf8mb4 虽然不是新特性,但 8.0 默认就启用,省去了建库时的字符集配置。

二是窗口函数很好用。在做文物统计报表的时候(比如按年代统计藏品数量、按类别统计分布),用窗口函数可以一行 SQL 搞定,不需要在 Java 代码里做复杂的流处理。

三是安全性和持久性默认配置更合理。8.0 的默认事务隔离级别、caching_sha2_password认证方式、redo log 的容量自适应等特性,让数据库在长期运行中的稳定性更好。

2.4 前端为什么选 Vue3

前端选 Vue3 几乎是必然的。项目需要的是一个单页管理后台,Vue3 的 Composition API 让代码组织更清晰,配合 Vite 的开发体验也比旧版 Webpack 方案快很多。

更重要的是,Vue3 生态现在已经完全成熟。Element Plus 作为后台管理系统的组件库,表格、表单、弹窗、分页这些组件开箱即用,跟 Vue3 配合非常顺畅。Vue Router 4 和 Pinia 在路由和状态管理上的用法也很稳定,网上资料多,遇到问题容易排查。

前端框架选型的时候有一个原则:管理后台类项目不需要华丽,需要的是稳定组件、好维护的结构、和够用的性能。Vue3 恰好都满足。

3. 数据从哪来:馆藏系统核心表结构设计

3.1 文物主表:一张表承载核心档案

整个系统的核心就是藏品主表。我把这张表设计成扁平结构,字段尽量齐全,减少查询时的关联表数量。表名定为tb_relic,核心字段如下:

CREATE TABLE `tb_relic` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `relic_no` VARCHAR(64) NOT NULL COMMENT '藏品编号,全局唯一', `relic_name` VARCHAR(255) NOT NULL COMMENT '藏品名称', `category_id` BIGINT DEFAULT NULL COMMENT '类别ID,关联tb_category', `era` VARCHAR(100) DEFAULT NULL COMMENT '年代', `material` VARCHAR(100) DEFAULT NULL COMMENT '质地/材质', `level` TINYINT DEFAULT 0 COMMENT '级别:0未定级 1一级 2二级 3三级', `source_method` VARCHAR(100) DEFAULT NULL COMMENT '来源方式:拨交/征集/捐赠/发掘', `donor` VARCHAR(100) DEFAULT NULL COMMENT '来源单位或个人', `size_desc` VARCHAR(255) DEFAULT NULL COMMENT '尺寸描述', `weight` DECIMAL(10,2) DEFAULT NULL COMMENT '重量(kg)', `description` TEXT COMMENT '详细介绍', `status` TINYINT DEFAULT 0 COMMENT '状态:0在库 1出借 2修复 3下架', `location_id` BIGINT DEFAULT NULL COMMENT '当前位置ID,关联tb_location', `cover_image` VARCHAR(500) DEFAULT NULL COMMENT '封面图片路径', `entry_date` DATE DEFAULT NULL COMMENT '入藏日期', `registrar` VARCHAR(100) DEFAULT NULL COMMENT '登记人', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (`id`), UNIQUE KEY `uk_relic_no` (`relic_no`), KEY `idx_category` (`category_id`), KEY `idx_status` (`status`), KEY `idx_era` (`era`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='藏品主表';

这里有几个设计细节值得说明。

逻辑删除字段deleted是 MyBatis-Plus 的全局逻辑删除功能,统一加了这个字段之后,所有deleteById操作会自动变成UPDATE ... SET deleted=1,避免误删数据。这个在文物数据上尤其重要——藏品档案一旦物理删除就真没了。

唯一键uk_relic_no保证每个藏品有唯一编号。编号格式在业务层做了约束,通常是“馆藏简拼-年度-流水号”这种形式,保证人工能看懂,程序能查出来。

索引策略针对category_id、status、era建了普通索引。这三个字段是查询条件里最高频的字段,虽然单列索引在组合查询时的提升有限,但至少能保证单独按状态或按年代筛选时不走全表扫描。这张主表的数据量在一个馆藏机构场景下最多也就几万条,这个体量下这样的索引设计完全够用。

3.2 关联表设计:类别、位置与记录的拆分

主表之外,三个关联表支撑起整个业务闭环。

类别表tb_category:字段非常简单,id、category_name、parent_id、sort_order。支持两级分类,比如“陶瓷”下面可以分“陶器”“瓷器”。前端做级联选择时比较方便。

位置表tb_location:这是容易被忽略但实际使用频率很高的表。字段包括id、location_name、location_type(库房/展厅/展柜)、parent_id、sort_order。位置信息做成树形结构,可以精确到“一号库房-A柜-第三层”,切合实际库房管理习惯。

系统用户表tb_user:字段包括id、username、password(BCrypt 加密)、real_name、role(ROLE_ADMIN / ROLE_USER / ROLE_VIEWER)、status、create_time。这里密码加密很重要,直接用明文存密码的项目我已经见过太多次了,一律用 BCrypt。

这三张表虽然简单,却是系统能否落地到真实业务场景的关键,跳过其中任何一张,都会导致实际使用中大量维护数据变得别扭。

3.3 记录类表:出入库与修复日志

管理系统的价值在于历史记录可追溯。我设计了tb_stock_record和tb_repair_record两张记录表。

出入库记录表tb_stock_record:

CREATE TABLE `tb_stock_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `relic_id` BIGINT NOT NULL COMMENT '藏品ID', `record_type` TINYINT NOT NULL COMMENT '类型:1出库 2入库', `target_place` VARCHAR(255) DEFAULT NULL COMMENT '去向或来源场所', `purpose` VARCHAR(500) DEFAULT NULL COMMENT '事由', `handler` VARCHAR(100) NOT NULL COMMENT '经办人', `operator_id` BIGINT NOT NULL COMMENT '操作人用户ID', `record_time` DATETIME NOT NULL COMMENT '操作时间', `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`), KEY `idx_relic_id` (`relic_id`), KEY `idx_record_time` (`record_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出入库记录表';

修复记录表tb_repair_record的结构类似,字段包括relic_id、repair_content、repair_date、repairer、cost和remark。

这两张表本质上是审计日志,但是它们又不只是日志,因为业务人员在日常工作中要反复查询这些记录,追溯一件文物的移动轨迹和修复历史。所以我把它们做成正式的业务表,而不是简单地写在日志文件里。

3.4 MySQL8.0 JSON 字段的应用

藏品主表里我用了一个 JSON 字段:

`extra_attrs` JSON DEFAULT NULL COMMENT '扩展属性,以JSON格式存储'

为什么要用 JSON 字段?因为馆藏业务中最常见的痛点就是不同类别的文物属性差异极大。书画有“作者”“装裱形式”,铜器有“铸造工艺”,玉器有“沁色”——如果把这些属性全部平铺成表的字段,表会变得非常宽,而且绝大多数行里那些字段都是 NULL。

JSON 字段的好处是可以让业务管理员在前端动态录入自定义属性对(key-value),存到这个 JSON 字段里,查询时可以配合 MySQL8.0 的JSON_CONTAINS或者JSON_EXTRACT函数做筛选。

我知道有些人会担心 JSON 字段的查询性能,但在数据量几万条的场景下,这种担心是多余的。从实际项目经验来看,这个 JSON 字段很好地兼顾了结构的灵活性和数据的可扩展性,等后续真的有强查询需求了,再拆独立的属性表也来得及。在业务模型不稳定的时候,JSON 字段是成本最低的容错方案。

4. 后端实现:SpringBoot2 + MyBatis-Plus 分层构建

4.1 工程结构与通用 CRUD 的落地

后端工程我按标准的分层架构来组织:controller→service→mapper,中间夹一层entity和dto。

一个重要决定是直接用 MyBatis-Plus 的IService + ServiceImpl基类。这样每个业务 Service 接口只需要继承IService<T>,实现类继承ServiceImpl<Mapper, T>,就自带了一套完整的通用方法。比如学历管理模块里的RelicServiceImpl,写出来的代码非常简洁:

@Service public class RelicServiceImpl extends ServiceImpl<RelicMapper, Relic> implements RelicService { // 自定义业务方法写在这里 // 基础CRUD全部由父类提供 }

在 Controller 里调用时,也不需要再自己手写 POST 接口来做最简单的插入。比如新增藏品的接口:

@PostMapping("/relic") public Result<Void> addRelic(@RequestBody Relic relic) { relicService.save(relic); return Result.success(); }

这种通用 CRUD 的方式,开发速度提升非常明显。凡是只做单表操作的接口,基本不需要单独写实现逻辑。

4.2 多条件检索与分页:前端查询的完整支撑

馆藏系统的核心功能是按各种条件组合查藏品,比如“找所有唐代的、三级以上的、材质是玉器的、当前在库的藏品”,这就是典型的多条件组合查询。这个场景我用 MyBatis-Plus 的LambdaQueryWrapper+ 分页插件来完成。

Controller 接收的参数用 DTO 封装,大致如下:

public class RelicQueryDTO { private String relicName; // 名称模糊 private String era; // 年代精确 private Integer level; // 级别 private Long categoryId; // 类别 private Integer status; // 状态 private String material; // 材质模糊 private Integer pageNum = 1; private Integer pageSize = 10; }

Service 层的查询方法:

@Override public Page<Relic> pageRelic(RelicQueryDTO dto) { Page<Relic> page = new Page<>(dto.getPageNum(), dto.getPageSize()); LambdaQueryWrapper<Relic> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(dto.getRelicName()), Relic::getRelicName, dto.getRelicName()) .eq(StringUtils.hasText(dto.getEra()), Relic::getEra, dto.getEra()) .eq(dto.getLevel() != null, Relic::getLevel, dto.getLevel()) .eq(dto.getCategoryId() != null, Relic::getCategoryId, dto.getCategoryId()) .eq(dto.getStatus() != null, Relic::getStatus, dto.getStatus()) .like(StringUtils.hasText(dto.getMaterial()), Relic::getMaterial, dto.getMaterial()) .orderByDesc(Relic::getCreateTime); return this.page(page, wrapper); }

我习惯用LambdaQueryWrapper而不是普通 QueryWrapper,因为它是类型安全的——字段名写错了会在编译期就报错,而不是等运行期才发现 SQL 异常。这个特点在维护阶段特别有用,重构实体字段名时不会引发隐蔽的 SQL 断裂。

分页插件配置同样简单:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

配置完之后,selectPage返回的Page对象可以直接拿到total、records、current等字段,返回给前端做分页展示,完全不用自己写 COUNT 查询。

4.3 手写 SQL 的场景:多表联查与统计报表

虽然 MyBatis-Plus 覆盖了大多数场景,但有两个场景我是手写 SQL 的。

第一个是联表查询。比如查询藏品列表时需要同时显示类别名称、位置名称,而这些名称在tb_relic表里只有 ID,这时候用 MyBatis-Plus 的selectPage就不够用了。我在 Mapper 接口里定义了一个自定义分页查询:

public interface RelicMapper extends BaseMapper<Relic> { IPage<RelicVO> selectRelicPage(IPage<?> page, @Param("dto") RelicQueryDTO dto); }

对应的 XML 里写了一个带 LEFT JOIN 的查询,同时用<where>标签动态拼接条件。这种灵活性是 MyBatis 系列产品的传统优势,也是我始终不愿意换成纯 JPA 的原因——JPA 在复杂查询时反而要把简单问题复杂化。

第二个是统计报表。比如首页仪表盘要显示“按级别分布的藏品数量”,在 MySQL8.0 下用一条简单的分组查询就能搞定:

SELECT level, COUNT(*) AS cnt FROM tb_relic WHERE deleted = 0 GROUP BY level;

如果要做更复杂的“按年代分布”统计,我会用 MySQL8.0 的窗口函数或者CASE WHEN语法,这里不展开细说,但结论是:能用 SQL 完成统计的,就不要在 Java 代码里循环算,前者性能和可维护性都更好。

4.4 事务边界与图片管理

图片存储是这个项目里容易被忽略的部分。文物图片往往分辨率很高,一个文件几十兆,HTTP 请求直接传 base64 是不现实的。我的做法是:

  • 前端先把图片通过独立的上传接口传到服务器,保存为本地文件(或者对象存储,本项目里是服务器本地目录)。
  • 上传接口返回文件的访问 URL,前端再把 URL 作为字段值随表单一起提交给后端。
  • 图片的访问路径通过 SpringBoot 配置的静态资源映射暴露出来。

图片与业务数据的写入时序上有个隐蔽的坑:如果先上传了图片,但后续表单提交失败,服务器上会残留一个无用文件。虽然这个项目里我因为简化处理没有做定时清理,但负责任的做法是定期扫描孤儿文件并删除,或者在业务接口中先保存数据库记录再做图片关联。

事务方面,我把“出入库登记”这个操作明确地设计成一个事务性的方法:插入一条出库记录 + 更新藏品的当前状态为出借,两个操作必须同时成功或同时失败。代码如下:

@Transactional(rollbackFor = Exception.class) public void stockOut(StockRecordDTO dto) { stockRecordService.save(buildRecord(dto, 1)); relicService.lambdaUpdate() .eq(Relic::getId, dto.getRelicId()) .set(Relic::getStatus, 1) .update(); }

@Transactional注解的rollbackFor = Exception.class很重要——默认情况下 Spring 只对 RuntimeException 回滚,如果业务代码里抛出了受检异常,不加这个参数事务是不会回滚的。这点我在项目代码里加了注释,避免后来维护的人踩坑。

5. Vue3 前端:从列表页到表单页的完整实现

5.1 工程脚手架:Vite + Element Plus 的搭建

前端工程我用 Vite 创建,Vue3 必须配 Vite,这是现在的共识。初始化命令一行就够了:

npm create vite@latest relic-web -- --template vue

随后安装核心依赖:

npm install vue-router@4 pinia axios element-plus @element-plus/icons-vue

工程目录结构如下:

src/ ├── api/ // 按模块封装接口请求 ├── assets/ // 静态资源 ├── components/ // 公共组件 ├── router/ // 路由配置 ├── store/ // Pinia 状态管理 ├── views/ // 页面 │ ├── relic/ // 藏品管理相关页面 │ ├── system/ // 系统管理相关页面 │ └── dashboard.vue ├── App.vue └── main.js

后台管理系统按“页面 + API + 状态”三个维度组织代码,这个结构经得起项目规模增长。

5.2 藏品管理页的完整流程

藏品管理页是系统的核心页面,功能包括三块:查询区、表格区、弹窗表单。

查询区是一个el-form,里面的控件的值直接绑定到一个queryData的响应式对象上:

const queryData = ref({ relicName: '', era: '', level: null, categoryId: null, status: null, material: '' })

点击查询按钮时调用后端接口,把queryData作为参数传过去。点击重置时把对象里的字段清空再重新加载。

表格区用el-table,列绑定relicVO的字段。这里有几个前端小技巧值得分享:

  • 状态列(在库/出借/修复/下架)在展示时用一个映射函数把数字转成中文标签,同时用el-tag组件加不同颜色,视觉上一眼能看出哪些藏品不在库。
  • 图片列用el-image的preview-src-list属性实现点击预览大图,不用额外写 Lightbox 组件。
  • 操作列固定“编辑”“出入库”“修复记录”三个按钮,通过slot-scope拿到当前行数据。

弹窗表单在新增和编辑时复用同一个el-dialog里的表单组件。表单校验用 Element Plus 内置的rules,比较常用的规则是藏品编号必填、名称必填、重量如果是数字必须是正数。例如:

const rules = { relicNo: [{ required: true, message: '请输入藏品编号', trigger: 'blur' }], relicName: [{ required: true, message: '请输入藏品名称', trigger: 'blur' }], weight: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '请输入正确的重量数值', trigger: 'blur' }] }

表单整体思路是:新增和编辑共用一个对话框组件,通过传入的row对象判空来区分是新增还是编辑。这样避免了写两套几乎一样的表单代码。

5.3 前端与后端对接的细节

这一块实际上坑最多。简单说一下 Axios 封装:

const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) }, error => { if (error.response?.status === 401) { router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )

几个细节需要特别注意。

BaseURL 用环境变量管理。开发环境通过 Vite 的代理转发到 SpringBoot 的 8080 端口,生产环境同域名下部署则直接用/api前缀,通过 Nginx 反向代理到后端服务。这是最省心的前后端联调方式,避免了跨域问题。

响应拦截器里统一处理业务状态码。后端返回结构是{ code, msg, data },code 为 200 时业务成功。将data直接返回给调用方,页面代码里就不用每个地方都做一层res.data.data的解包。

401 状态处理。后端鉴权失败时返回 401 状态码,前端拦截器里统一跳转登录页。这种集中处理比在每个页面里单独判断要干净得多。

5.4 Composition API 用起来更清晰的几个场景

在这个项目里,我大量使用了 Vue3 的 Composition API。对比旧版 Options API,它在两个场景下的优势特别明显。

第一个是多个功能块的逻辑抽取。藏品管理页里同时有查询、表格、弹窗、上传图片四块业务逻辑,如果用 Options API,所有代码都堆在data、methods、watch三个选项里,几百行代码看下来很累。Composition API 可以按业务功能拆成独立的组合式函数,比如把上传图片的逻辑单独抽到useUpload.js里,表单逻辑抽到useRelicForm.js里,每个文件只关注一块业务。

第二个是响应式数据控制更精确。ref和reactive让我可以明确知道什么数据是响应式的,什么时候需要.value,什么时候不需要。虽然学习的时候多了一点心智负担,但写维护性更强的代码是值得的。

插一句,如果你是从 Vue2 转过来的,最常见的困惑就是ref要写.value而reactive不用。我的经验是:基础类型用ref,对象类型用reactive,但如果对象需要整体替换时,用ref更好。因为reactive对象整体赋值会丢失响应性,这是新手最常见的坑。

6. 开发过程中遇到的坑与复盘

6.1 MyBatis-Plus 字段映射:当驼峰遇到下划线

第一个坑来自字段命名映射。数据库表字段是下划线风格(relic_no、create_time),实体类是驼峰风格(relicNo、createTime)。MyBatis-Plus 默认会开启驼峰映射,正常情况下没有问题。

但问题出在自定义 SQL 的返回结果映射上。如果我在 XML 里写:

<select id="selectRelicPage" resultType="com.example.dto.RelicVO"> select r.id, r.relic_no, r.relic_name, c.category_name from tb_relic r left join tb_category c on r.category_id = c.id </select>

当RelicVO里的字段定义是relicNo时,MyBatis 的自动驼峰映射通常能处理,但如果某个字段别名不一致——比如我把查询结果里的category_name映射成categoryName,XML 里没写as别名,就会导致这个字段查出来是null。这个问题的表现形式很隐蔽,因为不报错,只是某个字段没值。

解决方式是:自定义查询一律在 SQL 里写显式别名,例如select c.category_name AS categoryName。不依赖自动映射,排查问题也容易。

6.2 Vue3 响应式丢失:reactive 整体赋值的坑

Vue3 开发中最常见的一个坑,我在这个项目里也踩了。

场景是这样的:表格页面里,我定义了一个reactive对象存分页数据,然后从后端拿到数据后直接整体赋值:

const pageData = reactive({ records: [], total: 0 }) // 错误写法 pageData = res.data // 这样写直接丢失响应性,页面不会更新

正确写法是要么改pageData.records = res.data.records; pageData.total = res.data.total,要么干脆用ref:

const pageData = ref({ records: [], total: 0 }) pageData.value = res.data

用ref整体替换是符合直觉的,我现在写代码倾向于“能ref就ref”。reactive适合比较窄的场景,比如一个固定结构的表单对象,不会整体替换。

6.3 MySQL8.0 连接串的时区问题

SpringBoot 连接 MySQL8.0 时,如果 JDBC 连接串不写时区,很可能会在启动或执行 SQL 时报错Could not create connection to database server,或者时间字段在读写时差 8 小时(或者显示异常)。这是因为 MySQL8.0 的驱动强制要求确定时区。

我的连接串是这样配置的:

spring: datasource: url: jdbc:mysql://localhost:3306/relic_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true

这里有两个关键参数值得说明:

  • serverTimezone=Asia/Shanghai保证 MySQL 服务器时间与本地时间一致,避免时间偏移问题。
  • allowPublicKeyRetrieval=true是配合caching_sha2_password认证插件用的。MySQL8.0 默认的认证插件要求首次连接时获取 RSA 公钥,如果不开这个参数,有些环境下连接会报Public Key Retrieval is not allowed。

如果你确实不想在连接串里暴露这些参数,也可以在 MySQL 里把用户的认证插件改成mysql_native_password,但既然用了 8.0,我更建议用默认插件并配合allowPublicKeyRetrieval=true。

6.4 分页插件和逻辑删除的顺序问题

MyBatis-Plus 的分页插件拦截器是有顺序的。官方文档强调PaginationInnerInterceptor应该放置在拦截器链的最外层,也就是第一个 add。如果项目中同时配置了乐观锁插件、防全表更新插件,需要注意它们的顺序。

我出现过的一个问题是:加上乐观锁插件后,分页查询的total始终返回 0。排查半天发现不是业务代码的错,而是插件的顺序问题——OptimisticLockerInnerInterceptor放在分页插件前面,导致拦截器链的处理顺序不对。把PaginationInnerInterceptor调到第一个之后,问题就消失了。

插件顺序的经验是:分页插件放最外面,然后其他业务插件依次放里面。

6.5 上传图片后的回显路径问题

前后端对接图片上传时也踩过一个很小的坑。前端上传图片时,上传接口返回的是相对路径/upload/2025/xx.jpg,而后端配置了静态资源映射,浏览器实际访问时要带上完整域名或 IP。

在开发环境还好,前端代理能处理;但部署到生产环境后,如果前端页面用相对路径拼接图片地址,Nginx 可能会把请求打到前端服务上而不是后端,导致图片 404。

正确的做法是:上传接口返回的 URL 直接给完整可访问的路径(比如http://ip:9000/upload/2025/xx.jpg或通过 Nginx 统一转发的/files/2025/xx.jpg这种前缀),前端存什么就展示什么,不做二次拼接。这看起来是个小问题,实际对使用体验影响很大——图片普遍加载不出来,所有人都会觉得系统坏了。

6.6 前端分页组件的 current-page 同步

最后一个坑是用 Element Plus 的分页组件时,current-page和page-size的绑定需要使用v-model或者手动监听current-change事件。如果不小心只用:current-page=pageData.current单向绑定,点击分页按钮时,页码可能不会正确更新,或者点击后查询条件里的页码一直是 1。

正确写法是:

<el-pagination v-model:current-page="pageData.current" v-model:page-size="pageData.size" :total="pageData.total" @change="loadData" />

在 Vue3 中,v-model可以同时绑定多个属性,Element Plus 的分页组件也支持这种写法。理解了这一点,前端的分页交互就再也不会出现“点第二页还是显示第一页数据”的诡异问题了。

7. 部署与文档:让项目真正可以交付

7.1 Docker 运行 MySQL8.0

项目交付时,客户的环境不一定已经装好了 MySQL8.0,为了减少部署踩坑,我提供了 Docker 方式安装 MySQL8.0 的部署文档和脚本。

一条命令就可以把 MySQL8.0 跑起来:

docker run -d \ --name relic-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=your_password \ -e MYSQL_DATABASE=relic_db \ -e TZ=Asia/Shanghai \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0

几个参数说明:

  • -e TZ=Asia/Shanghai设置容器时区,避免和 JDBC 连接串里的serverTimezone不一致产生时间偏移。
  • -v /opt/mysql-data:/var/lib/mysql做数据目录的持久化。容器删了重建,数据不丢。这一步绝对不能省,否则一个docker rm就把所有文物档案数据弄没了,后果严重。
  • mysql:8.0没有指定小版本号,用的时候建议锁到具体小版本,比如mysql:8.0.36,保证部署环境的一致性。

容器启动后,把项目里的 SQL 脚本导入即可:

docker exec -i relic-mysql mysql -uroot -pyour_password relic_db < relic_db.sql

7.2 前端打包与 Nginx 配置

后端用 Maven 打包:

mvn clean package -DskipTests java -jar relic-backend.jar

前端打包:

npm run build

Vue3 + Vite 构建后输出到dist目录,把这个目录丢到 Nginx 的 html 目录下。然后 Nginx 配置要特别注意history 路由模式的 try_files配置:

server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; # 关键:Vue Router history 模式必须配置 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

try_files $uri $uri/ /index.html这行的意思是:当用户直接访问/relic/list这样的路由地址时,服务器找不到对应的物理文件,就回退到index.html,由 Vue Router 接管路由。

如果不配置这一行,用户刷新页面时会出现 404。这是 Vue3 history 路由部署中的头号问题,一定要写清楚。

7.3 项目文档的组织方式

标题里写了【含文档】,项目文档我按下面的结构整理,方便交付后维护:

  • 01-需求说明.md:记录需求沟通时确认的业务范围和功能清单。
  • 02-数据库设计.md:包含完整的建表 SQL、每个表的字段注释、ER 关系说明。
  • 03-接口文档.md:记录所有 RESTful 接口的 URL、请求参数、返回值示例。如果是交付给客户二次开发,建议接口文档直接用 Knife4j 自动生成,省去手工维护的麻烦。
  • 04-部署手册.md:从 Docker 安装 MySQL8.0、初始化数据库、构建后端 jar、构建前端 dist 到 Nginx 配置的全过程,每一步都配了命令。
  • 05-开发环境搭建.md:记录本地开发用的 JDK、Maven、Node 版本,方便新成员加入时快速上手。

文档不是写给客户看的,也是写给我自己的。六周后回头改代码时,有文档和没文档的差别就是“半小时定位问题”和“半天重新看代码”的差别。

7.4 交付后客户的真实使用反馈

项目上线运行一个月后,客户的反馈基本集中在两个点上。

第一是检索效率的提升。以前在 Excel 里筛唐代玉器目录要用半天,现在系统里十秒钟出结果,还能直接导出。这种体验的提升是立竿见影的。

第二是出入库记录的规范。以前纸质的出入库登记本,年末盘点时对着台账对不上账是常态,现在每一条出库记录都有时间、经办人、事由,追溯起来非常容易。

当然也存在一些需要继续优化的点,比如图片批量导入、Excel 批量导入文物数据、以及移动端的盘点功能,这些可以放到二期去做。管理系统的开发通常不是一锤子买卖,第一版把核心流程跑通、把数据管起来,后面才有持续迭代的基础。

我在这个项目中最大的体会是:技术栈只是工具,业务建模和工程设计才是真正决定项目质量的关键。SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0 是一套非常成熟且高效的技术组合,拿来交付中小型管理系统是非常顺手的。如果你也在做类似的项目,希望这篇复盘能帮你少走一些弯路。

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

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

立即咨询