接到这个项目需求的时候是一个比较紧急的场景需要在短时间内产出一套能够支撑隔离人员全流程管理的业务系统包含人员登记、健康上报、隔离周期管理、房间分配这些核心动作。项目要求前后端分离后端定的是 SpringBoot2 体系前端要求 Vue3持久层指定 MyBatis-Plus数据库则是 MySQL8.0。这几乎就是当下 Java Web 后台管理系统最主流的一套组合拳了。我记得当时评估过几个方向要不要上 Spring Cloud要不要用 JSP 那套老方案前端要不要继续抱着 Vue2 不放最后都推翻了。原因后面会展开讲。这篇文章我打算把整个项目从选型、初始化、通用 CRUD 服务设计、核心业务模块实现到 MySQL8.0 的接入坑、最终文档交付完整梳理一遍——不是贴源码完事而是讲清楚每一个关键设计背后的理由以及那些不跑一遍根本发现不了的细节。1. 为什么是 SpringBoot2 Vue3 MyBatis-Plus这套组合的取与舍1.1 先从后端框架说起单体不是保守是对业务的清醒认知隔离管理系统这种项目并发量不会高到需要分布式来解决业务边界也很清晰就是人员、健康数据、房间资源、日志这几张主表之间的流转。用微服务去拆除了增加运维复杂度没有带来任何实际收益。我当时见过太多团队一上来就 Spring Cloud 全家桶结果没人能说清楚服务间调用的边界在哪里。SpringBoot2 在这个项目里有几个实打实的优势内嵌 Tomcat一个 jar 就能跑起来不用单独装容器自动配置让数据源、Redis、Jackson 这些组件的接入成本降到最低生态足够成熟遇到问题在社区里几乎都能找到答案。版本上我选了 2.7.x没有奔着 3.x 去——当时 SpringBoot3 刚出来对 Java 17 有硬性要求很多中间件客户端的兼容性还在磨合期没必要拿一个交付型项目去当小白鼠。1.2 Vue3 到底比 Vue2 强在哪Composition API 不是换个写法那么简单说实话Vue2 我写得挺顺手的Options API 在中小项目里其实够用。但接 Vue3 是趋势Vue2 在 2023 年底就停止维护了新项目再起 Vue2等于从第一天就开始背技术债。Vue3 真正让我觉得值的是 Composition API 带来的逻辑复用能力。同一个健康上报的校验逻辑可以在登记页面和修改页面里直接复用同一个useHealthForm函数不用再靠 mixin 那种容易命名冲突的方案。另外 Vue3 基于 Proxy 的响应式系统在数据量稍大的列表页里性能表现确实比 Vue2 的 defineProperty 方案要从容。热词里有人提到vue3 的 ref 万能对象我理解这个说法——ref在 Vue3 里既能包基本类型又能包对象模板里自动解包确实是无脑选择。但实战中我会提醒团队对象类型尽量用reactive只在需要整体替换、或者要传给子组件作为响应式引用时才用ref这样代码可读性更好。1.3 MyBatis-Plus 的价值CRUD 只写一次后面全在写业务选 MyBatis-Plus 是个很务实的决定。项目要求含文档、可交付、可二次开发意味着代码得能被后来者快速接手。MyBatis-Plus 提供了BaseMapper和IService单表 CRUD 几乎零 SQL分页插件也现成。我不需要为每张表写一套 XML 的 insert、update、selectById——这些无状态、无业务含义的操作交给框架是合理的。MySQL8.0 在这个项目里其实是配合着 MyBatis-Plus 一起选的。MySQL 5.7 到 8.0 的跳跃主要体现在字符集默认值、窗口函数支持和性能提升上。当然8.0 也带来了一些历史包袱的问题比如加密插件变更导致的客户端连接报错后面我会专门用一节来讲怎么处理。2. 从零搭建前后端工程骨架那些容易被跳过的初始化细节2.1 后端工程初始化Maven 依赖的版本是这个项目的第一个坑后端我用 Spring Initializr 生成基础工程Java 版本锁定 8。很多人会问SpringBoot2 明明支持 Java 11为什么不用因为交付型项目要考虑甲方环境JDK8 在服务器上最通用出了问题也好找替代方案。Maven 的pom.xml里最需要注意的是版本管理。当时踩过一个典型的坑直接用 MyBatis-Plus 3.5.x 的默认版本它能兼容 SpringBoot2但如果你把 MySQL 驱动依赖写成了旧的mysql-connector-java5.1.x连 MySQL8.0 时数据库连接池会不停报错。正确做法是使用带com.mysql坐标的 8.x 驱动或者直接用mysql-connector-j这个新坐标。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency项目结构上我没有开多模块就用单模块打包。原因是这个系统的代码量还没到需要拆 api、service、dal 多模块的程度单模块配合清晰的包名划分反而更容易让接手的人看懂。包结构大概是controller / service / mapper / entity / common这样五个主包后面通用 CRUD 服务就放在 common 里。2.2 前端工程初始化Vite 比 Webpack 快在哪里前端用的是npm create vue3的官方脚手架Vite 作为构建工具。Vite 在开发环境下的冷启动速度和热更新体验比 Webpack 时代的 Cra 或 Vue CLI 强太多。项目里组件库选的 Element Plus和 Vue3 的配合是官方级别的后台管理系统常见的表格、表单、弹窗、标签页组件都有现成的。UI 组件这块没必要重复造轮子。初始化时一个容易踩的细节是Element Plus 默认是按需引入的如果用全局引入方式打包体积会很大但如果按需引入又要额外配unplugin-vue-components。我直接用了全量引入——对于内部管理系统来说首屏体积的优化优先级没那么高换来的是写页面时不用担心哪个组件忘了 import。Vite 的代理配置也要在初始化阶段就设好不然前后端联调时跨域问题会让你怀疑人生。我在vite.config.ts里做了/api前缀的代理后端 Controller 统一用/api开头这样生产环境部署时通过 Nginx 反代也不用手忙脚乱改代码。server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }2.3 前后端的目录职责划分一开始就约定好后面少吵架前后端协作的项目最容易出问题的是接口边界不清晰。我在项目启动前一天花半小时给团队定了一个简单的约定后端只负责数据校验、业务处理、数据持久化前端只负责交互逻辑、展示逻辑、表单校验不直接拼 SQL 或操作业务状态机。后端 Controller 层的命名直接按资源走/api/isolated-person、/api/health-record、/api/room。前端 src 下按api / views / components / composables四个目录分。这样约定之后前后端联调几乎没有出现过这个接口到底归谁管的争论。3. 通用 CRUD 服务的设计让每次增删改查不重复造轮子3.1 为什么叫无状态增删改查以及它解决了什么这个项目热词里有句描述特别准基于 mybatis-plus db 工具类实现无状态增删改查。所谓无状态是指这里的增删改查不绑定任何一张具体表的业务逻辑拿到实体类和主键就能操作。它解决的核心痛点是项目里有十几张表如果每张表都写一套 Mapper 接口 Service 实现 Controller 方法那光 CRUD 就够写三天。MyBatis-Plus 其实已经给了两个现成的底座BaseMapperT提供单表 CRUD 方法IServiceT提供更强的通用服务能力。但真正到业务层每张表的 Service 还是得新建接口、新建实现类、然后调父类方法。我的做法是多走一步抽一个BaseService把通用的分页查询、列表查询、按 id 启停这些操作全部放进去子类只继承它业务代码里几乎不出现save、removeById这种原始调用。3.2 从 MyBatis-Plus 的 Db 工具类说起什么时候可以直接用MyBatis-Plus 从 3.5.x 版本开始内置了Db工具类静态方法直接操作任意实体。比如Db.lambdaQuery(Person.class).eq(Person::getStatus, 1).list()不需要注入任何 Mapper 就能完成一次查询。这个工具类的出现让那些只有一两处数据访问的临时方法变得非常轻量。但我在实际项目里对它做了限制只允许在 Service 内部使用 Db 工具类Controller 不允许直接调。原因有两个一是为了事务控制——Db 工具类的操作不会被 Spring 的事务注解自动管理如果你的方法里先查询后更新中间出了异常Db 工具类的操作不会自动回滚二是为了代码规范如果 Controller 里到处是Db.lambdaQuery接手的人会困惑这项目的 Mapper 到底去哪了。3.3 自己封装 BaseService 和 BaseController代码量确实少了一大半实际我写了一个BaseServiceT接口定义了pageList、getById、saveOrUpdate、deleteByIds、changeStatus这几个通用方法。T 是实体类型实现类里用 MyBatis-Plus 的ServiceImpl作为父类。子类继承后就自动拥有了所有通用方法。public abstract class BaseServiceImplM extends BaseMapperT, T extends ServiceImplM, T implements BaseServiceT { Override public PageResultT pageList(PageQuery query) { PageT page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperT wrapper Wrappers.lambdaQuery(); // 通用排序、过滤逻辑 return PageResult.of(this.page(page, wrapper)); } }Controller 层我也抽了BaseControllerT里面统一处理了响应包装Result.ok(data)、Result.fail(message)。所有 Controller 返回的数据结构都是{ code: 200, data: {...}, message: success }这种格式。前端 axios 拦截器统一处理错误码不再需要每个页面单独判断。这里有一个很重要的心得通用化程度不是越高越好。我见过有人把 Controller 也做成完全通用的一个/api/generic/{entityName}的接口处理所有表的 CRUD——这种设计看起来极简实际一用就崩因为你无法对不同的实体做不同的参数校验和权限控制。我的边界是Controller 保留每个资源的独立性只是把公共逻辑下沉到 BaseController。4. 隔离管理系统的核心业务实现以人员流转为主线4.1 业务全景一张人员状态机把整个系统串起来隔离管理系统本质上是一个状态流转系统。一个人从进入隔离区到解除观察经历待登记 - 隔离中 - 观察期 - 已解除中间还有异常转诊这种分支状态。我先用一张状态映射表把流转关系定死再据此设计数据库字段和前端页面。当前状态允许的操作目标状态待登记确认入住隔离中隔离中上报异常异常观察隔离中到期评估观察期异常观察复核正常隔离中观察期期满登记已解除这张表看起来简单但它是整个系统设计的锚点。数据库里每个隔离人员的记录都带status字段每次状态更新都会写入操作日志表。酒店的流程管理、健康上报模块、房间分配模块全部围绕着这个状态机展开。4.2 人员登记与档案管理一个典型的增删改查是怎么变成业务的人员登记页面包含的信息不算复杂姓名、身份证号、联系电话、来源地、入住日期、关联房间号。但落到业务层就不能只是简单地 insert 一条人员记录还需要做三件事。第一身份证号的重复校验。一个人不可能在同一个隔离点有两条有效记录这个校验不能只在前端做后端 Service 里必须用 LambdaQueryWrapper 按身份证号查一次并且要排除掉状态为已解除的历史数据。第二房间号与状态的联动。登记时必须把房间状态从未占用改为已占用这个操作要放在同一个事务里。第三生成隔离周期。默认隔离周期是 14 天根据入住日期自动算出预计解除日期同时生成一批未来 14 天的健康上报空记录这样前端日历面板可以直接展示。我曾见过一个很不合理的做法前端在登记成功后又发了第二个请求去初始化健康记录。一旦第二个请求失败人员登记了但每天的填报页面是空的。这种跨请求的一致性问题是典型的分布式事务陷阱在一个单体系统里完全可以通过事务避免掉。4.3 健康上报与异常预警定时任务和状态联动健康上报是一个高频次操作每个人每天至少一次上报的数据包括体温、咳嗽症状、是否接触疑似病例等。这个模块用到了两个技术点一个是 MyBatis-Plus 的条件构造器做批量查询另一个是 SpringBoot 自带的定时任务。每天凌晨跑一个定时任务扫描当天还没上报的人给他们推送提醒。同时统计上报率低于某个阈值时给管理人员发送汇总消息。这个功能用Scheduled(cron 0 30 6 * * ?)就能实现不需要引入 Quartz。需要注意的坑是EnableScheduling注解极易漏加加上之后还要确认任务线程池的大小默认单线程在任务耗时稍长时会产生阻塞。异常预警的逻辑则依托健康上报表里的异常标记字段。一旦某条上报记录里的体温超过阈值系统立即把该人员状态改为异常观察同时生成一条异常记录前端监控大屏通过 WebSocket 或轮询感知到变化后弹出提示。这里我用的是轮询因为实际场景里对实时性的要求没高到必须上 WebSocket轮询 10 秒一次的方案在实现成本和服务器压力上都更可控。4.4 房间分配与容量统计一个必须做权限控制的功能点房间分配这个功能看名字很简单但它是整个系统里最容易写歪的地方。核心难点在于并发两个管理员同时分配同一间房时后端必须保证只有一个请求能成功。我用了数据库条件更新来解决在 UPDATE 语句的 WHERE 条件里加上status 0然后用返回的受影响行数来判断是否抢房成功。UPDATE room SET status 1 WHERE id ? AND status 0MyBatis-Plus 里对应的写法是update(entity, wrapper)wrapper 里带eq(Room::getStatus, 0)最后判断updateCount 0。这个方案比先查后改的流程少了加锁环节性能更好也不会出现超卖。容量统计则简单很多一个group by status的查询就能得到各类房间的数量前端用卡片和图表展示。5. Vue3 前端的复用设计列表页、表单页、状态变更页的抽象5.1 登录鉴权与路由守卫不要把 token 校验写散在每一个页面后台管理系统都有用户体系这个项目也不例外。我选了比较轻量的方案登录接口返回 token前端存到 localStorageaxios 请求拦截器自动带上Authorization头响应拦截器统一处理 401 跳回登录页。路由守卫用的是 Vue Router 的beforeEach判断没有 token 就重定向到/login。这里有一个值得说的细节动态路由和静态路由的取舍。对于隔离管理系统这种角色不复杂的场景我偏向静态路由加菜单权限控制而不是根据后端返回的菜单列表动态生成路由。原因是动态路由在刷新页面时要重新拉取菜单、重新匹配路由处理不当时会有白屏闪烁。而我们的角色类型就那么两种——管理员和普通工作人员静态路由注册好前端根据角色字段控制菜单显示实现简单体验也更稳定。5.2 列表页的抽象search 条件、表格、分页三件套系统中至少有五六个列表页如果每个页面都复制粘贴一遍 Element Plus 的表格和分页代码那页面代码会膨胀得很难维护。我抽了一个通用列表组件ProTable它接收三个核心配置columns描述表格列searchConfig描述搜索表单的字段apiMethod是获取数据的接口函数。这样每个具体的列表页就变成一段很薄的配置代码const config { columns: [ { prop: name, label: 姓名 }, { prop: idCard, label: 身份证号 }, { prop: status, label: 状态, type: tag, tagMap: statusTagMap } ], searchConfig: [ { prop: name, label: 姓名, type: input, placeholder: 请输入姓名 }, { prop: status, label: 状态, type: select, options: statusOptions } ], apiMethod: fetchPersonList }不要小看这个抽象它至少让列表页的平均代码量少了一半而且把新增一列的改动从改模板变成了改一个对象属性。更关键的是所有列表页的排序、分页、加载状态、异常提示逻辑都统一了后端返回的数据结构只要约定一致前端几乎不用额外适配。5.3 表单页的交互细节动态添加行与校验时机表单方面有一个非常高频的需求人员登记时可能要动态添加多名同行人员的健康信息。热词里提到的vue3 动态添加删除 form 表单一行数据就是这个场景。Vue3 里用reactive数组 循环渲染可以轻松实现给数组 push 一个空对象模板删除时splice。需要注意的点是Element Plus 的表单校验要针对动态行的每一个prop单独配置通常用:propmembers. index .name的形式绑定。我在这个项目里吃过的亏是动态添加行时新增的行的字段初始值是空字符串还是 undefined会影响校验规则里的required是否正常触发。建议统一初始化为{ name: , health: , temperature: null }空字符串和 null 都会正常触发必填校验而 undefined 在某些组件里会被跳过。6. MySQL8.0 接入实录安装、配置与第一周踩的坑6.1 Docker 安装 MySQL8.0一条命令起来的库三个细节不能少项目的标准部署环境我给了 Docker Compose 方案这样不管在测试服务器还是客户的 Linux 环境上都能快速拉起一套 MySQL8.0。Docker 安装本身不复杂一条docker run就能跑起来但有几个非调不可的参数字符集、时区和数据卷。docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDyour_password \ -e TZAsia/Shanghai \ -v /data/mysql:/var/lib/mysql \ mysql:8.0TZ环境变量负责时区不设置的话默认是 UTC后面 Java 程序里 LocalDateTime 和数据库中 datetime 字段的对应关系就会乱。数据卷必须挂载否则容器一删数据全没了。还有一个容易被忽略的默认字符集虽然 MySQL8.0 已经是 utf8mb4但保险起见我会在启动后执行一条ALTER DATABASE xxx CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci确保和 Java 端的字符串编码完全一致。6.2 时区与连接参数连接串里少写一个参数就噩梦MySQL8.0 的 JDBC 连接串和 5.7 有区别。在 8.0 下连接串必须显式加上serverTimezoneAsia/Shanghai否则驱动会报时区错误。完整的连接串长这样jdbc:mysql://localhost:3306/isolated_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue是 MySQL8.0 加密插件带来的另一个必填项。8.0 默认用户认证插件是caching_sha2_password首次连接时客户端需要向服务器请求公钥进行密码传输加密如果不允许公钥获取会报Public Key Retrieval is not allowed。这个错误在测试环境第一次连接时几乎必然遇到。6.3 客户端工具连不上 MySQL8.0不是密码错了是认证插件变了团队里用 Navicat 或者其他老版本客户端的人在连接 MySQL8.0 时大概率会遇到Authentication plugin caching_sha2_password cannot be loaded的报错。正常安装的 MySQL8.0 不会用 mysql_native_password这是 5.7 时代的认证方式。我的处理方式是不强求把 MySQL8.0 的默认认证插件改回 old这样太绕了。直接让所有同事把数据库客户端升级到支持 caching_sha2_password 的版本即可。如果是连接池、程序里用的驱动只要用了 8.x 的 MySQL Connector/J天然支持新认证方式。文中一开始就强调不要用 mysql-connector-java 5.x最大的原因就是认证插件不兼容。MySQL8.0 还有一个和 5.7 的行为差异默认的sql_mode里多了ONLY_FULL_GROUP_BY。如果项目的 SQL 里用了select *加group by在 5.7 下可能不报错在 8.0 下就会直接报错。我的建议是尽量规范 SQL按需求列字段并用聚合函数包裹非分组字段如果确实要兼容历史代码可以通过修改sql_mode来放松限制但这不是长久之计。6.4 关于 Docker 容器中的 MySQL:8.0 和宿主机资源的一个插曲还有个容易忽略的坑容器跑起来的 MySQL8.0 在低配服务器上启动特别慢。我一开始没注意以为卡死了反复重启容器导致数据目录损坏。后来发现 MySQL8.0 初始化时需要做大量的系统表操作内存低于 1GB 的机器初始化时间可能长达几分钟。解决方案是配置performance_schemaOFF或者给容器分配足够的内存。这个经验让我意识到Docker 虽然方便但资源限制一定是你在生产环境要考虑的第一优先级。7. 含文档的含金量交付项目时我整理了哪些资料7.1 除了源码一个可交付项目必须有这几样东西标题里带了含文档三个字。在项目交付中源码只是一部分文档才决定这个项目能不能被顺利接手、二次开发。我最终交付的资料包里包含下面这些内容文档 / 资料说明数据库建表 SQL全部建表语句包含注释、索引、初始演示数据数据库设计说明每张表的字段含义、枚举值说明、表间关系接口文档维护成 Markdown 或导入 Apifox覆盖全部后端接口部署文档从环境准备到打包、启动、Nginx 配置、数据库初始化的完整步骤前端环境说明Node 版本要求、依赖安装命令、代理配置说明演示账号说明管理员的初始账号密码、功能演示路径数据库设计说明是被最多人忽略、但实际上最有价值的文档。我见过太多项目只给一个 SQL 文件字段名没人解释枚举值靠猜。在隔离管理系统里状态 0 1 2 3 是什么意思如果不写清楚接手的人光看代码要花半天。我在表注释里就把每个字段的枚举值都写了表结构本身变成了文档的一部分。7.2 接口文档与演示数据让二次开发的人能立刻跑起来接口文档我习惯在源码里加一个docs/api.md维护成本低和代码同仓库不会产生版本漂移。内容不需要像 Swagger 那样面面俱到但至少要把每个接口的请求路径、请求参数、响应示例写清楚——尤其是那些涉及状态流转的接口比如确认入住这个动作它同时修改了哪些表、有没有事务边界。演示数据是另一个关键。我提供了一个init-data.sql里面有十几个模拟的隔离人员记录、几十条健康上报记录、房间数据覆盖了系统的所有状态。这样接手的人拿到项目后导入数据库就能看到数据效果不需要自己慢慢造数据。这个细节看似不起眼但对项目的首次体验影响巨大。7.3 项目部署流程从后端 jar 到前端静态资源再到 Nginx部署文档写的是标准的前后端分离部署流程后端 Maven 打包成 jarjava -jar直接启动前端npm run build生成 dist 目录交给 Nginx 托管。Nginx 里核心配置是静态资源路径和反向代理/api到后端地址。这里要提醒一个实际遇过的坑前端构建出来的资源引用了绝对路径/assets/xxx.js如果部署到子路径下比如域名后面带个/admin就会出现白屏。解决方案是在前端项目里的vite.config.ts设置base: ./这样资源引用就变成相对路径放到哪个目录都能跑。这个坑不跑一次部署踩不到但踩到了极其蛋疼。7.4 一套源码如何被别人用得起来我的最后一个建议最后给正在做同类项目的人一个建议如果这套系统定位是分享或者交付请一定要把第一个启动体验做好。我见过很多开源项目代码写得很漂亮但 README 写的含糊其辞数据库脚本放得乱七八糟用户克隆下来第一步就跑不起来。所谓含文档不是在仓库里放一个 readme.md 就算数而是要让一个完全没有参与过这个项目的人照着文档能在半小时内把系统跑起来并且看得懂数据、走得通流程。这个标准不高但能达到的项目真的不多。回到这个隔离管理系统本身。技术上它不算多难但它逼着我完整地走了一遍从业务抽象到工程化交付的全过程状态机设计让业务逻辑有了锚点通用 CRUD 服务把重复代码压到了最低MySQL8.0 和 Docker 的组合在部署阶段省了大量时间。最让我满意的其实是最后那套文档因为三个月后我自己回来看这个项目也是靠那份文档迅速找回了所有上下文。说白了代码是写给机器的文档是写给三个月之后的自己看的——这句话在我做过这么多项目之后依然成立。