ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue3居家办公系统实战:从数据库设计到前后端部署

SpringBoot+Vue3居家办公系统实战:从数据库设计到前后端部署 这两年居家办公从临时应急变成了很多团队必须支持的常态。我当时接到一个很现实的需求员工分散在家里怎么收集健康状况、怎么打卡、怎么审批、怎么同步任务后来我把自己参与设计的这套基于 SpringBoot Vue3 MyBatis MySQL 的疫情居家办公系统源码做了完整整理前后端分离接口文档、数据库脚本、部署手册都齐全。这篇文章不是简单放一个演示链接而是把整个项目从业务分析到落地的关键细节拿出来讲透尤其是哪些地方最容易写错、哪些设计曾经让我加班到凌晨这些才是常规教程里不会写的部分。1. 居家办公系统的真实业务痛点以及它为什么不是普通CRUD1.1 分散场景下的信息孤岛问题集中办公时代考勤机解决的是“人到没到”的问题一旦团队分散在家问题就变成了“人在哪里、身体状态怎么样、今天做什么、审批找谁签”。这四个问题如果全部靠在微信群里接龙基本会乱成一锅粥。我开发这个系统时第一件事不是画 ER 图而是把分散场景下的信息流重新理了一遍员工每天需要提交健康状况和体温并且要能追溯到个人上下班不再以打卡机为准而是线上签到和离岗说明工作安排从口头布置变成可跟踪的任务列表请假、报销、用章申请等流程都要支持在线审批公司公告、防疫通知需要有已读回执。把这些痛点列出来之后系统的边界就清晰了。一个居家办公系统首先要解决的是“远程协作 健康数据采集”而不是一上来就做成大而全的 ERP。很多人做这种系统失败不是因为技术不行而是因为边界没控制住把薪资、库存、客户管理全都塞进来结果半年交付不了。1.2 从功能清单反推系统边界理完痛点之后我很快把系统拆成了六个核心域用户权限、健康上报、考勤签到、任务管理、审批流程、公告通知。用这六个域去反推数据库表大概就锁定了二十多张表规模适中既有学习价值又能完整跑通一个真实业务闭环。管理端和员工端的需求有明显差异。管理端要看统计报表、处理审批、推送公告所以需要仪表盘和审批列表员工端则聚焦每天都要用的上报、打卡、任务、申请。这个区别直接影响了前端菜单设计和后端接口设计。比如健康上报的接口管理员需要“按部门汇总”员工只需要“提交当天数据”这两个接口不能混在一起否则前端要传一堆冗余参数后端也会越写越乱。1.3 这套源码适合谁参考不是所有人都有必要从零写一套这样的系统。如果你是 Java 后端新手想看 SpringBoot 项目和 Vue3 项目怎么真实对接如果你要做的毕业设计、公司内部管理后台刚好需要健康上报、在线审批这类模块或者你想把一个完整项目整理进简历那这套源码就非常合适。它麻雀虽小但五脏俱全有 JWT 权限控制、有事务处理、有定时任务、有图表统计、有文件上传、有前后端分离部署这些点几乎覆盖了 Java 后端岗位面试中会被反复追问的常见场景。建议先按本文把系统整体结构过一遍再对照源码去看具体实现。源码不是看了就会必须能说清楚每个表为什么这么设计、每个接口为什么这么拆才是真正吃透了。2. 技术选型复盘SpringBootVue3MyBatis这套组合凭什么够用2.1 后端为什么是 SpringBoot 而不是别的框架SpringBoot 不是性能最强的框架但它是“快速交付业务”性价比最高的选择。它把 SpringMVC、事务管理、自动配置打包在了一起开发时不用再写一堆 XML 配置内置 Tomcat 直接启动打成一个 jar 包就能部署。对居家办公系统这种业务逻辑复杂度适中的后台SpringBoot 2.7.x 足够稳定搭配 JDK 1.8 或 11 都很顺手。这里我要专门提醒一个环境问题如果你本机 JDK 版本太高比如 17 甚至 21某些旧版依赖会报错。项目里我锁定了 SpringBoot 2.7.6 JDK 1.8就是为了避免无谓的兼容性问题。很多人一上来就装最新的 JDK结果 SpringBoot 启动时报UnsupportedClassVersionError然后开始怀疑代码有问题其实只是版本不匹配。2.2 前端为什么选 Vue3 而不是 Vue2Vue3 的组合式 API 写业务比 Vue2 的 Options API 清晰尤其是一个功能涉及多个状态时可以按逻辑聚合而不是散落在data和methods里。配合 Vite 作为构建工具冷启动速度和热更新体验比 Webpack 时代的 Vue2 舒服很多。UI 组件库我选了 Element Plus它在 Vue3 生态里最成熟表单、表格、弹窗这些后台管理高频组件基本开箱即用。居家办公系统前端大多是表格和表单页面比如健康上报列表、审批表单、任务面板。这类界面用 Vue3 Element Plus 开发效率很高。需要注意Vue3 的响应式原理基于 Proxy和 Vue2 的Object.defineProperty不一样直接通过下标修改数组元素的操作在 Vue2 里可能不触发视图更新在 Vue3 里是没问题的但也不能因此随意改数据结构嵌套过深的对象依然要考虑性能。2.3 为什么在这个项目里坚持用原生 MyBatis很多朋友看到标题里有 MyBatis 就会问既然都已经用 SpringBoot 了为什么不用 MyBatis-Plus我在这个项目里刻意保持了原生 MyBatis只用官方提供的 Mapper 扫描和 XML 映射方式。原因很简单原生 MyBatis 能让你更清楚 SQL 是怎么写的尤其是复杂多表查询、统计报表这类场景自己写 SQL 反而更好控制。MyBatis-Plus 的 BaseMapper 确实能省掉很多单表 CRUD但在本项目里像“员工每日健康上报统计”“部门考勤汇总”这类统计 SQL最终还是得靠自定义 SQL 完成。如果你追求开发效率引入 MyBatis-Plus 完全没问题但如果你想搞清楚 MyBatis 的核心原理强烈建议先拿原生版本写一个完整项目。这个项目里的UserMapper.xml、ReportMapper.xml都是手写的动态 SQL面试时被问到 MyBatis 动态 SQL、一级缓存、二级缓存你都能拿真实代码举例而不是背八股。2.4 MySQL 8.0 连接配置的几个坑数据库我选的是 MySQL 8.0。相比 5.78.0 的默认字符集已经是 utf8mb4对中文和表情符号支持更好WITH递归查询、窗口函数这些能力也更强。但 8.0 和 5.7 在驱动、时区、密码加密方式上都有差异最容易踩的有三个连接串里一定要加serverTimezoneAsia/Shanghai否则会差 8 小时驱动类名是com.mysql.cj.jdbc.Driver不是老版本的com.mysql.jdbc.DriverMySQL 8.0 默认使用caching_sha2_password认证如果连接工具版本太老可能报认证插件错误需要创建用户时指定mysql_native_password。这些都是看起来很小的配置但几乎每个用 MySQL 8.0 的人都在这里卡过几小时。后面我会在部署章节再展开一次时区问题。3. 数据库设计从 ER 模型到建表语句的取舍3.1 用户、角色、部门三张基础表不要省几乎所有管理系统都从用户表开始。但很多人为了图省事只建一张sys_user把部门名、角色名直接写成字符串字段放进去这样做原型演示可以真实项目会很难维护。我的设计是三张基础表加两张关联表sys_user用户主表包含用户名、密码、姓名、手机号、邮箱、部门 ID、状态sys_dept部门表包含父部门 ID、部门名称、排序sys_role角色表包含角色编码、角色名称sys_user_role用户和角色的多对多关联sys_dept_relation如果要做树形权限范围会用到部门层级关系。用户表里不建议直接存角色 ID因为一个用户可能有多个角色。居家办公系统里管理员可能同时是部门负责人普通员工也可能兼任考勤统计员这种场景通过多对多关系表才能灵活处理。建表时给逻辑外键加上索引但不一定要物理外键物理外键在分库分表和测试数据清理时会非常痛苦。3.2 健康上报表的唯一约束设计健康上报是这个系统里最核心的一张表我把它命名为health_report。字段包括用户 ID、上报日期、体温、健康状况、是否接触疑似病例、当前所在城市、详细地址、备注、创建时间。这里有一个非常关键的设计(user_id, report_date)必须加唯一索引。原因很简单员工每天只需要提交一条记录如果没有唯一索引应用层就算做了判断并发请求下依然可能插入重复数据。有了唯一索引即使前端连点两次提交或者有人直接调接口重复提交数据库也会拒绝第二条报DuplicateKeyException。后端再捕获这个异常把业务提示改成“今日已上报无需重复提交”一个完整的幂等方案就出来了。体温字段我建议用DECIMAL(4,1)而不是DOUBLE。因为体温只需要保留一位小数DECIMAL能避免浮点数精度问题也方便在 SQL 里直接做AVG和MAX统计。状态字段如健康状况可以用TINYINT存储数字编码不要直接用中文字符串否则统计时写一堆CASE WHEN既不优雅也不好维护。3.3 考勤签到表与审批表的状态机设计考勤表attendance我设计的字段包括用户 ID、签到时间、签退时间、工作地点、工作时长、备注。居家办公和公司打卡最大的区别是地点不固定所以“工作地点”字段建议允许用户手动填写同时记录经纬度。经纬度只是辅助参考不要一开始就想做电子围栏涉及地图定位会显著增加开发成本。审批表approval是这个系统里另一个容易设计过度的地方。我建议用一张大表统一管理请假、报销、用章申请通过approval_type字段区分业务类型而不是每种申请各建一张表。审批状态机就四个状态待审批、通过、驳回、已撤销。为什么这么设计居家办公系统的审批流程相对固定没有复杂的条件网关一张表加一个状态字段足够应付。如果每种申请都建一张表前端要做多个列表页后端要写多套接口维护成本翻倍。后续真要扩展时只需要加一个approval_type编码不会影响已有业务。3.4 SpringBoot MyBatis 当表不存在自动建表的实现网上经常搜到“SpringBoot MyBatis 当表不存在自动建表”的话题我在项目开发早期也研究过。这个需求其实是希望项目启动时自动初始化数据库表结构避免手动导入 SQL。实现方式有两种常见思路第一种是用 SpringBoot 自带的 SQL 初始化能力在application.yml里配置spring: sql: init: schema-locations: classpath:sql/schema.sql mode: always这种方式适合 Simplest 项目但要注意mode: always会在每次启动都执行所以schema.sql里必须写CREATE TABLE IF NOT EXISTS。第二种更可控我在项目里用了一个TableInitRunner实现ApplicationRunner接口在项目启动后执行一次检查逻辑。核心思路是用JdbcTemplate查询information_schema.TABLES判断表是否存在不存在就执行classpath:sql/init.sql中的建表语句。这种自动建表方案在开发测试环境很方便但生产环境我强烈建议换成 Flyway 或者手动执行 SQL。自动建表最大的风险是如果线上数据库里已经存在一部分老表新脚本又修改了字段直接执行 CREATE TABLE 是会报错的。生产环境的表结构变更应该走专门的迁移脚本而不是靠启动时自动执行。我已经在这个坑上吃过亏希望大家不要重蹈覆辙。4. 后端接口实现登录鉴权、业务事务与统计 SQL4.1 JWT Redis 的登录态管理后端接口设计里登录鉴权是最先要确定的模块。居家办公系统是前后端分离部署前端在浏览器后端接口在服务器上天然就是无状态服务所以我用了 JWT Redis 的方案用户登录成功后后端生成一个 token把用户 ID、用户名、角色信息写入 token 的 claims 里再将 token 以key: login:token:用户ID的形式存入 Redis设置比如 8 小时过期前端每次请求在 Header 里带Authorization: Bearer token后端拦截器解析 token校验签名和有效期再查 Redis 确认该 token 是否有效。为什么要存 Redis 而不用纯 JWT 的无状态方案因为纯 JWT 无法处理“用户修改密码后让旧 token 失效”“管理员强制用户下线”这类需求。只要 Redis 里有记录就可以随时删除。相比之下纯 JWT 虽然省了 Redis 查询但遇到权限变更只能等 token 自然过期这在企业管理后台里不可接受。核心拦截器逻辑可以这样理解public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { Claims claims JwtUtil.parseToken(token.substring(7)); if (claims ! null) { UserContext.set(claims); return true; } } response.setStatus(401); return false; }这里有个小细节UserContext一定要用ThreadLocal实现并且在请求结束后的拦截器afterCompletion里执行UserContext.clear()。否则线程池复用线程时会拿到上一个请求的用户信息造成数据越权。这个 bug 很隐蔽不是追查日志根本发现不了。4.2 健康上报接口的幂等与事务处理健康上报接口的完整流程是解析当前登录用户校验参数检查今日是否已上报然后插入一条记录最后给前端返回成功。前面说过唯一索引已经保证了幂等所以在代码里只需要捕获异常Transactional(rollbackFor Exception.class) public ReportResult submitReport(ReportDTO dto) { HealthReport report new HealthReport(); report.setUserId(UserContext.get().getUserId()); report.setReportDate(LocalDate.now()); report.setTemperature(dto.getTemperature()); report.setHealthStatus(dto.getHealthStatus()); // 省略其他字段 try { healthReportMapper.insert(report); } catch (DuplicateKeyException e) { throw new BusinessException(今日已上报请勿重复提交); } return ReportResult.success(); }方法上加了Transactional(rollbackFor Exception.class)表示任何异常都回滚事务。这里的rollbackFor很重要Spring 默认只对 RuntimeException 回滚如果你抛出的是自定义异常但没继承 RuntimeException事务不会回滚数据就可能出现半写入状态。编码规范上我习惯所有业务异常都继承 RuntimeException再在全局异常处理器里转换成对应的 HTTP 状态码。4.3 MyBatis 动态 SQL 实现多条件筛选居家办公系统里的列表页特别多比如用户列表要支持按部门、姓名、状态筛选上报记录要支持按日期、部门、健康状态筛选审批列表要支持按类型、状态筛选。如果每种组合都写一条 SQL代码量会爆炸。MyBatis 的where和if标签就是为这个场景设计的。以用户列表查询为例Mapper XML 是这样写的select idselectUserPage resultTypecom.example.entity.UserVO SELECT u.id, u.username, u.real_name, u.status, d.dept_name FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id d.id where if testquery.deptId ! null and query.deptId ! AND u.dept_id #{query.deptId} /if if testquery.realName ! null and query.realName ! AND u.real_name LIKE CONCAT(%, #{query.realName}, %) /if if testquery.status ! null AND u.status #{query.status} /if /where ORDER BY u.create_time DESC /selectwhere标签会自动处理第一个条件前面的AND所以即使deptId为空后面的realName条件也能正确拼接。不要自己去手动拼 SQL 字符串容易漏空格或者多逗号而且存在 SQL 注入风险。另外分页我用了 PageHelper 插件在业务代码里只要PageHelper.startPage(pageNum, pageSize)再执行查询返回结果就是一个带总数和页码的 Page 对象非常方便。4.4 统计报表接口的 SQL 写法管理端首页需要展示今日上报人数、部门上报率、近七日体温趋势、任务完成率等图表数据。这些数据最好在后端都聚合好前端只负责渲染。一个典型的统计 SQL 是SELECT department_name, COUNT(DISTINCT u.id) AS total_people, COUNT(DISTINCT CASE WHEN hr.id IS NOT NULL THEN u.id END) AS reported_people, COUNT(DISTINCT CASE WHEN hr.id IS NOT NULL THEN u.id END) * 100.0 / COUNT(DISTINCT u.id) AS report_rate FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id d.id LEFT JOIN health_report hr ON hr.user_id u.id AND hr.report_date #{date} GROUP BY d.id, d.dept_name这里我用了DISTINCT CASE WHEN而不是SUM(CASE WHEN ... THEN 1 ELSE 0 END)因为要防止同一个人在同一天有多条关联记录导致重复计数。虽然唯一索引已经保证了同一个人同一天最多一条记录但统计 SQL 里养成使用DISTINCT的习惯能在多表关联时避免很多隐性错误。图表接口返回的数据结构要简洁例如近七日趋势直接返回[{date: 2024-01-01, count: 120}, ...]前端 ECharts 拿来就能用。5. Vue3 前端工程请求封装、路由守卫与权限落地5.1 从 Vite 创建项目到接入 Element Plus前端工程结构我建议直接用 Vite 官方脚手架创建npm create vitelatest home-front -- --template vue cd home-front npm install npm install element-plus axios vue-router pinia echarts这里注意Vite 创建项目后默认没有装 router 和 pinia需要自己加。为了让 Element Plus 的按需引入和图标能正常使用我在vite.config.js里配置了unplugin-vue-components和unplugin-auto-import两个插件这样组件和 API 会自动按需导入打包体积会小不少。目录结构上我习惯把src/api放接口定义、src/router放路由、src/stores放 Pinia 状态、src/utils放请求封装和工具函数、src/views放页面。源码里每个模块对应一个目录比如views/health放健康上报相关页面views/approval放审批页面。如果全部页面都堆在views根目录项目一大会非常难维护。5.2 Axios 拦截器与 Token 失效处理axios 封装是整个前端工程的地基。我在utils/request.js里创建了一个 axios 实例并设置了基础路径const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { ElMessage.warning(登录已过期请重新登录) localStorage.removeItem(access_token) router.push(/login) } return Promise.reject(error) } )这里我把后端返回结构统一为{ code, message, data }拦截器里直接解包data业务页面拿到的就是接口真正的返回值不用每个页面都写res.data.data。遇到 401 状态码时统一跳转登录页而不是让每个接口单独处理。有一个容易被忽略的细节如果后端接口返回 401同时当前已经在登录页跳转会冗余。所以我在跳转前判断了router.currentRoute.value.path ! /login避免无限循环。这个处理虽然只有几行代码但真实项目里没有全局考虑就会出现诡异的白屏问题。5.3 路由守卫与按钮级权限实现居家办公系统的页面分两类管理员页面和员工页面。我在路由表里给每个路由配置了meta: { roles: [admin] }然后在全局前置守卫里做校验router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) if (!token) { if (to.path /login) { next() } else { next(/login) } } else { const userRole useUserStore().roleCode if (to.meta.roles !to.meta.roles.includes(userRole)) { next(/403) } else { next() } } })按钮级权限相对复杂一点。我用的是一个自定义指令v-permission指令里根据当前用户权限码判断是否移除 DOM 元素。比如删除按钮只有管理员能看到页面里就写el-button v-permissionsystem:user:delete删除/el-button。权限码在后端登录接口返回存入 Pinia 和 localStorage。这种方式比做动态路由简单也足够覆盖居家办公系统的权限场景。5.4 数据可视化页面的实现方式管理端首页的仪表盘用 ECharts 展示。ECharts 在 Vue3 里使用很简单先安装echarts然后在组件里引入import * as echarts from echarts const chartDom ref() const myChart ref() onMounted(() { myChart.value echarts.init(chartDom.value) loadChartData() }) const loadChartData async () { const data await reportApi.getTrend() myChart.value.setOption({ xAxis: { data: data.map(item item.date) }, series: [{ type: line, data: data.map(item item.count) }] }) }需要注意容器高度问题。ECharts 的容器必须有明确高度否则图表不显示。我在样式里给chartDom设置了height: 300px然后在窗口大小变化时调用myChart.resize()。如果不做 resize浏览器窗口缩放后图表会变形这个细节在初期很容易被忽略。6. 联调与部署阶段的高频问题排查6.1 前后端字段命名对不上的问题前后端联调时最常遇到的就是字段名对不上。Java 后端习惯用驼峰命名userNameMySQL 表字段习惯用下划线user_name。如果 MyBatis 没有开启驼峰映射返回给前端的对象属性就是下划线格式前端却按驼峰取数据显示当然为空。解决办法是在application.yml里配置mybatis: configuration: map-underscore-to-camel-case: true但如果某些查询用了自定义统计 SQL返回的reportRate之类的字段不会自动映射需要在 SQL 里起别名或者在 resultMap 里显式映射。联调时如果表格数据正常但某些列是空的优先排查是不是字段名映射问题而不是怀疑接口挂了。我习惯在开发环境开启 MyBatis SQL 日志一眼就能看到返回列名和前端字段是否一致。6.2 MySQL 时区导致的时间差 8 小时问题这个坑几乎人人都会踩。现象是数据库里存的时间是对的但后端查询出来传给前端前端显示的时间比实际慢了或者快了 8 小时。原因通常是 MySQL 连接串没有指定时区JDBC 驱动使用了服务器默认时区然后LocalDateTime序列化时又叠加了一次本地时区偏移。排查和解决路径是这样的连接串里必须明确serverTimezoneAsia/ShanghaiSpringBoot 的spring.jackson.time-zoneGMT8也要配上数据库连接驱动用com.mysql.cj.jdbc.Driver日期字段建议统一使用DATETIME而不是TIMESTAMP。TIMESTAMP会受数据库会话时区影响DATETIME存什么就返回什么逻辑更可控。如果已经部署到服务器还要检查服务器时区是不是 UTC。可以用date -R命令看如果是 UTC建议通过timedatectl set-timezone Asia/Shanghai改成本地时区。整个过程就是在“数据库—连接串—后端—前端”整条链路里把所有时区统一成东八区缺一个环节就会偏差。6.3 开发环境开启 MyBatis SQL 日志联调时看不到 SQL 是最难受的。后端接口报错如果你不知道 SQL 实际执行了什么排查效率会极低。我在项目里做了两个配置logging: level: com.example.mapper: debug mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样控制台会打印完整的 SQL 语句和参数列表。比如一条SELECT查不出数据看日志就能确定是条件条件拼接错误还是参数传成了 null。生产环境不建议开stdout否则日志量太大但开发环境强烈建议打开。还有一个技巧如果只想看某个 mapper 的 SQL可以把logging.level.com.example.mapper.HealthReportMapper设为debug其他 Mapper 保持默认减少噪音。6.4 前后端分离部署与 Nginx 代理配置部署是最后一个大头。后端打成 jar 包直接java -jar home-server.jar运行前端执行npm run build生成dist目录放到 Nginx 的html目录下。由于前后端端口不同存在跨域问题但我不推荐在后端代码里写CrossOrigin或者全局 CORS 配置更好的方式是用 Nginx 转发。Nginx 配置的核心是 location 前缀区分server { listen 80; server_name your.domain.com; root /usr/share/nginx/html; index index.html; location / { 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; } }这里比较关键的是try_files $uri $uri/ /index.html。如果写成try_files $uri $uri/ 404刷新前端/health页面时 404因为 Nginx 找不到对应的真实文件。加上/index.html后所有刷新请求都回到前端路由由 Vue Router 自己处理。proxy_pass http://127.0.0.1:8080/;末尾的斜杠表示把/api/前缀去掉转发后端接口里就不需要额外配置 context-path当然这也取决于你后端 ServerContextPath 的设置前后要对应。部署完成后还有一个安全细节后端服务不要直接暴露公网只允许 Nginx 通过内网端口访问。如果服务器上开了防火墙只开放 80/443 就够了8080 端口别对外开放。我现在回头看这套系统的维护过程体会最深的不是某个框架或者某个接口写得多精彩而是“边界控制”和“统一规范”这两件事。居家办公系统看起来简单但一旦把业务边界划清楚所有技术选型都围绕边界来做代码结构自然就稳定了。你要是准备拿这套源码做二次开发建议先从数据库表和部署流程看起跑通之后再去改功能会比从登录页一步一步点要快得多。后面我还会再整理一份关于这套系统里缓存策略和消息通知扩展的实战记录到时候可以接着聊。
返回列表