接手这个项目时,我第一反应是“企业内部的小型网络管理系统”看着门槛不高,真正落地后才发现,它既要照顾网络管理员的日常操作习惯,又要让非技术背景的领导看得懂数据报表,还得保证办公室里几十号人同时操作时不卡壳。整套系统基于Java SpringBoot + Vue3 + MyBatis + MySQL搭建,采用前后端分离架构,前端用Vue3负责交互展示,后端用SpringBoot提供接口服务,数据层由MyBatis操作MySQL。我在这篇文章里会完整复盘这套系统的需求拆解、表结构设计、后端实现、前端联调以及部署上线的全过程,把关键代码、参数配置和踩过的坑都摊开来讲。适合正在做类似企业内部管理系统、或者想用SpringBoot+Vue3练手全栈项目的同学参考。
1. 做这套系统的原因与技术选型背后的决策
1.1 为什么选择前后端分离架构
一开始我其实纠结过,企业内部的小系统是不是直接用SpringBoot模板引擎渲染页面更省事。但后来想清楚一个核心问题:这个系统的使用者不只是网络管理员,还有部门主管、运维值班人员,甚至领导层要看统计报表。不同角色的操作路径差别很大,如果用服务端渲染,每次改交互都要动后端代码,发布流程也变得重。前后端分离之后,前端工程独立开发、独立部署,后端只需要把接口定义好,两边可以并行推进,效率高不少。
实测下来还有两个好处。第一,前端静态资源可以交给Nginx托管,接口请求反向代理到后端服务,遇到大并发时甚至可以单独给静态资源做缓存策略。第二,后端接口可以同时服务Web端和后续可能增加的移动端,哪怕现在只做了网页版,将来要做一个小程序或者钉钉集成,接口不用重写。
当然,前后端分离也带来了跨域、联调成本、打包部署复杂度这些新问题。这些我在后文会一一展开,尤其是跨域和前端路由刷新404这两个坑,几乎每个做分离项目的同学都会遇到。
1.2 SpringBoot + MyBatis:开发效率与SQL可控的平衡点
后端框架选型上,我在Spring Data JPA和MyBatis之间犹豫过。这个系统涉及大量多表关联查询和动态条件筛选,比如设备列表要根据设备类型、状态、所属部门、入网时间组合筛选,还要关联IP资源表和告警记录统计。JPA的自动建表和Repository确实开发快,但面对这种复杂查询,要么写JPQL,要么用Specification拼条件,可读性和调优空间都不如直接写SQL。
MyBatis最大的吸引力在于SQL完全可控。我可以在XML里写精细的关联查询,可以用<where><if>标签动态拼接过滤条件,还能针对慢查询直接调整SQL写法。配合MyBatis的驼峰映射和自定义TypeHandler,实体类和数据库字段之间的转换也很省心。
还有一点,团队里如果有刚入门的新人,MyBatis的学习曲线比JPA平缓得多。你只要会写SQL,基本就能上手写Mapper。不过要注意,MyBatis的二级缓存我是一律关闭的。这个系统数据变更频繁,设备上下线、IP分配状态实时变化,开二级缓存反而容易出现脏读。默认走一级缓存和数据库查询就够了,复杂度没必要往上加。
1.3 Vue3带来的前端开发体验提升
前端用Vue3搭配Vite和Element Plus,刚开始迁移时确实有点不适应,但用顺手之后回不去Vue2了。组合式API把设备的逻辑按功能聚合,而不是按选项分散。比如设备列表页,搜索条件、表格数据、分页状态、加载动画这些逻辑可以放在一个setup函数里组织,代码跳转起来非常直观,维护一个几百行的页面也不觉得乱。
Vue3的响应式系统基于Proxy实现,相比Vue2的Object.defineProperty,新增属性、数组下标操作都不需要额外处理。这个项目里有个网络拓扑页面,需要动态往设备节点数组里添加新的交换机或路由器数据,在Vue2里就要用this.$set处理,Vue3直接list.push()就能触发视图更新,少写很多别扭代码。
组件库选Element Plus还有一个原因,表格、表单、弹窗、树形控件都很齐全,做管理后台基本不用自己造轮子。搭配Vite的热更新,改完代码浏览器秒级刷新,前后端联调时效率提升非常明显。
2. 需求拆解与数据库设计:表结构是系统的地基
2.1 功能模块清单:设备、IP资源、告警、工单、权限
在写任何代码之前,我先和网络管理团队聊了两轮需求,最后把系统拆成了五个核心模块。
设备管理是绝对的主角,负责登记和管理交换机、路由器、防火墙、服务器、无线AP等网络设备,记录设备型号、序列号、固件版本、上线时间、物理位置等信息。IP资源管理负责维护企业内网的IP地址池,记录哪些IP已分配、分配给哪台设备、是否保留、是否冲突。告警管理接收来自网络监控平台的告警信息,包括链路断开、设备离线、CPU或内存使用率过高、端口异常等,需要支持按级别和类型筛选。工单管理用来派发运维任务,比如某台设备需要更换配置、某个IP段需要重新规划,工单要能关联设备和IP,跟踪处理状态。用户与权限管理则区分普通用户、网络管理员、系统管理员三种角色,控制谁能查看、谁能编辑、谁能删除。
这里有个容易忽略的点:企业内部系统虽然规模小,但权限模型一定要从第一天就做好。不要觉得“反正就几十个人用,不做权限算了”。一旦设备数据录入不规范、误删操作发生,再回头补权限体系非常痛苦。我的做法是RBAC模型,用户绑定角色,角色绑定菜单权限和操作权限,后端在接口层面做校验,前端根据权限动态渲染按钮。
2.2 核心表的字段设计与关系
数据库我选了MySQL 8.0,字符集统一用utf8mb4,排序规则用utf8mb4_general_ci。不要用utf8,否则存emoji或者某些生僻字会报错,网络设备备注信息里经常有人粘贴特殊符号,这个坑我替你们踩过了。
设备表net_device是核心表,字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| device_name | varchar(64) | 设备名称,如“核心交换机-3F” |
| device_type | tinyint | 设备类型:1交换机、2路由器、3防火墙、4服务器、5无线AP |
| brand | varchar(32) | 品牌,如华为、H3C、Cisco |
| model | varchar(64) | 型号 |
| sn | varchar(64) | 序列号,带唯一索引 |
| ip_id | bigint | 关联ip_resource表主键 |
| location | varchar(128) | 物理位置,如“A栋3楼机房” |
| status | tinyint | 状态:1在线、2离线、3维护中、4报废 |
| firmware_version | varchar(32) | 固件版本 |
| purchase_date | date | 购买日期 |
| warranty_expire | date | 保修到期日 |
| remark | varchar(255) | 备注 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
IP资源表ip_resource用来管理IP地址,核心字段包括IP地址(varchar存字符串,方便模糊查询)、子网掩码、网关、所属网段、分配状态(0空闲、1已分配、2保留、3冲突)、关联设备ID、备注。这里我特意没有把IP设计成int类型,虽然int存储更省空间,但网络管理员习惯直接按IP段搜索,varchar配合索引做LIKE '192.168.1.%'查询更直观。
告警记录表net_alarm记录了告警消息,包括设备ID、告警类型(1链路断开、2设备离线、3CPU过载、4内存过载、5端口异常)、告警级别(1提示、2重要、3严重)、告警内容、发生时间、处理状态(0未处理、1已确认、2已修复)。工单表net_work_order则记录了工单编号、标题、描述、关联设备ID、优先级、状态(0待处理、1处理中、2已完成、3已关闭)、创建人、处理人、创建时间、完成时间。
用户表sys_user和角色表sys_role、用户角色关联表sys_user_role组成权限模型。用户表不要存明文密码,这个我后面细说。
2.3 字段设计的几个关键细节
表结构看似简单,但有几个细节直接决定了后面开发是否顺手。
第一个是统一的时间字段类型。我全部用datetime,Java后端用LocalDateTime接收,配合MyBatis的类型处理器,不需要额外配置就能正确读写。别混用timestamp和datetime,否则不同MySQL版本下行为不一致,排查起来很头疼。
第二个是逻辑外键。我没有在数据库层面建外键约束,只保留逻辑关联字段,比如设备表存ip_id。小型系统数据量不大,外键约束对性能影响可以忽略,但它对删除操作限制太死。比如要删一个IP资源,如果数据库有外键,必须先删关联设备,操作顺序很僵化。逻辑外键配合代码层面的校验,灵活性和完整性都能保证。
第三个是状态字段全部用tinyint,不直接存中文字符串。后续扩展状态值时,只改枚举和字典表,不用动表结构。前端通过字典接口拉取状态说明,展示端和存储端彻底解耦。
3. 后端核心实现:从Mapper到Controller
3.1 项目结构与分层规范
后端工程我按功能包名划分,没有用传统的按层分包,这样每个业务模块的Controller、Service、Mapper放在一起,找代码非常方便。
com.example.netmanage ├── common // 通用类:统一返回、异常处理、常量 ├── config // 配置类:MyBatis、CORS、拦截器 ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // MyBatis数据访问层 ├── entity // 实体类 ├── dto // 数据传输对象 └── security // JWT与权限相关实体类放在entity包里,dto专门放前端传入的参数对象,比如分页查询参数DeviceQueryDTO,包含当前页、每页条数、设备名称、设备类型、状态、位置等字段。为什么不直接用实体接收查询参数?因为实体类对应表结构,字段固定且不包含分页相关属性。如果用Map接收,代码又失去可读性。独立DTO让参数结构清晰,后续加筛选条件也不影响实体类。
Controller只负责参数接收和结果返回,不写业务逻辑。所有业务判断放在Service层,Mapper只做数据读写。这个分层规范坚持下来,项目越往后越轻松。我见过很多小项目把业务逻辑堆在Controller里,前期看着省事,加功能时各种方法互相调用,改一个需求牵扯一片。
3.2 统一返回体与全局异常处理
前后端分离项目里,接口返回格式不统一,前端解析起来就是灾难。我的方案是所有接口返回统一的结果对象Result<T>,结构是code、message、data三个字段。code为200表示成功,其他code对应具体错误场景,比如401未登录、403无权限、500系统异常。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }配合@RestControllerAdvice做全局异常处理,业务里只要抛出BizException,就能自动转换成对应的Result返回,不需要每个Controller都写try-catch。未登录时抛出401异常,前端axios拦截器接收到后自动跳转登录页。这里要注意,全局异常处理不能把日志吞掉,我看过有些项目全局捕获异常后只返回“系统错误”,日志里啥也不留,线上问题根本没法定位。我习惯在异常处理里用log.error把堆栈打印出来,同时返回给前端友好提示。
3.3 设备列表动态查询:MyBatis XML写法示例
设备列表是这个系统最核心的查询场景:管理员打开设备管理页,需要按条件组合筛选。在Mapper XML里,我用<where>配合<if>动态拼接条件,既避免了where 1=1这种丑陋写法,也能复用一个SQL应对多种查询组合。
<select id="selectDevicePage" resultType="com.example.netmanage.entity.Device"> SELECT d.id, d.device_name, d.device_type, d.brand, d.model, d.sn, d.location, d.status, d.firmware_version, d.create_time, d.update_time, i.ip_address, i.subnet_mask, i.gateway FROM net_device d LEFT JOIN ip_resource i ON d.ip_id = i.id <where> <if test="query.deviceName != null and query.deviceName != ''"> AND d.device_name LIKE CONCAT('%', #{query.deviceName}, '%') </if> <if test="query.deviceType != null"> AND d.device_type = #{query.deviceType} </if> <if test="query.status != null"> AND d.status = #{query.status} </if> <if test="query.location != null and query.location != ''"> AND d.location LIKE CONCAT('%', #{query.location}, '%') </if> <if test="query.ipAddress != null and query.ipAddress != ''"> AND i.ip_address LIKE CONCAT('%', #{query.ipAddress}, '%') </if> </where> ORDER BY d.create_time DESC </select>对应Mapper接口:
public interface DeviceMapper { List<Device> selectDevicePage(@Param("query") DeviceQueryDTO query); long countDevicePage(@Param("query") DeviceQueryDTO query); }分页我用了PageHelper插件,Controller里直接传页码和每页条数,PageHelper会自动拦截SQL生成count查询和分页语句。这里有个使用规范:PageHelper的startPage方法必须紧跟查询方法,中间不能插入其他数据库操作,否则分页会错乱。我用过一次在startPage和selectDevicePage之间加了一句日志打印,结果日志里的Mapper查询也被分页拦截了,查出来的count完全不对,调试了半小时才定位到。这个问题非常隐蔽,写代码时一定注意。
3.4 JWT登录认证与接口权限控制
登录认证我用的是JWT,实现方式不复杂:用户输入账号密码,后端校验通过后用HMAC SHA-256算法生成token,token里包含用户ID、用户名、角色编码和过期时间,返回给前端。前端把token存在本地存储,每次请求在请求头加Authorization: Bearer <token>,后端通过拦截器解析token并设置当前用户上下文。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains("/auth/login")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException(401, "未登录或登录已过期"); } // 解析token,校验签名和过期时间 Claims claims = JwtUtil.parseToken(token.substring(7)); if (claims == null) { throw new BizException(401, "Token无效"); } UserContext.set(claims); return true; } }密码存储方面,绝对不能用MD5,现在GPU暴力破解MD5速度极快。我用的BCrypt加盐哈希,每次加密结果不同,数据库泄露了也不容易反推原文。Spring Security的BCryptPasswordEncoder可以单独引入使用,不一定要引入整个Security框架。小型系统用拦截器做认证,配合方法级权限校验注解,比引入Security全家桶轻量得多,学习成本也低。
不过拦截器方式有个天然局限:它对静态资源也会拦截,部署时前端静态资源和后端接口如果同源,需要在配置里排除静态资源路径,否则会出现前端页面能打开、接口请求401的怪现象。这个我在联调时就遇到过,排查到最后发现是拦截器把静态资源请求都拦了。
4. 前端Vue3实现与前后端联调
4.1 项目初始化和依赖安装
前端工程我用Vite创建,选择Vue3 + JavaScript模板,组件库用Element Plus,状态管理用Pinia,路由用Vue Router 4。创建命令简单:npm create vite@latest netmanage-web -- --template vue,然后安装依赖:
npm install element-plus npm install pinia npm install vue-router@4 npm install axios npm install sassElement Plus按需引入可以用官方推荐的unplugin-auto-import和unplugin-vue-components插件,这样打包体积能小不少。我实测过全量引入和按需引入,一个只有十几个页面的管理后台,全量引入打包后的JS文件会大两三MB,首屏加载明显变慢。如果团队对打包体积不敏感,或者项目后续页面会非常多,按需引入是值得一开始就配置好的。
登录页到首页的主流程实现中,我会在路由守卫里做登录态校验:没有token就跳转登录页,有token但访问了不存在的路由就跳404页。这里有个细节,本地存储token不要放在localStorage里而是sessionStorage,关闭浏览器后登录态自动失效,企业内部管理系统这样更安全,避免公用电脑上别人直接打开浏览器就能进入系统。
4.2 设备资料卡片的双列表格展示
设备管理页我用Element Plus的el-table组件,数据来自后端分页接口。搜索区域用el-form的inline模式,一行排设备名称、设备类型、状态三个筛选项,右侧放查询和重置按钮。表格列定义注意几点:操作列固定在最右侧,宽度100px,按钮根据权限动态渲染,编辑和删除按钮用v-if控制;状态列用el-tag展示,不同状态不同颜色,在线是绿色、离线是红色、维护中是橙色;IP列因为关联了IP资源表,展示ip_address字段。
Vue3组合式API写这个页面的核心逻辑:
const queryParams = reactive({ current: 1, size: 10, deviceName: '', deviceType: null, status: null }); const tableData = ref([]); const total = ref(0); const loading = ref(false); const fetchList = async () => { loading.value = true; try { const res = await getDevicePage(queryParams); tableData.value = res.data.records; total.value = res.data.total; } finally { loading.value = false; } }; const handleSearch = () => { queryParams.current = 1; fetchList(); }; const handleReset = () => { queryParams.deviceName = ''; queryParams.deviceType = null; queryParams.status = null; handleSearch(); };分页组件用el-pagination,通过v-model:current-page和v-model:page-size绑定页码和每页条数,@current-change和@size-change都触发fetchList。这里要特别注意,修改每页条数时页码要重置为1,否则用户在第5页切换成每页50条时,后端拿不到对应页的数据,表格会空白。
4.3 axios封装与跨域问题处理
axios封装是前端工程质量的关键。我单独建了request.js,初始化axios实例,设置基础URL和超时时间,请求拦截器自动加上认证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 = sessionStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( response => { const res = response.data; if (res.code === 200) { return res; } if (res.code === 401) { sessionStorage.removeItem('token'); router.push('/login'); return Promise.reject(new Error('登录已过期')); } ElMessage.error(res.message || '系统错误'); return Promise.reject(new Error(res.message || '系统错误')); }, error => { ElMessage.error(error.message || '网络异常'); return Promise.reject(error); } );开发环境跨域用Vite的代理配置解决,在vite.config.js里加server.proxy,把/api开头的请求转发到后端服务的http://localhost:8080。这样前端请求地址保持/api开头,后端Controller的RequestMapping也只写业务路径,不写/api前缀。生产环境则依赖Nginx转发,这个文末会说。
4.4 网络拓扑页的动态渲染实现
这个页面是整个系统前端实现中最有挑战的部分。需求是要展示核心交换机、汇聚交换机、接入交换机、路由器之间的连接关系,支持点击节点查看设备详情。我没有用第三方图形库,直接用Vue3的动态组件配合绝对定位实现了一个简化版拓扑。
每个设备节点是一个相对定位的容器,根据后端返回的坐标数据设置left和top值,节点之间用SVG线条连线。后端在设计设备表时预留了topo_x和topo_y字段存储坐标,初始部署时管理员手动拖拽调整位置,保存后回写数据库。
<template> <div class="topo-container" ref="topoContainer"> <svg class="topo-lines"> <line v-for="line in topoLines" :key="line.id" :x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2" stroke="#409EFF" stroke-width="2" /> </svg> <div v-for="node in topoNodes" :key="node.id" class="topo-node" :style="{ left: node.x + 'px', top: node.y + 'px' }" @click="showDetail(node)"> <span>{{ node.name }}</span> </div> </div> </template>拖拽实现我直接用了HTML5拖放API,没引第三方库。每个节点draggable="true",拖拽结束后把新坐标通过接口保存。这里需要注意,SVG线条的坐标要根据节点尺寸计算中心点,不然线会从节点左上角穿过,视觉上非常别扭。计算中心点时把节点宽度和高度各取一半,偏移量算准后线条才贴合。
4.5 表单校验的常见细节
新增和编辑设备用同一个弹窗组件,表单校验规则用Element Plus的rules配置。设备名称必填、IP必填、设备类型必选,SN可选但如果有值长度限制64位。IP格式校验我写了自定义校验函数,用正则匹配IPv4地址格式。序列号如果重复,后端会返回“序列号已存在”的业务错误,前端在新增提交时提前调用查询接口做一次去重校验,减少用户等待后端报错的挫败感。
时间选择器我用的el-date-picker,绑定值为Date类型,提交给后端前要格式化成yyyy-MM-dd HH:mm:ss字符串。不格式化直接提交,axios序列化后Date对象会变成ISO字符串,和MySQL的datetime格式对不上,插入时就会报错。这个我在联调第一天就中招了,后来统一在提交前调用moment做格式化,问题解决。
5. 部署、测试与常见问题排查实录
5.1 本地联调环境的搭建
本地开发我建议后端和前端分开启动。后端用IDEA直接运行SpringBoot主类,端口8080。前端用npm run dev启动Vite开发服务器,端口5173,通过代理转发接口请求。MySQL用Navicat或者命令行创建数据库,执行初始化SQL脚本。
MySQL安装有个细节:如果你本机之前装过5.7版本,再装8.0时要彻底卸载干净,包括删除数据目录和服务注册信息,否则服务起不来或者端口冲突。我见过很多人在Windows上折腾半天,最后发现是旧版MySQL服务没删干净。
连接MySQL的驱动和配置要注意时区问题。MySQL 8.0以后驱动类名是com.mysql.cj.jdbc.Driver,连接串必须加serverTimezone=Asia/Shanghai,否则会报时区错误。我用的配置如下:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/netmanage?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username: root password: yourpassword servlet: multipart: max-file-size: 10MB max-request-size: 10MB数据库连接池我用的是HikariCP,SpringBoot 2.x以后默认就是它,不需要额外引入。连接池参数里,maximum-pool-size我设成20,小型系统足够了;minimum-idle设成5,避免空闲连接全部释放后请求还要重新建连。connection-timeout设成30000毫秒,防止数据库短暂不可用时请求全部卡死。
5.2 上线部署的流程与注意事项
项目上线我用单机部署方案:一台Linux服务器,安装JDK 17、MySQL 8.0、Nginx。后端打成可执行jar包,用systemd配置成服务,开机自启。前端打包后在Nginx配置静态资源服务和接口反向代理。
Nginx配置核心代码如下:
server { listen 80; server_name netmanage.example.com; root /opt/netmanage-web/dist; index index.html; 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;这一句至关重要。前端用了Vue Router的history模式,用户直接访问/device/list刷新页面时,Nginx如果找不到对应文件会返回404,加上这行配置后所有路由都回退到index.html,由前端路由接管。不用hash模式的原因很简单:URL里带#号既不美观,有些内部系统对#开头的地址还有特殊解析问题。
jar包部署时我用nohup java -jar启动过一次,后来改用systemd管理,好处是进程崩溃后自动重启,还能查看日志:journalctl -u netmanage -f。生产环境MySQL的账号尽量单独创建,只授予业务所需的增删改查权限,不要用root连接应用,这是最基本的安全底线。
5.3 常见问题排查速查表
下面是这个项目开发、联调、部署过程中我遇到过的典型问题,整理成速查表,遇到类似报错的直接对号入座。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 接口返回数据中时间为null | Jackson无法序列化LocalDateTime | 引入jackson-datatype-jsr310,在application.yml配置全局日期格式 |
| 前端请求接口报CORS错误 | 开发环境代理配置缺失,或后端未开启跨域 | 开发用Vite proxy,生产用Nginx同源代理,后端设置CorsFilter兜底 |
| MyBatis查询结果某字段一直是null | 数据库下划线字段无法映射驼峰属性 | mybatis.configuration.map-underscore-to-camel-case=true |
| 列表分页总数不对 | PageHelper多租户插件冲突或startPage被其他SQL拦截 | 确保startPage紧跟查询方法,中间不执行任何Mapper方法 |
| 前端打包后刷新404 | 路由history模式未配置try_files | Nginx location / 加try_files $uri $uri/ /index.html |
| 媒体文件或时间类型报错 | 数据库与Java类型不匹配 | 统一使用LocalDateTime对应datetime,tinyint对应Integer |
| 上传图片大小超限报错 | SpringBoot默认上传限制1MB | 配置spring.servlet.multipart.max-file-size |
| 服务器时间显示差8小时 | JVM默认时区不对 | JVM启动参数加-Duser.timezone=Asia/Shanghai |
还有一个很容易踩的坑是MyBatis的<if test>判断里,如果参数类型是Integer,不要写!= '',否则会触发类型比较警告,极端情况下判断结果不对。字符串字段才需要同时判断null和空字符串,数值类型只需要判断null。这个错误很隐蔽,因为大多数情况下数值参数传了就有值,不传就是null,但如果有人传了空字符串,Integer的!= ''会直接报类型转换异常,很难排查。
5.4 关于MyBatis缓存的补充说明
我在前文提到关闭了MyBatis二级缓存,这里展开说下原因。MyBatis默认只开启一级缓存,即SqlSession级别的缓存。在Spring集成环境下,每个请求对应一个SqlSession,请求结束SqlSession关闭,一级缓存自然清空,不会造成跨请求脏读。二级缓存是Mapper级别的,多个SqlSession共享,存在三个问题:缓存和数据库的一致性难保证,因为系统里设备状态随时变化,一个UPDATE操作后缓存可能还是旧值;分布式部署时缓存都在本地节点,节点之间数据不一致;配置参数稍微写错还容易序列化异常。
所以我建表时也刻意保持数据变更的实时性要求,设备上下线状态、IP分配状态、告警处理状态这些高频变更数据全部走数据库实时查询。只有设备类型字典、角色权限这类几乎不变的数据,才在服务启动时加载到内存Map里做缓存。这样既保证了性能,又避免了缓存一致性问题。
一些实操后的经验总结
这个项目做完,我个人最大的体会是:企业内部“小系统”的难点从来不在技术,而在需求的完整度和对细节的坚持。技术栈SpringBoot+Vue3+MyBatis+MySQL都是成熟方案,网上资料一大堆,但真正把设备管理、IP资源、告警工单、权限控制这几个模块串起来,需要想清楚每一张表的关系、每一个接口的边界、每一个交互细节的异常处理。
我踩过的坑里,最有代表性的三个一定要再强调一遍:第一,表设计阶段就要统一时间类型和字段命名规范,否则后期改表结构比写代码还痛苦;第二,前后端分离项目跨域和路由刷新404这两个问题要在架构设计时就规划好,别等部署了再补;第三,MyBatis的动态SQL是核心优势,但<if>标签的判断细节要严谨,数值类型和字符串类型的判断逻辑完全不同。
最后分享一个小技巧:这种管理系统做完后,我建议花半天时间把初始化SQL脚本里预置好常用的字典数据,比如设备类型枚举、告警级别的中文说明,这样前端展示时可以少写很多if-else,直接通过字典接口映射。还有个后续扩展方向,这个系统可以接入网络设备SNMP协议,自动采集设备在线状态和端口流量,替换掉目前手动登记状态的方式。虽然改动量不小,但价值提升是肉眼可见的。