
1. 项目整体设计与技术选型思路1.1 为什么是这四件套组合2025年还在纠结企业项目管理系统怎么搭我直接说结论SpringBoot Vue MyBatis MySQL这套组合是目前中小团队做业务管理系统性价比最高的方案。这不是什么情怀选择而是被大量线上项目验证过的稳妥搭配。先说SpringBoot。它把Spring生态里那些繁琐的XML配置全收了内嵌Tomcat打一个jar包就能跑这对企业内部的运维来说太友好了。你想想以前SSH时代部署一个系统要装Tomcat、配数据源、丢war包现在直接java -jar一把梭。尤其企业内部项目管理系统这种体量不需要微服务那套分布式的东西单体应用完全够用SpringBoot的单体模式恰好把复杂度控制在了合理范围内。Vue这边我推荐Vue 3 Vite的组合。Vue的响应式数据绑定让前端开发效率提升一大截组件化开发也让多人协作时互不干扰。Vite的冷启动速度和热更新体验比Webpack好太多几乎是秒级响应。企业项目管理系统涉及大量的表单、表格、弹窗、看板拖拽Vue对这种后台管理类场景的驾驭能力非常成熟生态里有Element Plus这样现成的组件库省去了从零写UI的功夫。MyBatis是持久层的务实之选。它的半自动化特性意味着SQL由你亲自掌控复杂查询可以精细调优不像JPA那样在某些场景下生成出让你抓狂的SQL。项目管理系统的统计报表往往涉及多表关联、子查询、条件聚合用MyBatis写动态SQL简直如鱼得水。尤其是if标签配合where处理多条件查询比拼字符串强一百倍。MySQL作为数据库没什么悬念。企业内部系统数据量一般不会大到需要上分布式数据库的程度MySQL的事务能力、索引性能、生态工具链完全满足需求。搭配Navicat或者DataGrip做开发调试效率很高。对于绝大多数企业项目管理场景MySQL就是最稳健的那个选择。这套组合还有一个隐性优势招人好招。市面上Java开发工程师基本都会这套后续找人维护、二次开发人才池很大。这不是技术时髦度的问题是企业级项目必须考虑的长期运营成本。1.2 需求拆解企业项目管理到底管什么做这套系统之前我先带着需求方把“项目管理”这个概念落地成了一组具体功能点。这是整个项目最关键的阶段因为很多需求方自己都说不清要什么。如果直接动手写代码后续肯定返工。企业项目管理核心就是四件事项目全生命周期管理从立项、计划、执行、监控到结项每个阶段的状态都要能被系统记录和流转。项目基本信息、起止时间、负责人、预算金额、项目类型这些基础档案必须齐备。任务分解与协同一个项目要拆成若干任务任务再分配给具体人员每个人能看到自己的待办、进行中、已完成列表。这里需要支持任务依赖关系、优先级、截止时间以及最基础的评论沟通功能减少线下开会扯皮。进度与里程碑追踪通过里程碑节点判断项目是否按时推进系统要能直观呈现整体进度百分比。进度数据来源于底层任务的完成情况自动汇总而不是人工填写——这一点在实际交付中我会反复强调。资源与成本统计项目投入了多少人力工时花了多少钱剩余预算是多少需要可看的数据报表。这里涉及两个关键指标工时登记和成本核算都需要按项目维度汇总。我把这些需求整理成了一张功能地图后端划分出用户模块、项目模块、任务模块、里程碑模块、工时模块、通知模块、附件模块和统计模块。前端则按页面维度规划为登录页、工作台、项目管理页、任务看板页、报表页、系统管理页。功能边界先理清楚开发才不会乱。1.3 架构分层与目录规范代码结构我习惯按经典的三层架构走但会加一层VO做数据适配。整体分包如下com.company.pm ├── controller // 接口层只做参数接收和结果封装 ├── service // 业务层核心逻辑都在这 │ └── impl ├── mapper // MyBatis数据访问接口 ├── entity // 数据库实体类 ├── vo // 视图对象接口返回给前端的结构 ├── dto // 前端传参的接收对象 ├── config // 全局配置跨域、拦截器、异常处理 ├── utils // 工具类JWT、日期处理等 └── exception // 自定义异常及全局异常处理器为什么要多一层VO因为实体类直接返回给前端有很多风险。比如用户表包含密码字段实体类直接序列化会把密码哈希也带出去数据库设计字段也不能完全贴合前端展示需要。VO层做了一次数据整形相当于接口层和数据库之间的翻译官。DTO则是反向的前端传参往往是一个复杂JSON对象直接用实体类接收容易水土不服DTO可以设计出更贴合参数的类结构。这种分层的好处是每个类职责单一出了问题很好定位。线上排查时看到异常堆栈直接就能判断是参数问题、业务问题还是SQL问题。多人协作的时候大家可以基于包结构并行开发冲突概率小很多。2. 数据库设计与核心表结构2.1 表设计原则与关键字段规划数据库设计是整个项目的地基我见过太多系统因为表设计不合理后期改造痛不欲生。项目管理系统的表不算多但关系比较复杂建表前一定要把关系捋清楚。我设计的核心表包括表名用途关键字段sys_user用户表id, username, password, real_name, avatar, department_id, statussys_department部门表id, parent_id, dept_name, leader_idadmin_project项目表id, code, name, description, status, owner_id, budget, start_date, end_date, deletedadmin_task任务表id, project_id, parent_id, assignee_id, title, description, priority, status, start_date, due_date, progressadmin_task_comment任务评论表id, task_id, user_id, content, created_atadmin_milestone里程碑表id, project_id, name, due_date, status, remarkadmin_work_hour工时记录表id, project_id, task_id, user_id, work_date, hours, descriptionadmin_project_member项目成员表id, project_id, user_id, role_type, join_timeadmin_attachment附件表id, project_id, task_id, file_name, file_url, file_size, uploader_id, created_atadmin_operation_log操作日志表id, user_id, module, action, detail, ip, created_at表命名的前缀我习惯用sys_表示系统级admin_表示业务级这样从名字就能区分归属。每张表都加上id主键、created_at、updated_at、create_by、update_by这些通用字段后面排查数据和做数据审计非常有用。有两个设计细节我要特别说明。第一个是逻辑删除标记deleted字段。项目数据在企业内部是非常重要的记录物理删除风险太大一旦误删且没有备份就全完了。用deleted标记0和1表示正常和已删除查询时统一带WHERE deleted 0条件。第二个是用户表和项目表的关联通过admin_project_member中间表实现不要直接在项目表里挂一个member_ids字符串存多个用户ID那是反面教材。中间表的好处是可以记录每个成员在项目里的角色负责人、开发、测试、观察员未来查“某个用户参与了哪些项目”也只需要一条SQL。2.2 索引设计与SQL优化预判建表简单建索引需要想清楚业务查询模式。我根据高频查询场景做索引规划admin_task表(project_id, status)联合索引支撑任务看板按项目过滤和状态分组的查询。看板是用户每天都要打开的页面这个索引必须建。admin_task表(assignee_id, status)联合索引支撑“我的任务”列表查询。员工一登录就要看自己有哪些活这是高频中的高频。admin_work_hour表(project_id, work_date)联合索引支撑项目工时统计报表按时间范围聚合。admin_project_member表(user_id)单列索引支撑查询用户参与的项目。admin_operation_log表(user_id, created_at)联合索引支撑用户操作行为追溯。索引不是越多越好每个索引都会占用磁盘空间并降低写入速度。我建议只建立能明显提升查询性能的索引表数据量在十万级以下时过度建索引的收益很低反而拖慢插入更新。关于主键我继续用BIGINT自增主键没有引入雪花ID。企业项目管理系统的数据量级和并发量自增主键完全够用而且自增主键对InnoDB的二级索引维护非常友好聚簇索引的物理存储顺序和插入顺序高度一致页分裂概率极低。2.3 初始化数据脚本准备表结构建好后要写一套初始化数据脚本。我习惯把脚本分成两个文件schema.sql建表data.sql灌初始数据。初始数据至少要包含一个超级管理员账号admin三个基础部门研发部、产品部、测试部每个部门下的若干测试用户一个示范项目包含里程碑、任务、工时等数据注意密码不能明文存储。在初始化脚本里直接放BCrypt加密后的哈希值SpringSecurity自带的BCryptPasswordEncoder可以生成。我在项目里单独写了一个生成器类跑一次main方法打印出加密后的字符串再粘到SQL脚本里。3. 后端核心实现与MyBatis实战3.1 接口设计与统一返回结构后端接口设计我遵循RESTful风格但没有走得特别极端。比如更新资源用PUT删除用DELETE这些基本语义是明确的。但如果是批量操作、复杂查询我不会强行套REST资源概念而是用POST 语义化URL来处理。所有接口返回统一封装为ResultT对象public class ResultT { private Integer code; // 200成功400业务错误401未登录500系统异常 private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }统一返回结构的好处是前端axios拦截器可以直接基于code做全局判断不用每个接口单独处理状态判断。比如code401时自动跳转登录页code400时弹出错误提示。如果每个接口返回格式都不一样前端封装逻辑会非常痛苦。接口路径设计如下模块接口路径说明登录认证POST /api/auth/login登录获取token用户管理GET /api/user/page分页查询用户项目管理POST /api/project创建项目项目管理PUT /api/project/{id}更新项目任务管理GET /api/task/project/{projectId}按项目查任务列表任务管理PUT /api/task/{id}/status更新任务状态里程碑GET /api/milestone/project/{projectId}项目里程碑列表工时统计GET /api/report/project/{projectId}/hours项目工时汇总附件上传POST /api/attachment/upload上传附件3.2 JWT登录认证与拦截器配置企业内部系统的登录认证我是直接用JWT实现的没有引Spring Security全家桶。我的考量是 Spring Security的功能非常强大但配置复杂度高很多企业的内部管理系统权限模型没有精细到按钮粒度只需要做到接口级别的登录认证和角色校验。SpringSecurity的过滤器链对新手不友好出问题排查困难。JWT方案的核心代码Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; // 单位秒 public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }拦截器中校验tokenpublic class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String header request.getHeader(Authorization); if (StringUtils.isBlank(header) || !header.startsWith(Bearer )) { throw new BusinessException(401, 未登录或token已过期); } String token header.substring(7); try { Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, Long.valueOf(claims.getSubject())); request.setAttribute(username, claims.get(username)); return true; } catch (Exception e) { throw new BusinessException(401, token校验失败); } } }拦截器注册到WebMvcConfigurer里需要放行的路径有登录接口、静态资源、错误路径。其余/api/**全部拦截。这里要特别注意JWT的过期时间。我设置的是8小时企业内部系统员工一天的工作时长大概就是这个量级。设置太短会导致频繁登录体验差太长会增加token泄露的风险。服务端不用维护session状态很适合多实例部署的场景这也是很多团队选择JWT的原因。3.3 MyBatis动态SQL与复杂查询实现MyBatis的精髓就在动态SQL。任务列表查询是典型的多条件组合场景按项目过滤、按状态过滤、按负责人过滤、按关键字搜索、按时间范围过滤。每个条件都可能为空这时候动态SQL的价值就体现了。以任务分页查询为例select idselectTaskPage resultTypecom.company.pm.vo.TaskVO SELECT t.id, t.title, t.priority, t.status, t.progress, t.start_date AS startDate, t.due_date AS dueDate, u.real_name AS assigneeName, p.name AS projectName FROM admin_task t LEFT JOIN sys_user u ON t.assignee_id u.id LEFT JOIN admin_project p ON t.project_id p.id where t.deleted 0 if testprojectId ! null AND t.project_id #{projectId} /if if teststatus ! null and status ! AND t.status #{status} /if if testassigneeId ! null AND t.assignee_id #{assigneeId} /if if testkeyword ! null and keyword ! AND (t.title LIKE CONCAT(%, #{keyword}, %) OR t.description LIKE CONCAT(%, #{keyword}, %)) /if if teststartDate ! null AND t.due_date gt; #{startDate} /if if testendDate ! null AND t.due_date lt; #{endDate} /if /where ORDER BY choose when testorderField ! null and orderField ! ${orderField} /when otherwise t.created_at DESC /otherwise /choose /select这段SQL里有几个细节需要注意。if标签自动处理条件的有无where标签会自动去掉第一个AND前缀避免SQL语法错误。CONCAT(%, #{keyword}, %)方式做模糊查询而不是直接用%${keyword}%原因是用${}做字符串拼接会导致SQL注入风险。gt;和lt;是XML中大于号小于号的转义写法直接用在XML解析时会报错。关于${orderField}的排序字段这里我用了${}因为ORDER BY子句不能绑定参数。但这里有SQL注入风险解决方案是在Java代码里对传入的orderField做白名单校验只允许预定义的字段名通过比如[created_at, due_date, progress]不在列表里就使用默认排序。3.4 项目进度自动汇总的实现逻辑项目中有一个很核心的功能项目总体进度。这个数据不能靠人工填而是根据任务完成情况自动计算。我在admin_project表里设计了一个progress字段并提供定时任务自动更新。计算逻辑是 加权平均法任务的进度乘以权重汇总后除以总权重。权重如何定按任务优先级和工时预估综合计算。Service public class ProjectProgressService { public void refreshProjectProgress(Long projectId) { ListTaskWeightDTO tasks taskMapper.selectProjectTasksWithWeight(projectId); if (CollectionUtils.isEmpty(tasks)) { projectMapper.updateProgress(projectId, 0); return; } double totalWeight tasks.stream().mapToDouble(TaskWeightDTO::getWeight).sum(); double weightedProgress tasks.stream() .mapToDouble(t - t.getProgress() * t.getWeight()) .sum(); int progress (int) Math.round(weightedProgress / totalWeight * 100); projectMapper.updateProgress(projectId, progress); // 记录操作日志 operationLogService.log(projectId, 进度刷新, 自动计算更新项目进度为 progress %); } }任务本身的进度变化时服务层触发refreshProjectProgress保证数据实时性。同时每天凌晨两点跑一次全量刷新定时任务用于兜底防止某些异常场景下自动触发失败导致数据不更新。这种自动汇总逻辑的关键优势是消除了人工填报带来的数据失真。很多企业的项目周报都要项目经理手动更新进度实际上团队成员的完成情况根本不会及时反馈到项目经理那里。系统自动汇总后每个人完成任务、更新进度百分比项目总进度自然联动变化管理层看到的数据永远是最新的。3.5 MinIO接入与附件管理项目管理系统里附件上传是刚需我选的是MinIO不是OSS。为什么MinIO是一个开源的对象存储服务兼容S3协议可以部署在纯内网环境。很多企业内部对数据安全有要求不能把资料传到公有云MinIO是绝佳选择。SpringBoot整合MinIO的关键配置Configuration public class MinioConfig { Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(http://192.168.1.100:9000) .credentials(minioadmin, minioadmin) .build(); } }上传文件的核心逻辑public String uploadFile(MultipartFile file, Long projectId, Long uploaderId) { try { // 生成唯一文件名避免重名覆盖 String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String objectName project- projectId / System.currentTimeMillis() - UUID.randomUUID() ext; minioClient.putObject( PutObjectArgs.builder() .bucket(pm-bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); // 保存附件记录到数据库 attachmentService.saveAttachment(originalFilename, objectName, file.getSize(), projectId, uploaderId); return objectName; } catch (Exception e) { throw new BusinessException(500, 文件上传失败 e.getMessage()); } }文件下载或预览时通过MinIO的getPresignedObjectUrl生成一个临时访问URL返回给前端有效期设置为5分钟。这样避免了把存储桶设为公开读保障数据安全。这个系统中我额外实现了视频纪要的在线预览。项目评审会录制的视频存储在MinIO里前端通过Video.js播放支持切片播放。当时测试过M3U8格式的直播录制文件用Vue的video播放器加hls.js插件就能实现免插件播放苹果设备上的兼容性也都正常。如果你们的企业内部网络带宽一般建议将视频转码成HLS流再接入播放体验会好很多。4. 前端Vue实现与核心模块讲解4.1 Vue3 Vite工程搭建与目录结构前端我用的Vue 3 Vite Element Plus Pinia组成的现代栈。Vue 3的组合式API让代码组织更灵活尤其是把逻辑抽到独立composables里复用体验比Vue 2的Options API好太多了。src ├── api // 所有接口请求封装按模块拆分文件 │ ├── project.js │ ├── task.js │ └── user.js ├── assets // 静态资源 ├── components // 公共组件UploadFile、ProjectSelect等 ├── composables // 组合式函数useTable、useForm等 ├── layout // 主布局组件侧边栏、顶栏、内容区 ├── router // 路由配置包含动态路由逻辑 ├── stores // Pinia状态管理用户信息、项目上下文等 ├── utils // 工具函数request封装、日期格式化等 └── views // 页面组件 ├── login ├── dashboard ├── project ├── task └── reportVite的配置文件vite.config.js里我设置了两件事。一个是别名指向src目录写代码的时候再也不用写一长串相对路径首页项目的导入路径清爽很多。另一个是开发服务器代理把/api前缀的请求代理到后端端口这样调试时前端不用关心跨域问题。import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, src) } }, server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })4.2 动态路由与权限控制企业系统的用户角色不是铁板一块管理员和普通成员能看到的功能菜单应该不一样。我把菜单权限做在了路由层面。后端登录接口返回用户信息时同时返回该用户的角色列表前端根据角色动态生成可访问的路由表。动态路由的核心逻辑在router/index.jsimport { createRouter, createWebHistory } from vue-router import { useUserStore } from /stores/user const constantRoutes [ { path: /login, component: () import(/views/login/index.vue) }, { path: /, redirect: /dashboard } ] const asyncRouteMap { admin: [ { path: /system, component: () import(/layout/index.vue), children: [ { path: user, component: () import(/views/system/user.vue), meta: { title: 用户管理 } }, { path: dept, component: () import(/views/system/dept.vue), meta: { title: 部门管理 } } ] } ], manager: [ { path: /report, component: () import(/layout/index.vue), children: [ { path: hours, component: () import(/views/report/hours.vue), meta: { title: 工时报表 } } ] } ], member: [] } export function generateRoutes(roles) { const needRoutes [] roles.forEach(role { if (asyncRouteMap[role]) { needRoutes.push(...asyncRouteMap[role]) } }) return needRoutes }登录成功后调用generateRoutes得到该用户的路由数组用router.addRoute()动态注册。这里有一个坑router.addRoute()添加的嵌套路由如果要正确渲染在Layout组件里必须确保路由的path和Layout组件的path对应关系正确。我踩过这个坑子路由的path不能带前导斜杠否则匹配会失效。路由守卫里也要加上权限校验逻辑防止未登录用户直接输入URL访问受保护页面。这个不能省前端路由守卫虽然只是用户体验层的拦截真正的安全校验必须后端做但不能因为后端做了就省掉前端这层否则用户会看到空白页或者报错信息体验太差。4.3 Axios封装与请求拦截前端和所有后端接口打交道的入口我统一封装在了utils/request.js里。这层封装看似简单实际是前端工程质量的分水岭。import axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user import router from /router const request axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截器自动携带token request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) // 响应拦截器统一处理业务错误和HTTP错误 request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { const userStore useUserStore() userStore.logout() router.push(/login) ElMessage.error(登录已过期请重新登录) return Promise.reject(new Error(Unauthorized)) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request统一封装的价值在于所有接口的异常处理、登录态失效处理、错误提示代码只写一遍全局生效。团队里新来的同事写接口不用操心错误处理逻辑只需要关心自己的业务代码。4.4 任务看板的前端实现任务看板是整个系统前端最复杂的模块需要把任务按状态分列展示、支持拖拽改变状态。这里我用了vuedraggable组件实现拖拽这是一个基于Sortable.js的Vue封装。看板的核心数据结构const columns ref([ { key: todo, title: 待办, tasks: [] }, { key: doing, title: 进行中, tasks: [] }, { key: done, title: 已完成, tasks: [] } ]) // 从后端获取任务后按状态分组 function groupTasks(tasks) { columns.value.forEach(col { col.tasks tasks.filter(t t.status col.key) }) }拖拽完成后的处理逻辑function onTaskDrop({ added, moved }) { const task added?.element || moved?.element if (!task) return updateTaskStatus(task.id, task.status) }这里有个关键点拖拽组件操作的是前端本地数组改变的是界面上的列归属。要让这些变更持久化到数据库必须在拖拽结束后调用后端接口把任务ID和新状态传给服务端。否则用户刷新页面任务状态又变回原来的位置。任务看板的加载性能也要注意如果项目下任务数量超过几百个一次性返回所有任务再前端分组渲染体验会明显下降。我采用的方案是前端只请求当前项目的所有任务这已经做了项目维度隔离任务条的卡片渲染做分页懒加载滚动到底部再加载下一批。企业项目管理场景下单项目任务量一般可控但提前做好性能预案更稳妥。4.5 项目统计报表与ECharts可视化项目管理系统如果只有一堆表格管理层的使用意愿会很低。我引入了ECharts做数据可视化。报表模块包含三个核心图表项目进度概览柱状图、工时分布堆叠柱状图或饼图、任务状态统计环形图。ECharts在Vue里的用法很简单import * as echarts from echarts onMounted(() { const chart echarts.init(document.getElementById(progressChart)) chart.setOption({ title: { text: 各项目进度对比 }, tooltip: {}, xAxis: { data: projectNames }, yAxis: {}, series: [{ type: bar, data: projectProgress, label: { show: true, position: top, formatter: {c}% } }] }) })关键点是图表容器的生命周期管理。Vue组件销毁前必须调用chart.dispose()释放资源否则页面上留下隐藏DOM会导致内存泄漏。我在onUnmounted钩子里做了清理。统计报表的后端接口设计要考虑到数据聚合的维度。比如工时统计按项目ID 月份查询SQL中使用DATE_FORMAT(work_date, %Y-%m)做月度分组再用SUM(hours)聚合。这套数据接口在功能上线后可以很方便地做Excel导出方便财务部门做项目成本核算。5. 打包部署与生产环境配置5.1 Maven构建与前后端集成部署方案系统开发完成后部署是最后一道关卡。我用的部署方案是把前端打包后的静态资源直接放进SpringBoot的static目录这样整个系统只有一个jar包部署极其简单。不搞前后端分离部署因为内部系统用户量小不需要单独的前端CDN或Nginx优化。前端构建# 前端构建 npm run build # 构建产物在 dist 目录 # 把dist目录下的所有文件复制到后端 src/main/resources/static 目录在项目pom.xml中配置前端构建插件可以实现一条命令完成前后端构建plugin groupIdcom.github.eirslett/groupId artifactIdfrontend-maven-plugin/artifactId version1.15.0/version configuration workingDirectoryfrontend/workingDirectory installDirectorytarget/installDirectory /configuration executions execution idinstall node and npm/id googledinstall/googled phasegenerate-resources/phase configuration nodeVersionv18.20.0/nodeVersion npmVersion10.5.0/npmVersion /configuration /execution execution idnpm install/id googlednpm install/googled phasegenerate-resources/phase /execution execution idnpm run build/id googlednpm run build/googled phasegenerate-resources/phase /execution /executions /plugin但这里有个需要注意的点Vue Router如果用history模式不是hash模式刷新页面时后端需要做fallback处理把未知路径重定向到index.html。单纯把静态文件放在static目录下SpringBoot默认处理不了这种history路由。我的处理方案是自定义一个ControllerController public class SpaController { RequestMapping(value {/{path:[^\\.]*}, /{path:^(?!api).*}/**/{path:[^\\.]*}}) public String forward() { return forward:/index.html; } }这个正则表达式匹配所有不含点号的路径即非静态资源路径把它们转发到index.html让前端路由接管。如果使用hash模式URL长这样/#/project/list刷新时不会请求后端路径不会有这个问题但URL比较丑我还是选了history模式加上面的fallback。5.2 MySQL生产配置与连接池调优生产环境的数据库配置有几个参数需要认真对待。MySQL的配置文件my.cnf中最关键的是字符集设置[mysqld] character-set-serverutf8mb4 collation-serverutf8mb4_general_ciutf8mb4字符集一定要设置否则中文正常但遇到emoji表情符号、生僻字会报错Incorrect string value。MySQL的utf8其实是utf8mb3不支持4字节字符这是老坑。SpringBoot数据源配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/pms_db?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: yourpassword hikari: minimum-idle: 5 maximum-pool-size: 20 idle-timeout: 600000 connection-timeout: 30000连接池我用的是HikariCP它是SpringBoot默认引入的性能比Druid和C3P0都好。参数设置的依据是企业内部系统并发用户通常在几十人以内maximum-pool-size20已经是富余配置20个连接足够支撑上百人的并发操作设置过大会浪费数据库连接资源。URL参数中serverTimezoneAsia/Shanghai必须指定否则高版本MySQL驱动在处理时间字段时会报时区错误。allowPublicKeyRetrievaltrue是MySQL 8.0配合useSSLfalse必须的参数不加的话连接会报Public Key Retrieval is not allowed。5.3 打包过程中的版本兼容问题我实际建设这个项目时遇到过SpringBoot版本与MyBatis Starter的兼容性问题。SpringBoot 3.x要求JDK 17并且JavaEE包名从javax.*迁移到了jakarta.*。如果团队电脑上装的是JDK 8强行用SpringBoot 3.x编译会爆炸依赖一个都导不进去。我的选型建议是生产环境用什么JDK版本就选什么SpringBoot版本。目前最稳妥的组合是JDK 8 SpringBoot 2.7.x MyBatis Starter 2.3.xJDK 17 SpringBoot 3.2.x MyBatis Starter 3.0.x两个组合都能正常工作但不要混搭。网上很多报错帖根源基本都是版本组合混乱。SpringBoot 2.7.x对JDK 8是最后的支持版本如果企业服务器是传统的JDK 8环境建议直接用2.7.x稳定且生态兼容性最好。JDK 17的G1垃圾回收器默认启用性能和启动速度上都强一截团队如果愿意用新版本SpringBoot 3.2.x也很好。MyBatis的SQL日志打印也是排查问题的利器。在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl加上这一行控制台就能看到所有执行的SQL语句和参数值。这个配置只建议在开发环境打开生产环境打开会泄露查询细节且增加磁盘IO开销。6. 常见问题排查与避坑实录6.1 MyBatis缓存导致的脏数据问题这是MyBatis使用中非常经典的一个坑。MyBatis内置了二级缓存默认没有开启但当你配置了cache标签或引入了一些缓存增强组件后查询结果会被缓存。如果一个项目的数据被多个服务节点同时修改缓存中的旧数据会让用户看到过期内容。我遇到的场景是这样的管理员在系统管理页面修改了某个用户的项目角色任务分配接口读取用户角色做数据权限校验由于二级缓存的存在新角色没有立即生效导致权限判断错误。解决方案是业务系统强烈建议直接关闭二级缓存依赖一级缓存和MySQL自身的查询优化就够了。配置方式mybatis: configuration: cache-enabled: false如果团队确实关心性能有更精细的缓存需求我建议引入Redis做显式缓存在Service层控制缓存的写入、更新和失效而不是依赖MyBatis那种黑盒缓存。显式缓存的好处是你能精确知道数据什么时候更新、什么时候删除不会出现莫名其妙的脏数据。6.2 动态SQL中比较符号报错SQL中写符号在XML文件里会直接报org.xml.sax.SAXParseException。原因是XML解析器把视为标签结束符的一部分实际上只有字符有问题在XML中其实合法但我在写代码时为了统一规范把大于号小于号都做了转义。遇到这个错误排查思路很简单看报错信息里提示的XML文件行号检查是否有未转义的特殊字符。最佳实践是写SQL时全部使用转义形式符号转义写法lt;gt;lt;gt;!lt;gt;6.3 MySQL SSL连接错误用Navicat或者JDBC连接MySQL 8.0时报这样一个错javax.net.ssl.SSLHandshakeException: No appropriate protocol。这个问题的根源是MySQL 8.0默认开启SSL验证而本地的JDK版本与MySQL的SSL协议不兼容。解决方式有两种 第一修改JDBC URL显式关闭SSLjdbc:mysql://localhost:3306/pms_db?useSSLfalseallowPublicKeyRetrievaltrue第二修改MySQL服务端配置关闭SSL要求[mysqld] ssl0企业内部系统数据库一般在内网环境数据链路相对安全关闭SSL对性能反而是正向优化因为省去了SSL握手和加解密的CPU开销。这个报错本身不影响系统功能但会一直刷日志也会导致连接池初始化失败必须处理。6.4 Vue打包后刷新404问题这个问题我上面提过部署到SpringBoot后用户访问/project/list正常但刷新浏览器就404。原因就是history路由模式下的fallback没配好。排查方法分两步 先确认使用的是不是createWebHistory如果用的是createWebHashHistory不会有这个问题。 再确认SpringBoot的SpaController是否正确生效直接在浏览器访问/project/list看返回的是不是index.html。我写的这个fallback正则测试时要多关注带路径参数的场景比如/project/detail/123。有些简单的正则只处理了一层路径深层路由刷新还是会404。6.5 附件上传超时问题上传大文件时前端报timeout of 15000ms exceeded。因为axios全局设置了15秒超时一个视频文件上传慢一点就触发超时了。解决方式是上传接口单独设置更长的超时时间export function uploadFile(file) { const formData new FormData() formData.append(file, file) return request.post(/attachment/upload, formData, { timeout: 120000, headers: { Content-Type: multipart/form-data } }) }同时后端也要同步调整。SpringBoot内嵌Tomcat默认有单次请求体大小的限制不配置的话超过1MB的文件会被拒绝。配置spring: servlet: multipart: max-file-size: 200MB max-request-size: 200MB这两个配置缺一不可后端不调前端怎么等都没用。6.6 MyBatis中TypeHandler的自定义场景做系统过程中还遇到一个数据映射问题。项目的预算金额在数据库中存的是DECIMAL(12,2)但某些统计场景需要显示成“万元”单位。数据库数字是元页面展示要万元。自定义TypeHandler解决这个场景MappedTypes(String.class) MappedJdbcTypes(JdbcType.DECIMAL) public class WanTypeHandler extends BaseTypeHandlerString { Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setBigDecimal(i, new BigDecimal(parameter).multiply(new BigDecimal(10000))); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { BigDecimal value rs.getBigDecimal(columnName); return value.divide(new BigDecimal(10000), 2, RoundingMode.HALF_UP).toString(); } // 其他方法略 }在Mapper的XML中指定result columnbudget propertybudgetWan typeHandlercom.company.pm.handler.WanTypeHandler/TypeHandler的使用场景不少自定义类型转换、加密字段解密、JSON字段映射都用得上。建议有空把MyBatis的TypeHandler机制研究透它在实际开发里非常实用。7. 这套系统的扩展思路与个人心得体会系统跑通之后有几个明显的扩展方向可以聊一下。第一是引入工作流引擎。当前的任务流转只有简单的状态变更如果企业流程复杂比如需要“提交-审批-下发-执行-验收”这种多级审批环节可以引入Flowable或Activiti工作流引擎把业务流程固化成模型。这不是改动小功能的事而是系统架构层面的升级建议在二期规划。第二是消息通知的完善。目前系统内通知只在站内信和页面提示。扩展方向是接入企业微信或钉钉的WebHook让任务分配、里程碑到期、审批通过这些事件推到IM群提高团队响应速度。SpringBoot整合WebSocket实现实时消息推送我也已经尝试过效果很好浏览器标签页不用轮询延迟几乎为零。第三是数据分析增强。当前报表用的是ECharts直连后端聚合数据如果数据量未来增长到百万级可以考虑引入专业的BI组件或者将聚合计算任务下沉到时序数据库。不过对大多数企业内部管理系统来说这个数据量级还很遥远不必过早设计。最后分享一点个人体会做企业项目管理系统最大的难点永远不是技术而是搞清楚业务到底需要什么。很多团队一上来就写代码最后交付的系统同事们根本不用因为流程和实际工作方式对不上。我在这个项目中花了不少时间跟项目管理人员沟通把他们的工作习惯梳理成流程图再转化为功能设计这个前期投入是值得的。技术上这套SpringBoot Vue MyBatis MySQL组合谈不上新潮但胜在稳定成熟就像一把用得顺手的螺丝刀不需要整天换新干活踏实就行。另外一个小建议技术文档要写。不要觉得代码就是文档这个系统的接口列表、数据字典、部署手册我都整理成了内部Wiki。半年后你自己回来看代码没有文档补充理解成本会高出好几倍。这也是我在实际交付中慢慢养成的习惯分享给你。