做红色革命文物征集管理系统这个项目,起初是因为一个文博行业的朋友跟我吐槽:馆里征集文物还在用Excel登记,来源信息全靠手工备注,审核流程靠微信来回传文件,征集回来之后想查某位捐赠者捐过哪些东西,得把表格翻个底朝天。他说想让我帮忙做一个真正的管理系统,但经费有限,不能搞太重的平台。我评估了一下需求,决定用Java Web MVC这套经典组合去落地:后端SpringBoot2 + MyBatis-Plus操作MySQL8.0,前端Vue3做后台管理界面,整个项目源码和文档都整理齐了。今天就拿这个项目当例子,把完整的设计思路、技术选型理由、核心代码写法和踩坑过程都拆开讲,给正在做类似后台管理系统或者毕业设计的同学一份可以直接照着做的参考。
这个系统解决的问题很明确:把文物征集的来源登记、图片资料、初步鉴定、价值评估、入藏审批、档案归档这一整条业务链路搬上线,让业务人员、鉴定专家、馆领导各司其职,流程状态全程可追溯。整套代码基于SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0实现,前后端分离,RESTful接口通信,适合用来理解Java Web项目从零搭建到功能落地的完整过程。
1. 文物征集到底在管什么:业务域梳理与系统边界
很多第一次接触这类系统的同学有个误区:文物征集不就是登记一个表格,记录“谁捐了什么”吗?真正做过业务调研之后你会发现,征集管理的复杂度远超想象。一个合格的征集管理系统,至少要覆盖从“线索登记”到“藏品入馆”的全过程,中间还要处理捐赠人信息、物品照片、鉴定结论、价格评估、领导审批等多个环节的数据。
1.1 征集业务的核心流程
我梳理下来,整个业务链路大致是这样的:
- 线索登记:社会人士或机构主动联系场馆,表达捐赠、出售或托管意向,这时候只记录基础信息,比如物品名称、数量、持有人联系方式。
- 实物初鉴:业务人员约持有人带实物到馆,或者接收照片资料,做第一轮真伪和价值的初步判断。初鉴通过才进入正式征集程序。
- 专家鉴定与评估:组织相关领域的鉴定专家对文物进行真伪鉴定和价值评估,这个环节会产生鉴定意见、估价建议等核心数据。
- 入藏审批:业务部门根据鉴定结果,拟定征集意见并提交领导审批。审批通过后进入入藏流程,审批不通过则流程终止或退回修改。
- 入藏归档:财务完成征集费用结算(如果是捐赠则不存在费用),藏品编目入库,形成正式档案,征集流程结束。
这套流程里最讲究的是“状态流转”:任何一件文物在任意时刻都处于某个明确的业务状态,比如“待初鉴”“待终鉴”“待审批”“已入藏”“已退回”。系统要保证每个状态变更都有操作记录,谁在什么时间做了什么决定,全部留痕。
1.2 系统角色与权限矩阵
因为业务流程里参与的人角色不同,系统一定要做权限控制。我按照实际使用人群,把系统用户划分成四个角色:
| 角色 | 核心权限 | 业务场景 |
|---|---|---|
| 业务人员 | 登记线索、录入文物信息、发起征集流程 | 日常征集工作的主要执行者 |
| 鉴定专家 | 查看待鉴定文物、填写鉴定意见 | 只处理与鉴定相关的数据 |
| 领导/审批人 | 查看征集批次、做出审批结论 | 最终把关,不参与基础录入 |
| 系统管理员 | 用户管理、角色分配、基础数据维护 | 维护系统运行,处理异常数据 |
权限控制我用的是经典的Spring Security + JWT方案:用户登录后得到token,每次请求通过拦截器校验token并解析当前用户角色。后端接口上标注权限注解,比如@PreAuthorize("hasRole('EXPERT')"),确保鉴定专家只能访问鉴定相关接口,访问其他模块直接被拒绝。
1.3 功能模块清单
最终给到业务方的功能模块包括:用户登录认证、首页统计看板、征集线索管理、文物征集登记、鉴定评估管理、入藏审批管理、捐赠人信息管理、系统用户与权限管理、操作日志审计。每个模块都对应一套前端页面加一组后端接口,整个系统按MVC模式拆成了清晰的层次。
2. 为什么是SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0这套组合
技术选型不是越新越好,而是要在“团队熟悉度、生态成熟度、开发效率、部署成本”之间找平衡。这个项目最终选了SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0,原因我一个个说。
2.1 后端选型的核心逻辑
SpringBoot2对比SpringBoot3,虽然3已经发布了挺长时间,但很多企业内部系统和教学资料还稳定在2.x时代。为什么?因为SpringBoot2的生态兼容性更广——第三方starter、老项目的升级路径、低版本JDK环境支持都更宽松。这个系统如果部署在单位自有的老服务器上,JDK版本可能还是8或者11,SpringBoot3强制要求JDK17以上,这就直接把部署环境卡死了。所以我选了SpringBoot2.7.x,它既能稳定支持JDK8,又不影响使用最新的Java语法特性。
MyBatis-Plus是一个绕不开的话题。它本质上是MyBatis的增强工具,主打“单表操作零SQL”:继承BaseMapper<T>之后,selectById、selectPage、insert、updateById这些基础CRUD方法全部内置,不用写XML文件。说句实话,对于这种业务以单表增删改查为主的管理系统,MyBatis-Plus的效率和代码整洁度确实比原生MyBatis高一个量级。但它的意义不只是省几行代码——它自带的字段填充、逻辑删除、乐观锁、分页插件,几乎覆盖了后台管理系统所有高频需求,相当于把地基都给你打好了。
2.2 前端选型:2026年的Vue3项目该怎么做
现在开发Vue3项目,网上的资料铺天盖地,但你要是去翻2026年的主流做法,会发现几件事已经基本定型:用Vite做构建工具而不是Webpack,用<script setup>语法糖而不是Options API,状态管理用Pinia替代Vuex4,UI组件库用Element Plus。这套组合在开发体验上确实碾压两三年前的旧方案——Vite的冷启动快到基本不用等,<script setup>把组件的代码量压缩了一大半,Pinia的API设计比Vuex简洁太多。
我实际开发中还注意到一个细节:Vue3项目最好直接上TypeScript。虽然是管理系统,类型约束带来的收益依然明显——接口返回的数据结构可以自动提示,改字段名的时候编辑器能全局报错,不会出现改完了接口、前端还在用旧字段名的尴尬情况。不过为了照顾一些刚接触Vue3的同学,这个项目的核心页面我还是用了JavaScript写法,代码逻辑更直白,阅读门槛低一些。
2.3 版本锁定与兼容性实测
技术选型落地时最怕“版本地狱”,组件之间互相不兼容,报错信息看得人一头雾水。下面是我在这个项目上实际验证过的一套稳定版本组合,照着配基本不会出问题:
| 组件 | 版本 | 备注 |
|---|---|---|
| JDK | 1.8 | 兼容SpringBoot2.x,老服务器无压力 |
| SpringBoot | 2.7.18 | 2.x的最终维护版本,稳定优先 |
| MyBatis-Plus | 3.5.x | 适配SpringBoot2,分页插件用法稳定 |
| MySQL | 8.0.x | 字符集utf8mb4,存文物描述不怕emoji |
| Vue3 | 3.4+ | 组合式API稳定版本 |
| Vite | 5.x | 构建速度与插件生态平衡 |
| Element Plus | 2.x | Vue3官方推荐的UI组件库 |
| Node | 18+ | Vite5要求Node版本不能太低 |
有一点必须提醒:MyBatis-Plus的版本要跟SpringBoot2匹配,不要盲目上3.5.5以上的最新版,某些新版本对SpringBoot2的兼容性反而有回退。我项目里锁定的是mybatis-plus-boot-starter(注意是SpringBoot2专用坐标,不是mybatis-plus-spring-boot3-starter)。
3. 数据库设计:从实体类到建表SQL的完整路径
数据库是这类系统的命根子。征集管理涉及的数据表不算特别多,大概二十几张表,但表结构设计得好不好,直接决定后续业务逻辑写起来顺不顺手。更关键的是,我在这个项目里实践了一种“实体类为中心”的开发方式:先写Java实体类,再借助MyBatis-Plus能力生成建表SQL,反向推动数据库结构建设。
3.1 核心表结构设计思路
我先拿最核心的文物征集表(cultural_relic)举例。这张表要承载文物从线索到入藏全生命周期的基础信息,字段设计时我反复考虑过,最终结构如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,MyBatis-Plus默认雪花ID |
| relic_code | varchar(50) | 征集编号,业务唯一标识 |
| relic_name | varchar(200) | 文物名称 |
| relic_type | varchar(50) | 文物类别,如文献、实物、照片 |
| source_type | varchar(20) | 来源方式:捐赠/购买/移交 |
| donor_id | bigint | 捐赠人ID,关联捐赠人表 |
| relic_desc | text | 文物描述,包含材质、尺寸、特征 |
| image_urls | varchar(1000) | 图片地址,多图逗号分隔 |
| preliminary_conclusion | varchar(255) | 初鉴结论 |
| expert_opinion | text | 专家鉴定意见 |
| appraised_value | decimal(10,2) | 评估价值 |
| storage_location | varchar(100) | 入藏存放位置 |
| status | tinyint | 业务状态,见下方说明 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
| deleted | tinyint | 逻辑删除标记 |
状态字段status我定义为整型字典值:0=草稿待提交、1=待初鉴、2=待终鉴、3=待审批、4=已入藏、5=已退回、6=流程终止。这种数字字典的设计比直接存中文状态字符串要好得多——一是节省存储,二是后续扩展状态不破坏已有代码,三是前端用枚举映射展示文案一目了然。
3.2 MyBatis-Plus根据实体类生成建表SQL的两种做法
热搜词里有一条很精准:“mybatisplus根据java实体类生成创建表的sql语句”。这确实是很多同学卡住的地方。我把两种可用路径都跑通了一遍,这里直接给出实操结论。
第一种做法,使用MyBatis-Plus代码生成器先反出实体类,再用工具生成建表语句。但这个思路对现有数据库才能生效,如果是从零开始的新项目,顺序往往相反:你先设计实体类,然后期望自动得到建表SQL。第二种做法才是新项目常见的:我在实体类上写好完整的字段注解,项目启动时通过配置自动建表。MyBatis-Plus的db-config里有一个开关:
mybatis-plus: global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl不过说实话,MyBatis-Plus内置的自动建表能力比较基础,只负责“表存在则不建,不存在则新建”,不会帮你自动加字段索引。我的做法是写一个启动时执行的SchemaInitializer组件,启动时扫描指定包下的实体类,用反射读取字段注解拼接CREATE TABLE IF NOT EXISTS语句,同时手动补充索引和注释。核心拼接逻辑类似:
for (Field field : entityClass.getDeclaredFields()) { TableField annotation = field.getAnnotation(TableField.class); if (annotation != null && !annotation.exist()) { continue; // 非表字段跳过 } String columnType = resolveJdbcType(field.getType()); sb.append("`").append(humpToUnderscore(field.getName())) .append("` ").append(columnType).append(" COMMENT '") .append(field.getName()).append("',\n"); }用实体类反推建表SQL的好处很明显:表结构变更时,只需要改实体类,重启后SQL自动调整,开发期迭代表结构非常快。但生产环境千万别这么干,生产环境的表结构变更必须走专门的数据迁移脚本,这是底线。
3.3 字段映射与逻辑删除的坑
MyBatis-Plus默认开启驼峰转下划线映射:Java里的relicName自动对应数据库的relic_name,这个特性在配置里用map-underscore-to-camel-case: true控制。绝大多数情况下它是省心神器,但有个坑——如果你的数据库字段本来就用了驼峰命名(比如从老系统迁移过来的表),或者某个字段不想按驼峰规则匹配,就必须在实体类上显式写@TableField("实际字段名"),否则查询结果就是一堆null,排查起来非常隐蔽。
逻辑删除字段deleted是另一个必踩的坑。我初始设计时,以为配置了logic-delete-field: deleted就万事大吉,结果发现这个配置只对MyBatis-Plus内置方法生效——selectList、selectPage会自动追加WHERE deleted = 0条件,但如果你手写了自定义SQL或者XML里的查询语句,逻辑删除条件不会自动拼接。这意味着你写一个@Select("SELECT * FROM cultural_relic")的Mapper方法,查出来的是包含已删除数据在内的全量结果。解决方式有两种:要么在自定义SQL里手动加条件,要么把所有查询都由MP内置方法派生(不推荐,复杂查询写起来很难受)。我在项目里最终选择了前者,并在代码审查清单上专门标注了这件事。
4. 后端MVC分层落地:Controller-Service-Mapper怎么划责
MVC模式在SpringBoot工程里体现为标准的Controller-Service-Mapper三层,但很多新手对“哪段业务逻辑放哪一层”始终拿不准。我以征集登记这个核心功能为例子,完整走一遍代码链条,你就明白每一层到底该写什么了。
4.1 目录结构与分层职责
项目后端包结构是这样的:
com.example.relic ├── controller # 接收前端请求、参数校验、返回统一结果 ├── service # 业务逻辑编排,事务控制,状态流转判断 ├── mapper # 数据访问层,继承BaseMapper或自定义SQL ├── entity # 数据库实体类 ├── dto # 前端传输对象,避免实体类直接暴露 ├── vo # 视图对象,向页面输出展示数据 ├── config # 安全、跨域、MyBatis-Plus分页等配置 ├── common # 统一返回结果、异常处理、工具类 └── security # JWT认证、用户登录过滤链分层最重要的原则是:Controller层永远不写业务逻辑,只做参数接收、简单校验和结果包装;Service层承接所有业务判断,比如状态能不能变更、数据能不能删除;Mapper层只做数据查询和持久化操作。举个例子,删除一件文物之前要检查它是不是已经入藏——这个判断必须放在Service里,因为Controller拿不到完整上下文,Mapper只负责执行删除,都不会有全局视角。只有Service层知道“当前状态可不可以删”。
4.2 征集登记接口的完整实现链路
先说实体类。实体类我用MyBatis-Plus的注解标注表和字段映射,关键是主键策略和自动填充:
@Data @TableName("cultural_relic") public class CulturalRelic { @TableId(type = IdType.ASSIGN_ID) private Long id; @TableField("relic_code") private String relicCode; @TableField("relic_name") private String relicName; @TableField("relic_type") private String relicType; @TableField("source_type") private String sourceType; @TableField("donor_id") private Long donorId; @TableField("relic_desc") private String relicDesc; @TableField("image_urls") private String imageUrls; @TableField("status") private Integer status; @TableField(value = "create_time", fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableField("deleted") @TableLogic private Integer deleted; }@TableId(type = IdType.ASSIGN_ID)表示使用雪花算法生成分布式ID,替代数据库自增主键。好处是分库分表时不用关心ID冲突,而且MP在插入前就能拿到主键值,方便后续操作子表或者记录日志。如果你想用数据库自增,改成IdType.AUTO即可,但实体类主键类型必须是Long而不是int。
然后是Mapper接口。继承了BaseMapper<CulturalRelic>以后,最基础的增删改查方法全部内置,不需要写任何SQL:
@Mapper public interface CulturalRelicMapper extends BaseMapper<CulturalRelic> { // 自定义复杂查询可以放这里,比如多表联查、分组统计 List<RelicBatchVO> selectBatchList(@Param("status") Integer status); }Service层负责业务编排。登记一件文物时,要生成征集编号、检查来源信息完整性、设置初始状态为草稿、插入捐赠人信息——这些步骤之间有关系,必须放进同一个事务方法里,中间任何一步失败都要整体回滚:
@Service @Transactional(rollbackFor = Exception.class) public class CulturalRelicServiceImpl extends ServiceImpl<CulturalRelicMapper, CulturalRelic> implements CulturalRelicService { @Override public Long createRelic(RelicCreateDTO dto) { // 1. 生成业务编号:类型首字母 + 年月日 + 4位随机码 String relicCode = "RW" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", (int)((Math.random() * 9 + 1) * 1000)); // 2. 转换DTO为实体,设置初始状态 CulturalRelic relic = new CulturalRelic(); BeanUtils.copyProperties(dto, relic); relic.setRelicCode(relicCode); relic.setStatus(RelicStatusEnum.DRAFT.getCode()); // 3. 如果捐赠人ID不存在,先插入或更新捐赠人信息 Long donorId = saveDonorIfNecessary(dto.getDonorInfo()); relic.setDonorId(donorId); // 4. 入库 this.save(relic); return relic.getId(); } }Controller层只做接口定义和参数绑定,用@Valid触发参数校验规则,返回统一结构体Result<T>:
@RestController @RequestMapping("/api/relic") public class CulturalRelicController { @Resource private CulturalRelicService relicService; @PostMapping("/create") public Result<Long> create(@Valid @RequestBody RelicCreateDTO dto) { return Result.success(relicService.createRelic(dto)); } @GetMapping("/page") public Result<Page<CulturalRelic>> page(RelicQueryDTO query) { return Result.success(relicService.pageRelic(query)); } }这套链路走完,一个完整的“新增文物征集登记”就从页面按钮落到了数据库记录,每层各司其职,改动任何一层都不至于牵一发动全身。
4.3 审核状态流转的实现细节
状态流是征集系统里最容易出Bug的地方。我踩过一个很典型的坑:领导审批通过后,艺术状态直接从“待审批”变成“已入藏”,但入藏之后还需要关联库存位置、负责人信息,这些数据还没填,结果系统里出现了一批“已入藏却没有存放位置”的孤儿数据。
后来我把状态变更拆成了两步:第一步审批通过后状态变成“待入藏”,必须由业务人员补充存放位置、保管责任人并点击确认入库,才真正变成“已入藏”。这个中间状态的引入,本质上是在业务链路上增加了一个“必填数据校验点”,防止流程下游数据缺失。
代码实现上我做了一个状态机校验工具类,从一个状态到另一个状态是否合法,统一集中管理:
public class RelicStatusTransition { private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>(); static { ALLOWED_TRANSITIONS.put(RelicStatusEnum.DRAFT.getCode(), Set.of(RelicStatusEnum.PENDING_FIRST_CHECK.getCode())); ALLOWED_TRANSITIONS.put(RelicStatusEnum.PENDING_FIRST_CHECK.getCode(), Set.of(RelicStatusEnum.PENDING_EXPERT_CHECK.getCode(), RelicStatusEnum.REJECTED.getCode())); ALLOWED_TRANSITIONS.put(RelicStatusEnum.PENDING_EXPERT_CHECK.getCode(), Set.of(RelicStatusEnum.PENDING_APPROVAL.getCode(), RelicStatusEnum.REJECTED.getCode())); ALLOWED_TRANSITIONS.put(RelicStatusEnum.PENDING_APPROVAL.getCode(), Set.of(RelicStatusEnum.PENDING_STORAGE.getCode(), RelicStatusEnum.REJECTED.getCode())); ALLOWED_TRANSITIONS.put(RelicStatusEnum.PENDING_STORAGE.getCode(), Set.of(RelicStatusEnum.STORED.getCode())); } public static boolean canTransition(Integer current, Integer target) { Set<Integer> allowed = ALLOWED_TRANSITIONS.get(current); return allowed != null && allowed.contains(target); } }每次变更状态前先走这个校验,发现非法跳转直接抛业务异常。这套逻辑看起来简单,但比每个Service方法里都写一堆if判断要可靠得多——新写一个状态变更接口时,只要调用了canTransition,就不会出现流程乱跳。
4.4 分页与条件查询的统一封装
后台管理系统的列表页是最常见到吐的页面,条件多、分页复杂。MyBatis-Plus的分页插件配置好之后,调用selectPage方法就能自动生成LIMIT语句和COUNT语句:
@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; } }条件查询用MP的LambdaQueryWrapper,既有类型安全又不用拼字符串:
LambdaQueryWrapper<CulturalRelic> wrapper = Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(query.getRelicName()), CulturalRelic::getRelicName, query.getRelicName()) .eq(query.getStatus() != null, CulturalRelic::getStatus, query.getStatus()) .between(query.getStartDate() != null && query.getEndDate() != null, CulturalRelic::getCreateTime, query.getStartDate(), query.getEndDate()) .orderByDesc(CulturalRelic::getCreateTime); Page<CulturalRelic> page = this.page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper);注意like方法的第一个参数是“条件是否拼接”——前端没传姓名就不加这个查询条件,传了才拼,这比手动判断+字符串拼接SQL干净太多,基本杜绝了SQL注入风险。
5. Vue3后台管理端:页面怎么组织、数据怎么联动
后端接口串通了,前端才是真正面对业务人员的界面。Vue3写后台管理系统的体验,对比Vue2是好了一大截的,尤其是组合式API把逻辑按“功能”而不是“选项”组织,一个业务功能的所有相关代码(响应式数据、计算属性、方法、生命周期)天然聚拢在一起,读代码的效率高很多。这个项目的页面不算特别多,但把后台管理系统的典型页面类型都覆盖了:登录页、首页统计看板、数据列表页、表单页、详情页。我就拿最核心的几个页面拆解。
5.1 Vite + Vue3 + Pinia + Element Plus的工程结构
前端工程用Vite创建,结构如下:
frontend ├── index.html ├── vite.config.js ├── src │ ├── main.js │ ├── App.vue │ ├── router/index.js │ ├── store/index.js # 定义Pinia store │ ├── api/ # 所有后端接口封装 │ │ ├── request.js # axios实例 │ │ ├── relic.js │ │ └── auth.js │ ├── views │ │ ├── login/index.vue │ │ ├── dashboard/index.vue │ │ ├── relic/list.vue │ │ ├── relic/create.vue │ │ ├── relic/detail.vue │ │ └── system/user.vue │ └── components │ └── StatusTag.vue路由配置里最值得说的是路由守卫。系统所有页面除了登录页都要求登录状态,否则跳转会令人困惑——用户体验上,未登录用户访问任何页面都应该被自动送到登录页,登录回来后还要能跳回原本想访问的地址。实现方式:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })5.2 axios请求封装与跨域处理
前端跟后端的通信基础是axios实例。我把请求逻辑统一封装在request.js里,所有请求自动携带token、统一处理错误码、统一解包后端返回的数据结构:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )开发环境跨域是另一个高频坑。Vite开发服务器默认跑在5173端口,后端接口在8080端口,直接请求会跨域。两种解法我都试过:后端加@CrossOrigin或者全局CORS配置,前端Vite配proxy代理。我的建议是优先用Vite的proxy,因为生产环境用Nginx反代时,前后端通常部署在同一域名下,要么不存在跨域,要么由Nginx配置转发。开发环境用代理最贴近生产形态:
// vite.config.js server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }5.3 列表页、表单页、审核页的代码要点
列表页是后台系统里信息密度最高的页面。我的list.vue按照“搜索区 + 表格区 + 分页区”三段式布局,搜索条件与分页参数统一管理:
<script setup> import { ref, reactive, onMounted } from 'vue' import { getRelicPage } from '@/api/relic' import { ElMessageBox } from 'element-plus' const query = reactive({ relicName: '', status: null, pageNum: 1, pageSize: 10 }) const tableData = ref([]) const total = ref(0) const loading = ref(false) const loadData = async () => { loading.value = true try { const res = await getRelicPage(query) tableData.value = res.data.records total.value = res.data.total } finally { loading.value = false } } onMounted(loadData) const handleSearch = () => { query.pageNum = 1 loadData() } </script>表单页创建文物时,我特意把图片上传做成了单独组件,Multi-image上传完成后的URL拼接成一个逗号分隔字符串存库,展示时再分割渲染。el-upload组件配合后端的上传接口,整个交互还算丝滑。审核页则是把状态、鉴定意见、评估价值集中在一个抽屉组件里,提交时调用状态变更接口。
5.4 Vue3开发中容易被忽视的细节
Vue3有几个和Vue2显著不同的细节,写代码时不注意就会埋雷。第一个是v-model的用法变化:v-model:value="xx"这种写法在Vue3里仍然有效,但更规范的写法是直接用v-model绑定,配合defineProps/defineEmits去封装子组件。第二个是组件根节点不再强制单一——Vue3支持多个根节点,但多个根节点时父组件传入的class、style和事件监听需要显式通过$attrs绑定,不然样式和事件会失效。第三个是响应式丢失的问题:从reactive对象里解构出基本类型变量,再用这个变量做双向绑定,变量会丢失响应性,必须用toRefs包一层转成响应式引用。这些坑不亲自踩一遍,Debugger都无从下手。
6. MySQL8.0环境准备与接入常见坑
MySQL8.0是这套系统的“最后一块拼图”。和旧版本相比,8.0在性能、窗口函数、全文索引、JSON支持上都有明显升级,但换来的代价是新版本带来了一些配置上和兼容性上的新问题。这一节我把环境准备和实际接入过程中验证过的方案完整写出来,包括Docker安装、字符集时区配置、连接串参数、驱动版本这几个关键点。
6.1 Docker安装MySQL8.0并初始化环境
本地开发我推荐直接用Docker跑,一条命令就能起一个干净环境,用完随手销毁不污染操作系统。创建MySQL8.0容器的同时,要处理两件事:映射数据卷保证容器销毁后数据还在,设置字符集和时区避免中文乱码和时间错乱。
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e TZ=Asia/Shanghai \ -v /opt/mysql/data:/var/lib/mysql \ -v /opt/mysql/conf:/etc/mysql/conf.d \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci参数解释:-e TZ=Asia/Shanghai强制容器时区为东八区,--character-set-server=utf8mb4让数据库默认字符集支持emoji和生僻字,--collation-server=utf8mb4_unicode_ci指定排序规则。如果忘记配置这些,后面会出现两个典型的症状:插入中文没问题但查出来乱码,或者CURRENT_TIMESTAMP()返回的时间跟北京时间差了8小时。
6.2 连接串参数与驱动版本
Java连接MySQL8.0,JDBC驱动也和5.x时代完全不同。首先驱动的groupId变了,从mysql:mysql-connector-java变成了com.mysql:mysql-connector-j;其次驱动类名还是com.mysql.cj.jdbc.Driver,但一定要带cj。
连接串上有两个参数值得专门说明。第一个是serverTimezone=Asia/Shanghai,明确告诉驱动连接时的服务器时区,否则驱动默认使用JVM时区,两头不一致时时间精度可能出问题。第二个是useSSL=false,本地开发和内网部署都不需要SSL加密,开着反而拖慢速度:
jdbc:mysql://localhost:3306/relic_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=trueallowPublicKeyRetrieval=true这个参数是MySQL8.0特有的——新版驱动在首次连接时要求获取服务器公钥,如果用了caching_sha2_password认证方式,不开启这个参数会报Public Key Retrieval is not allowed异常。这个问题在本地跑通项目时踩过,排查了很久才定位到。
6.3 时区、索引、分页的实测问题
MySQL8.0默认时区是UTC,这直接导致Java的LocalDateTime存进去之后再查出来,时间偏移8小时。解决方式有两种:一是Docker启动时指定TZ=Asia/Shanghai,二是在连接串后面加上serverTimezone=Asia/Shanghai。两个都配上最保险。
另一个实际开发中容易被忽略的是索引设计。征集列表页的查询条件集中在“状态、来源方式、创建时间”这几个字段上,因为它们频繁出现在WHERE子句里。我给cultural_relic表建了联合索引:
ALTER TABLE cultural_relic ADD INDEX idx_status_create_time (status, create_time);这条索引的作用很直接:按状态筛选文物并用时间倒序排列时,MySQL可以直接走索引完成排序,避免全表扫描加临时文件排序。
分页插件在数据量上来之后也有性能变化。数据量小的时候SELECT COUNT(*)不会有感觉,一旦到百万行级别,MP的自动count查询会变得很慢。我测试过万级数据的场景,解决方案是自定义一个专门的count SQL,只count主键:SELECT COUNT(id) FROM cultural_relic WHERE ...,能显著减少扫描代价。这个优化在系统早期可以不做,但代码层面要把count逻辑和列表查询解耦,方便后期替换。
7. 上线前必查清单与后续扩展方向
系统开发完成不等于项目结束。我每次交付项目前都会过一遍检查清单,很多问题是开发阶段不会暴露、一到真实环境就出事的。这份清单对这个系统同样适用,我直接整理成表格,方便对照自查。
| 检查项 | 检查内容 | 我的经验说明 |
|---|---|---|
| 数据库备份 | 生产库是否有定时备份机制 | 至少每天全量备份一次,binlog开启增量 |
| 默认口令 | 所有预置账号密码是否修改 | 管理员初始密码生成随机并强制首登修改 |
| 文件上传目录 | 上传目录是否有访问权限控制 | 不能放在静态资源公开目录下,最好走鉴权接口读取 |
| 日志清理 | 应用日志是否配置滚动策略 | 按天切割,保留30天,避免磁盘爆掉 |
| 接口鉴权 | 是否所有接口都经过JWT校验 | 漏放一个接口等于把数据暴露给所有人 |
| 数据校验 | 必填字段校验是否覆盖前后端 | 前端校验防用户填错,后端校验防接口直调 |
| 事务边界 | 多表操作是否都有@Transactional | 插入主表后子表报错,数据必须一起回滚 |
| 静态资源缓存 | 前端打包产物是否配置缓存策略 | index.html不缓存,js/css带hash指纹长缓存 |
我知道很多团队觉得这些是运维的事,但作为开发人员,交付的时候把这些问题都提前想到,生产环境会少折腾很多。
7.1 从征集管理延伸到文物入库、展览借调
这个系统的边界虽然定在“征集”,但表结构和代码组织上我特意留了扩展空间。cultural_relic表里的storage_location字段已经在为入库管理做准备,后续如果要做全面的藏品管理系统,可以在这个表的基础上增加馆藏位置变动记录表、展览借出归还记录表、修复保养记录表。这些表跟文物表都是多对一关系,业务上串联起来就是一套完整的藏品全生命周期管理。
如果要往这个方向扩展,前端还可以新增文物360度展示页面、地图分布展示、数据可视化大屏等。征集数据的画像统计本身也很有价值——通过挖掘征集渠道、文物类型分布,可以辅助业务部门调整征集方向。这些进阶功能在“红色革命文物”这个主题下尤其有意义,相关单位做宣传展示时能拿出来的不只是静态照片,而是有数据支撑的故事线。
7.2 多端适配与移动审批的思考
我使用的过程中还有一个新体会:文物征集业务里,鉴定专家和领导真正方便使用系统的场景其实在移动端。专家可能到现场看实物之后马上要填写初步意见,领导审批走流程时希望手机上就能看到附图和鉴定报告。目前这套系统接口层面已经支持这种扩展——后端接口全部是无状态REST风格,前端完全可以再做一个移动端H5或者小程序页面,复用同一套认证和接口逻辑。我从架构上刻意做了这个预留,避免以后要从零开始。
最后再说一点个人建议。很多同学拿到类似项目的第一反应是照着教程敲代码,但做完一套完整系统之后我最大的体会是:技术选型虽然重要,但真正拉开项目差距的往往是业务理解和流程设计。你把这个“红色革命文物征集管理系统”的征集、初鉴、终鉴、审批、入库的流程想透,后端代码怎么写只是水到渠成的事,前端页面也只是为了把这个流程做成“人能用”的界面而已。项目地址和完整文档我放在项目主页了,需要的可以直接去拉代码跑一遍,有具体问题欢迎留言交流。