这段时间整理了一套红色革命文物征集管理系统的前后端分离源码,技术栈是SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,整套走的是标准Java Web MVC模式。聊到这套系统,很多人第一反应是"文物征集不就是填个表格、录个信息吗?",实际做下来才发现,里面的流程节点、审核链路、凭证留痕和状态流转远比想象中复杂。这篇文章就围绕这套系统,把我从业务梳理、数据库设计、后端分层、前端联调到部署排坑的完整过程写清楚,适合正在做管理类系统、准备全栈项目或者想参考一套Web MVC模式落地案例的开发者。
文物征集的本质是把一件来源不明的物件,通过公告、提交、初审、复审、入库等环节,变成一条来源清晰、凭证完整、可追溯的馆藏记录。技术上它又是一个标准的前后端分离CRUD项目,但难点不在CRUD本身,而在于状态机的设计和业务节点的合规控制。
1. 红色文物征集的业务痛点:一件文物如何"合规入馆"
1.1 征集流程中的关键角色与合规节点
文物征集不是简单的物品登记。一件民间藏品要进入馆藏体系,至少要经过提交、材料初审、专家复审、行政确认、实物交接、入库建档六个节点。每个节点背后都有对应的角色:社会公众或捐赠人负责提交,征集办公室负责初审,专家委员会负责真伪和价值评估,馆领导负责审批确认,库房管理员负责实物入库和信息建档。
这套系统的核心价值,就是把上述线下流程搬到一个可视化系统里,同时保证每个环节都有操作留痕、每份材料都有附件佐证、每次审核都有意见记录。从业务建模的角度,我需要设计的不是一张大而全的信息表,而是一组互相联动、通过状态字段串联的业务表。
| 流程节点 | 操作角色 | 关键动作 | 系统要求 |
|---|---|---|---|
| 提交征集 | 公众/捐赠人 | 填写藏品信息、上传照片与来源说明 | 必填校验+附件格式限制 |
| 材料初审 | 征集办人员 | 核验材料完整性,给出初审意见 | 审核记录留痕 |
| 专家复审 | 专家委员会 | 鉴定真伪、评估价值、确定是否征集 | 多级审核意见汇总 |
| 行政确认 | 馆领导 | 确认征集或退回,签批意见 | 终审状态唯一性 |
| 实物交接 | 库房管理员 | 登记实物入馆信息、存放位置 | 实物信息与档案关联 |
| 入库建档 | 档案管理员 | 生成正式藏品编号,归档 | 自动生成编号+档案状态落库 |
这个流程拆完之后,系统的模块边界就清晰了:征集公告模块、藏品提交模块、审核管理模块、入库管理模块、系统管理模块(用户、角色、菜单、日志)。
1.2 为什么没有做成纯报表系统
很多人会觉得,这种管理系统的核心是把数据录进去、统计出来就行。实际上,征集管理系统最敏感的是"退"和"拒"两个动作。一件物品如果不被征集,必须要有明确的审核意见,甚至要支持给提交人一份电子回执;如果同意征集,就要生成交接单、入库单,后续还要关联到馆藏总账。这些动作背后全是状态变化,而状态变化就是MVC模式中Service层最核心的业务逻辑。
我在这套系统里特意用一个collect_status字段贯穿全流程:PENDING_SUBMIT(待提交)、SUBMITTED(已提交待初审)、PRELIMINARY_PASSED(初审通过待复审)、EXPERT_PASSED(复审通过待确认)、CONFIRMED(确认征集)、REJECTED(不予征集)、RETURNED(退回修改)、STORED(已入库)。整个状态机建出来之后,后端所有接口的权限、前端所有按钮的显隐、菜单所有页面的数据范围,都围绕这个状态机来设计。
2. 技术选型逻辑:为什么是SpringBoot2加上Vue3再加MyBatis-Plus
2.1 SpringBoot2的取舍与理由
现在SpringBoot3已经出了稳定版,为什么这套系统依然用SpringBoot2?核心原因是生态兼容性和稳定性。SpringBoot2.7.x对JDK8的支持最成熟,大量生成文档、代码生成器、旧版依赖都能平滑共处。做管理系统这类业务,求稳比求新重要,只要不是必须使用Jakarta EE或虚拟线程这类新特性,SpringBoot2依然是低风险的选择。
SpringBoot2.7在这个项目里负责的核心内容包括:集成MyBatis-Plus完成数据持久化、集成Spring Security + JWT处理认证授权、集成Swagger/Knife4j生成接口文档、集成参数校验框架。这套组合能在一个工程内把接口层、业务层、数据层完整串起来,同时给前端提供清晰、稳定的RESTful API。
我实际建项目时用的依赖组合是:spring-boot-starter-web+mybatis-plus-boot-starter(3.5.x)+mysql-connector-j+jjwt+hutool工具包。关键点在于MyBatis-Plus的版本要和SpringBoot2.7兼容,推荐使用3.5.3以上版本,避免通用CRUD方法在旧版本上出现不兼容。
2.2 MyBatis-Plus的"无状态增删改查"思路
热搜词里有句描述非常准确:通用CRUD服务,基于MyBatis-Plus的工具类实现无状态增删改查。无状态在这里的含义是,Service层不存放请求级别的业务状态,每次操作都是独立的、可重复的原子操作。MyBatis-Plus的BaseMapper内置了insert、deleteById、updateById、selectById、selectPage等通用方法,配合LambdaQueryWrapper可以完全避免手写SQL,做到95%以上的单表操作零SQL。
我在这套系统里把通用CRUD又向上抽象了一层:自定义一个BaseController,把分页查询、新增、修改、删除、详情五个基础动作做成模板方法,子类只需要声明实体类型和Service类型。这样做的好处是,征集公告、审核意见、用户管理这些相对标准的资源,Controller层几乎不再重复代码。
以分页查询为例,前端传pageNum和pageSize,后端用Page对象接收,通过LambdaQueryWrapper动态拼接查询条件,最后返回统一结果集。这套封装不用写一行SQL,却覆盖了后台管理系统90%的列表页需求。
public class BaseController<E, S extends IService<E>> { @Autowired protected S service; @GetMapping("/page") public Result<IPage<E>> page(PageQuery query) { Page<E> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<E> wrapper = Wrappers.lambdaQuery(); // 子类通过钩子方法追加查询条件 buildQuery(wrapper, query); return Result.success(service.page(page, wrapper)); } protected void buildQuery(LambdaQueryWrapper<E> wrapper, PageQuery query) {} }这部分封装看起来简单,但在项目里收益很大:新加一个实体模块只需要写Mapper、Service、Controller三层的空壳,业务差异部分用钩子方法覆盖即可。
2.3 MySQL8.0与Vue3的配套理由
MySQL8.0的窗口函数、WITH语法、更好的JSON支持和默认的utf8mb4字符集,对管理系统的统计报表和附件元数据存储非常友好。比如征集数据的季度统计,原来要在Java里去内存分组,现在一条窗口函数SQL就能解决。
Vue3这边主要看重组合式API带来的逻辑复用能力。管理系统页面之间存在大量重复逻辑:列表分页、搜索条件、弹窗表单、状态标签转换。Vue3的setup语法配合自定义Composable函数,可以把"分页加载"、"提交表单"这类行为封装成函数直接复用,代码组织方式比Vue2的Options API清晰很多。
前端工程的核心依赖是:Vue3.2+、Vue Router4、Pinia、Element Plus、Axios、ECharts。Element Plus的表格、表单、标签、穿梭框组件,几乎覆盖了后台管理界面80%的交互需求。
3. 数据库设计与通用CRUD落地:表结构背后的业务思考
3.1 核心表结构设计细节
数据库设计是这套系统里我认为最值得展开的部分,因为它直接反映业务理解深度。整个库一共设计了12张核心业务表,外加5张系统表。最重要的五张:
collection_batch:征集批次/公告表,记录某一轮征集活动的起止时间、征集范围、公告状态。collection_item:征集藏品表,核心字段包含交易流水号、藏品名称、年代、质地、尺寸、来源方式、藏品简介、当前状态、提交人信息。collection_attachment:附件表,存图片、视频、PDF鉴定报告,通过biz_type和biz_id与业务数据关联。collection_review:审核意见表,存每个节点的审核人、审核结果、意见内容,一条征集数据对应多条审核记录。collection_storage:入库记录表,存实物交接时间、存放库位、保管人、藏品编号。
这里要重点说一个设计取舍:为什么审核意见单独建表而不是直接冗余在业务表里?因为多级审核链路中,每个节点都可能产生意见,如果冗余在业务表里,每加一级审核就要加一组字段,表结构会无限膨胀。独立审核表配合node_code字段,可以无限扩展审核节点,查询明细时按时间排序即可拿到完整审批链。
collection_item的状态字段采用tinyint加常量枚举映射,而不是直接存字符串。这样在数据库层面做统计索引时效率更高,代码里通过CollectStatusEnum做转换,可读性也不差。
3.2 通用Service封装的边界控制
MyBatis-Plus的通用CRUD能力很容易被滥用。基础增删改查虽然方便,但业务系统中的删除操作几乎都不能是物理删除。我在通用Service层统一注入了逻辑删除能力:所有业务表都配置deleted字段,通过@TableLogic注解实现逻辑删除,查询时MyBatis-Plus自动追加deleted=0条件。
与此同时,通用Service做到了"能通用则通用、不能通用就得覆盖"。比如collection_item的删除操作,如果这条征集数据已经进入审核流程,必须禁止删除,只能走"撤回"或"退回"操作。这个规则放在通用remove方法里就不合适,我在子类Service里重写了删除方法,先查状态再决定是允许删除、抛出业务异常还是转化为状态变更。
这里我用了AOP的方式来统一处理状态校验:自定义一个@StateGuard注解,标注在Service方法上,通过切面在方法执行前读取collect_status字段做前置校验。这样业务方法本身不用写重复的状态判断,校验逻辑集中管理,排查问题时只需要看注解配置。
3.3 乐观锁解决并发审核问题
文物征集审核过程中有一个典型并发场景:同一件物品,两个审核人员同时打开审核页面,一个点击通过,另一个点击退回。如果不做并发控制,后提交的会覆盖先提交的,审核记录却留下两条不一致的操作。
MyBatis-Plus提供了@Version注解实现乐观锁,我给collection_item表加了version字段,每次更新前先比对版本号,不一致则更新失败并抛出异常。前端拿到失败提示后刷新列表即可看到最新状态。这个方案对管理系统足够,不需要引入分布式锁那样重的机制。
@Entity @TableName("collection_item") public class CollectionItem { @TableId(type = IdType.ASSIGN_ID) private Long id; @Version private Integer version; private Integer collectStatus; // 其余字段省略 }注意乐观锁生效的前提是更新SQL必须携带version条件,MyBatis-Plus在实体类上有@Version注解时会自动处理,但手写自定义SQL时需要手动拼接。
4. 后端MVC分层实现:从征集提交到审核入库的完整链路
4.1 Controller、Service、Mapper三层的职责边界
MVC模式在这套系统里体现得最明显的就是严格的分层。Controller层只做参数接收和结果包装,不写任何业务判断;Service层承载全部业务规则,包括状态校验、流程操作、事务控制;Mapper层依赖MyBatis-Plus提供数据访问,自定义复杂查询时通过注解或XML编写SQL。
在工程结构上我按controller、service、mapper、entity、dto、vo、config、common八个包组织。dto负责接收前端请求参数,vo负责输出到前端展示,两者和entity分离避免把数据库字段直接暴露给前端。
举个例子,征集提交时前端传的参数有藏品基本信息、联系人信息和附件列表。DTO设计成嵌套结构:ItemSubmitDTO里包含CollectionItemDTO基本信息、ContactDTO联系人和List<AttachmentDTO>附件列表。Controller层通过@Validated注解完成DTO参数校验,然后转交Service层统一处理。
4.2 征集档案的完整处理链路
征集档案是这套系统的主动脉,我按状态机的流转把整条链路串起来。提交阶段,系统生成唯一业务流水号,格式是ZJ + 年月日 + 四位随机码,这个流水号作为整个业务链路的关联主键,方便线下沟通时快速定位。附件上传和基本信息保存在同一事务里,保证不会出现有档案没附件的情况。
初审阶段,征集办人员对待初审列表进行操作。系统要求必须先填写初审意见才能点击通过或退回,前端通过表单必填校验控制,后端在Service层再次做非空判断。这里有一个细节:退回操作必须选择退回原因,因为退回原因会直接拼成短信或站内信模板发给提交人。
复审阶段,专家委员会可能多人参与。系统设计为:一条征集数据关联多份复审意见,只有当所有指派专家的意见都是通过时,状态才流转为EXPERT_PASSED,否则默认进入REJECTED或RETURNED。这个"多人会签"逻辑是审核类系统的常见需求,核心实现是查询复审意见表中是否还有未提交的意见。
入库阶段,库房管理员录入实物信息,包括存放位置、保管人、实物现状描述。确认后系统自动生成藏品编号,编号规则为CP + 类别码 + 年份 + 序列号,例如CP-GM-2025-0012。藏品编号一旦生成,该条征集数据锁定为不可编辑状态,所有操作转为只读。
4.3 JWT认证与操作审计的双保险
系统管理员的认证我用Spring Security + JWT实现。登录成功后生成Token,前端将Token存放在本地存储,每次请求通过Axios拦截器自动附加到请求头。服务端通过过滤器解析Token并设置用户上下文,供Controller层获取当前操作人。
审计日志这块我单独建了operation_log表,通过AOP切面记录每个写操作的请求路径、请求参数、操作人、操作时间和IP地址。审核意见、征集状态变更这类关键操作除了写日志,还强制记录业务流水号,便于后续追溯整个业务链路。我在实际配置中把修改和新增操作的日志级别设置为INFO,查询类操作不记录,避免日志表数据量膨胀。
5. Vue3前端实现:后台管理界面如何与后端优雅协作
5.1 组合式API与通用分页列表的封装
Vue3后台管理的核心工作是列表页和表单页的搭建。我写了一组自定义Hook来统一处理分页逻辑:useTableList接收请求函数和搜索条件对象,返回表格数据、加载状态、分页参数和刷新方法。所有列表页只需引入这个Hook,声明自己的列表接口和搜索字段,就能获得完整的分页交互能力。
export function useTableList(fetchFn, searchForm) { const tableData = ref([]) const total = ref(0) const loading = ref(false) const pageNum = ref(1) const pageSize = ref(10) const loadData = async () => { loading.value = true try { const res = await fetchFn({ pageNum: pageNum.value, pageSize: pageSize.value, ...searchForm.value }) tableData.value = res.records total.value = res.total } finally { loading.value = false } } onMounted(loadData) return { tableData, total, loading, pageNum, pageSize, loadData } }这套封装减少了我大量重复的loading、data、page变量声明。搜索、重置、翻页、删除后刷新都复用同一个loadData,组件代码干净很多。
5.2 Axios请求封装与跨域处理
Axios封装的核心是拦截器。请求拦截器统一附加Token,响应拦截器统一处理HTTP错误码和业务错误码。后端返回格式固定为{ code, message, data },code=200表示成功,其他code直接弹出ElMessage.error。这样业务组件里就不需要每个接口都写错误处理逻辑。
前端所有请求走 `/api` 前缀,通过 Vite 开发服务器代理到后端地址。// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }生产环境部署时,通过Nginx将/api前缀反向代理到后端服务,前后端分离的跨域问题从根源上消除。这里我没有采用后端开启CORS全局配置的方式,因为Nginx层处理更干净,也能顺便配置HTTPS和静态资源缓存。
5.3 多级审核流程的前端状态联动
审核列表页是前端交互最复杂的部分。不同状态下,列表右侧的操作按钮完全不同:PRELIMINARY_PASSED状态的条目显示"转复审"按钮,EXPERT_PASSED显示"提交入库"按钮,STORED状态则完全禁用操作列。
我在前端封装了一个CollectStatusTag组件,通过字典映射把状态值转为标签类型和文案。同时根据当前用户角色和条目状态,动态计算按钮权限数组。这块功能很依赖后端提供当前用户的角色信息,所以登录接口返回的UserInfoVO里必须有roles字段,前端通过Pinia统一存储。
审核操作采用弹窗表单完成:点击"审核"按钮弹出当前节点的审核窗口,包含意见输入框和通过/退回两个按钮。提交前通过ElMessageBox.confirm做二次确认,降低误操作概率。
6. 部署与踩坑实操:MySQL8.0环境、跨域联调与常见问题
6.1 Docker安装MySQL8.0并初始化数据库
这套系统在部署时我用Docker部署了MySQL8.0,命令比较常规,但有几个细节容易踩坑。容器创建时一定要指定字符集和排序规则,否则默认字符集可能是latin1,中文字段插入后会乱码。时区也要指定为Asia/Shanghai,否则后端JDBC连接时日期会少8小时。
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -e TZ=Asia/Shanghai \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci数据库初始化时我准备了一个init.sql脚本,包含建库、建表、初始用户数据、字典数据四部分。通过docker exec -i mysql8 mysql -uroot -p yourdb < init.sql执行,比手动一条条执行SQL高效得多。
JDBC连接串方面,useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai这三个参数缺一不可。MySQL8.0的驱动类名是com.mysql.cj.jdbc.Driver,旧版的com.mysql.jdbc.Driver会直接报错。
6.2 联调阶段最容易翻车的三个问题
第一个是参数类型不匹配。前端传的时间字符串是2025-03-18 10:00:00,后端用LocalDateTime接收时,必须在Jackson配置里添加JavaTimeModule并指定日期格式,否则反序列化直接报错。
第二个是分页参数命名不一致。MyBatis-Plus的Page对象接收current和size,但很多人前端习惯传pageNum和pageSize。我在PageQuery类里做了参数映射,用@JsonProperty("pageNum")和@JsonProperty("pageSize")注解实现别名绑定,避免前后端因为命名不一致反复扯皮。
第三个是逻辑删除和唯一索引的冲突。我在collection_item表上对流水号建了唯一索引,但逻辑删除后再次提交同一条流水号时,插入会报唯一键冲突。解决方案是唯一索引改成联合字段(biz_no, deleted),这样被逻辑删除的记录不会阻塞新记录插入。
6.3 前端构建部署到Nginx的实操细节
Vue3项目构建产物是纯静态文件,通过npm run build输出到dist目录。Nginx配置里需要注意:try_files指令必须把路由回退到index.html,否则刷新页面时会出现404。
location / { root /opt/collection-web; index index.html; 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; }静态资源缓存这块,我对index.html设置为no-cache,对带有哈希指纹的JS和CSS设置为长缓存,这样发布新版本时用户总能拿到最新的入口文件,同时静态资源又能命中缓存减少带宽消耗。
整套系统从业务梳理到上线部署,最耗时的地方不是编码,而是状态机的设计和各种边界情况的校验。文物征集这类管理系统的价值不在于技术栈有多新,而在于把业务流程固化成系统规则,让每一个操作都有依据、有记录、可追溯。如果你也正在做类似的管理系统,建议先把状态流转图画清楚、把审核链路的数据表设计好,再去写代码,这样能少走很多弯路。