我几年前接过一个办公管理系统项目技术栈是现在很多人在用的 Java SpringBoot SSM。当时甲方的要求很简单把人、事、流程管起来把请假、报销、用章这些日常审批线上化。结果一聊需求才发现办公管理系统这东西看着不起眼真做起来全是细节。一个审批流的状态怎么流转、不同角色的数据权限怎么隔离、考勤异常怎么自动标记每一项都够你折腾一阵子。这篇就把我当时从选型、拆模块、搭骨架到调试踩坑的完整过程整理出来。如果你正准备做毕设或者是公司内部想搞一套轻量的OA系统这套东西可以直接参考。我会把关键代码思路和调试经验都写进去不是那种只给结论的文章而是你拿着它真能动手做出来的那种。1. 为什么是SpringBoot SSM这套技术选型背后的逻辑先说结论这年头做办公管理系统用SpringBoot当底座下面挂SpringMVC、Spring、MyBatis这套SSM组合是在开发效率、学习成本和系统稳定性之间最平衡的选择。我自己做项目时喜欢问自己一个问题这个系统最核心的要求到底是什么办公管理系统就是增删改查多、审批流程多、角色权限细它不需要分布式事务不需要高并发削峰更不需要微服务那套复杂的服务治理。它需要的是结构清晰、开发速度快、后续好维护。1.1 SpringBoot解决的是配置地狱SpringBoot最直观的价值就是不用再写那一大堆XML配置了。传统SSM项目里你要配web.xml、配spring-mvc.xml、配spring-mybatis.xml三张配置文件互相引用稍不留神一个bean扫描不到启动直接报错。SpringBoot把这套全收了约定大于配置把默认值给你安排好。你只需要关心业务代码连接数据库写一行配置就能起来。拿我做的这个办公系统来说pom.xml里引了spring-boot-starter-web和mybatis-spring-boot-starter启动类加个SpringBootApplication一个内嵌Tomcat就直接跑起来了。省掉的XML配置时间够我把部门管理模块写完。1.2 SSM在办公场景里的分工逻辑SpringMVC负责Web层的路由转发和参数绑定。前端发来一个/api/leave/add请求它帮忙找到对应的Controller方法把Json参数自动绑到实体类上。Spring负责管理Bean的生命周期事务控制和依赖注入。审批流里每个服务类的Autowired注入靠的都是Spring容器。MyBatis负责数据库操作。办公管理系统SQL的逻辑很重比如统计考勤异常记录、查询多条件审批列表where加if动态拼SQL的方式比JPA那种全自动的ORM灵活太多。一个有意思的点是很多人纠结到底用MyBatis还是JPA。我的经验是办公系统这种你对SQL有强控制欲的场景直接选MyBatis。JPA适合标准化的简单CRUD但一旦遇到多表联查、复杂条件过滤JPQL写起来又绕又难调优。1.3 这套技术栈的边界在哪这套组合适合单体办公系统但不意味着它万金油。如果你们公司的OA要做到几千人同时在线、审批流程引擎高度可配置化那SpringBoot SSM只是起点后面你要考虑消息队列、分布式缓存、工作流引擎的改造。但这不该是办公管理系统项目的第一版需求——先把流程走通再谈规模化。第一版就把架构搞得很重的我见过太多最后全部死在过度设计上。2. 办公系统的核心业务域先拆需求再动代码不夸张地说办公管理系统的需求拆解比代码实现重要得多。需求没拆清楚代码写一半才发现流程对不上返工成本特别高。我当时拿到甲方的原始需求是一句话把公司的请假、报销、用章、办公用品领用都搬到线上。听着简单一拆就复杂了。2.1 用户权限体系RBAC模型的落地办公系统离不了部门和角色。我的做法是建三张基础表sys_user用户表、sys_role角色表、sys_menu菜单/权限表再加用户角色关联表和角色菜单关联表。这样就有了一套完整的RBAC权限模型。实际开发里有个很关键的细节权限粒度要控制到按钮级别。比如请假审批这个页面经理角色能看到审批通过/驳回按钮普通员工角色只能看到提交申请按钮。这个在SpringBoot里用拦截器可以做到在后端接口上配合权限注解判断当前用户是否有这个操作的权限标识前端再根据接口返回的按钮权限列表去动态渲染。2.2 审批流程模块状态流转的设计思路审批是办公系统的灵魂。请假的流程一般是提交 - 直属主管审批 - 部门经理审批 - 人事备案 - 结束中间每一步都有同意、驳回两个动作。这个我当时没有引入高性能的工作流引擎Activiti/Flowable而是自己设计了一张审批节点表字段含义process_id所属审批流程IDnode_order节点顺序approver_role当前节点审批角色node_status当前节点状态等待/通过/驳回approve_comment审批意见这样设计的好处是审批链路完全由数据驱动。当前节点通过了就把下一个节点的node_status从等待改成审批中流程结束就更新主表状态。如果你一开始就引入Activiti光理解它的概念和写流程定义文件就要一周而且这种简单的直线审批用状态引擎反而更可控。2.3 考勤与日程模块状态建模是关键考勤模块最能体现一个开发者的建模功底。打卡数据每天会产生很多条你得想清楚是每次打卡都存一条还是只存最早打卡时间和最晚打卡时间。我当时的方案是建一张attendance_record表每天每人一条记录明确字段为上班打卡时间、下班打卡时间、考勤日期然后加一个状态字段正常/迟到/早退/缺卡/异常由定时任务每天晚上跑一次比对规则补状态。日程模块就简单一些建一张用户日程表支持新增、修改、删除、查询当天的日程列表再加一个工作日历视图方便查看整月安排。难点在时间的时区判断和重复日程的处理第一版我建议只做单次日程重复日程放二期。2.4 通知公告与文档管理通知公告的本质是内容发布已读状态。内容发布用富文本编辑器存HTML到数据库已读状态需要一张公告与用户的关联表标记哪些人读了哪些没读。文档管理则是文件上传分类权限控制。上传文件要先确认存储位置本地磁盘还是对象存储这涉及文件系统的规划我放到后面功能点章节细讲。3. 从零搭建项目代码骨架的实操记录需求拆完可以动手搭项目了。很多新手在这里容易手忙脚乱一会建包一会建表最后项目结构乱成一锅粥。我搭这个办公系统的时候走的是先建骨架再填肉的路线。3.1 单模块还是多模块别被Maven多模块迷惑我见过一些项目一上来就拆common、system、framework、module四个模块。深度拆解是有益的但办公管理系统这种中小体量的项目拆四层完全是给自己找麻烦。Maven多模块带来的是打包复杂度和模块间依赖管理成本对单体应用来说收益很低。我的建议是单模块但包结构要分层清晰。项目包里按功能模块分controller、service、mapper、entity、vo、config、common、util。一个模块的代码从controller到mapper是一条直线谁在什么位置一目了然新人接手也快。3.2 分层设计的职责边界先明确每层的职责代码才不会乱Controller层只做三件事——接收参数、调用Service、返回统一结果。千万不要把业务判断写在Controller里。Service层业务逻辑的所在地事务的边界在这里。每个Service都面向接口开发便于后期替换实现。Mapper层MyBatis的数据库操作接口每个方法对应XML里的一段SQL或者注解SQL。Entity层表和实体类的映射字段名对齐数据库列名。VO层给前端展示用的对象可以多表组装不需要和数据库结构一一对应。3.3 统一返回体和全局异常处理这是一个做过和没做过项目的明显分水岭。没有统一返回体前端拿到的数据一会儿是Json对象一会儿是字符串一会儿是null联调时候大家一起崩溃。我前期就把Result类写好结构固定为code状态码、message提示信息、data业务数据。接口统一返回Result.success(data)或Result.error(msg)前端只要判断code是否为200就能决定走成功回调还是错误提示。配合一个RestControllerAdvice全局异常处理器Controller里面不用再写try-catch业务异常抛出来全局处理器统一转成友好提示返回代码干净很多。3.4 SQL建表的几条血泪建议办公系统建表有几个坑提前说所有表都要带create_time、update_time、deleted字段逻辑删除比物理删除安全。我后来做恢复数据的时候庆幸当初留了deleted字段不然一堆误删数据找不回来。关键表要加create_by创建人ID做操作日志和数据权限的时候会用到。字符集统一utf8mb4别说存emoji的问题了光生僻字和特殊符号就够你喝一壶。金额字段用decimal(10,2)别用float或double精度问题在报销单上出现一次你就长记性了。4. 五个核心功能点的实现思路与关键代码骨架搭完就进入最硬核的阶段具体功能怎么实现。这里我挑五个办公系统里必然出现、而且有一定技术含量的功能点展开讲。4.1 登录认证Token方案还是Session方案办公系统是内部系统不涉及太复杂的第三方授权我最终选了JWT Token方案。登录流程是用户提交用户名密码后端校验通过后生成Token返回前端前端存到localStorage每次请求在Header里带上Authorization: Bearer {token}后端用一个拦截器统一校验Token合法性。生成Token的代码很简洁// 使用JWT工具类生成token String token JwtUtil.createToken(userId, username, roleList); // Secret和过期时间配置在application.yml // jwt.secretyour-secret-key // jwt.expire86400000拦截器校验Token时注意两点一是放行登录接口和Swagger文档相关的路径二是从Token中解析出用户信息后用ThreadLocal存起来业务代码里随时能拿到当前登录人做数据权限过滤时特别方便。public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行白名单 if (isWhitelist(request.getRequestURI())) { return true; } String token request.getHeader(Authorization); // 校验token并解析用户 - 存入ThreadLocal Long userId JwtUtil.parseToken(token); UserContext.setUserId(userId); return true; }4.2 数据权限过滤不允许越权看数据这是办公系统里的一个大活。比如部门经理只能看自己部门的人事信息普通员工只能看自己的请假记录和工资条。数据权限如果只靠前端隐藏菜单用户可以直接调接口越权拿数据这是巨大的安全隐患。我的方案是给Mapper层的方法统一传一个DataScope对象里面封装当前用户ID、部门ID、角色标识。在MyBatis的SQL里动态拼上数据过滤条件select idselectLeaveList parameterTypemap resultTypeLeaveVO SELECT * FROM leave_record WHERE deleted 0 if testdataScope.userId ! null AND create_by #{dataScope.userId} /if if testdataScope.deptId ! null AND dept_id IN (SELECT id FROM sys_dept WHERE id #{dataScope.deptId} OR parent_id #{dataScope.deptId}) /if /select这里有个常见坑很多人在Service层判断这个人能不能看这条数据表面做了校验但列表查询接口直接SELECT *全返回了然后在Java内存里过滤。数据量小还好数据一多全表数据都传给接口性能和安全都出问题。数据权限必须下沉到SQL层这是原则。4.3 文件上传与管理本地存储还是对象存储办公系统的文件数量不会少公告附件、审批附件、报销凭据照片。我第一版方案用的是本地磁盘存储设计思路是建一张file_record表记录文件的原始文件名、存储路径、文件大小、上传人、关联业务ID。public String upload(MultipartFile file) { // 按日期建目录避免单目录下文件过多 String dirPath /data/oa/file/ LocalDate.now(); File dir new File(dirPath); if (!dir.exists()) { dir.mkdirs(); } // 使用UUID作为文件名避免中文名和重名问题 String newFileName UUID.randomUUID().toString() getExtension(file.getOriginalFilename()); file.transferTo(new File(dirPath / newFileName)); // 保存记录到数据库返回文件ID给前端 return fileId.toString(); }本地存储的好处是简单不依赖外部服务部署的时候挂载一个磁盘目录就行。缺点是集群部署时文件同步麻烦而且磁盘损坏会丢数据。如果公司预算允许二期可以升级到MinIO这类开源对象存储接口和S3兼容SpringBoot集成也就多一个starter的事。我的建议是毕设和内部试用用本地存储够了生产环境直接上对象存储。4.4 审批流数据库设计与状态推进审批流我前面提过设计思路这里补充代码层面的状态推进逻辑。以请假单为例主表leave_record有一个current_node字段指向当前正在等待审批的节点ID。提交申请时创建主记录和第一个审批节点记录审批人通过当前节点时找到approval_node表里process_id当前流程id AND node_order当前节点order 1的下一条记录把它状态改为审批中同时更新主表的current_node。Transactional public void approve(Long nodeId, boolean isPass, String comment) { ApprovalNode node approvalNodeMapper.selectById(nodeId); // 校验当前节点确实是待审批状态 if (!PROCESSING.equals(node.getNodeStatus())) { throw new BusinessException(该节点已处理请勿重复审批); } node.setNodeStatus(isPass ? APPROVED : REJECTED); node.setApproveComment(comment); approvalNodeMapper.updateById(node); if (isPass) { // 找下一个节点并推进 ApprovalNode next approvalNodeMapper.selectByProcessIdAndOrder(node.getProcessId(), node.getNodeOrder() 1); if (next ! null) { next.setNodeStatus(PROCESSING); approvalNodeMapper.updateById(next); } else { // 没有下一个节点整个流程通过 leaveMapper.updateStatus(node.getProcessId(), APPROVED); } } else { // 驳回整单状态改为驳回 leaveMapper.updateStatus(node.getProcessId(), REJECTED); } }这个设计有两个好处。一是流程状态完全落库出了问题可以直接查表定位在哪个节点卡住二是代码逻辑简单不需要引入复杂的工作流引擎一线开发都能接手维护。给这段代码加了Transactional保证节点推进和主表状态更新要么都成功要么都失败不出现节点通过了但单据状态还是审批中的怪现象。4.5 操作日志审计AOP无侵入记录办公系统很看重审计员工什么时候登录、谁在什么时间改了什么数据都要有迹可循。不要在每个Controller里面手动写日志代码那样太重复了。我用AOP做一个切面拦截所有Controller请求自动记录操作日志。Aspect Component public class OperLogAspect { Before(annotation(operLog)) public void doBefore(JoinPoint joinPoint, OperLog operLog) { // 从ThreadLocal获取当前用户 Long userId UserContext.getUserId(); // 记录操作模块、操作类型、请求方法、请求参数、请求IP、操作时间 } }OperLog是一个自定义注解里面只定义模块名、操作类型新增/修改/删除/查询Controller方法上标一下就行。参数序列化这一步有个坑不能直接JSON.toJSONString(args)因为有些对象里可能有大字段、二进制数据直接序列化会把日志表撑爆。我当时的处理是过滤掉MultipartFile类型和长度超过500字符的参数截断存储。5. 调试阶段踩过的五个比较实在的坑要说这个项目最磨人的不是功能开发是调试阶段那些看似没问题但就是跑不通的坑。这里挑常见且典型的五个你在自己搞的时候大概率也会遇到。5.1 SpringBoot版本与SSM组件的兼容性SpringBoot从2.x升到3.x是一次大换代javax.*改成jakarta.*包名一堆第三方starter也跟着要换坐标。我见过很多项目卡在这个坑里SpringBoot用3.2mybatis-spring-boot-starter还是旧版本启动直接NoClassDefFoundError。我的建议非常直接办公管理系统这种常规CRUD项目就选SpringBoot 2.7.x别追新。2.7生态最成熟资料最多网上随便一搜就是答案。新版本留给那些确实需要新特性的大项目去折腾我们做业务系统稳定比时髦重要。5.2 MyBatis的XML文件扫描不到这个坑十个人有八个人踩过。代码里定义了Mapper接口启动也没报错一调用就报Invalid bound statement (not found)。原因基本只有一个MyBatis没找到对应的XML文件。application.yml里要这样配置mybatis: mapper-locations: classpath:mapper/**/*.xml还有一个细节如果你把XML文件放到了src/main/java目录下Maven默认打包时不会把XML文件带进target目录必须在pom.xml的build节点里加资源配置resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources建议的最好做法是XML文件统一放src/main/resources/mapper/目录下和Java代码分开从源头避开这个问题。5.3 拦截器放行路径和静态资源的相爱相杀加登录拦截器之后经常遇到登录页面能打开但CSS/JS/图片全挂了的问题。原因是你拦截了所有路径连静态资源也拦了。SpringBoot的静态资源默认路径/static、/public、/resources等要在拦截器addPathPatterns方法里专门放行或者用excludePathPatterns把静态资源路径排除掉。registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/login, /api/register, /doc.html, /webjars/**);注意我这里只拦截了/api/**路径前端页面和静态资源完全不经过拦截器问题自动规避。代码里注释一下凡是静态资源一律不拦截后面接手的人一眼就能看懂设计意图。5.4 跨域问题在前后端分离下的处理办公系统前端用Vue开发和后端接口完全分离跨域是绕不开的。最省事的方案是用CorsFilterConfiguration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }跨域配置里有几个细节容易埋雷allowCredentials(true)允许携带Cookie但同时addAllowedOrigin(*)就冲突了浏览器会拒绝这种配置必须用addAllowedOriginPattern(*)这样的写法。配置好后前端的请求还要在Axios里设置withCredentials: true否则前端不带Cookie跨域会话拿不到。5.5 数据库连接串的时区问题启动SpringBoot项目数据库连不上报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这个乱码看着吓人其实是MySQL连接串没指定时区。在spring.datasource.url里加上serverTimezoneAsia/Shanghai即可。spring: datasource: url: jdbc:mysql://localhost:3306/oa_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai这个坑的原理是MySQL 8.0以上版本默认时区和中国本地时区不一致连接时要做时区换算没有指定时区就会拿系统默认值。加了这个参数顺带把useUnicode和characterEncoding也带上中文乱码问题一并解决。6. 调试文档和设计文档怎么组织才真正有用标题里提到这个项目带源码LW调试文档讲解很多开发者不重视文档觉得写完代码就完事了。我在实际带团队时发现一份组织得当的调试文档能把后续维护成本降低一大半。这里聊聊我怎么组织配套文档的思路你可以直接套用。6.1 LW论文/设计文档的主要章节如果你拿这个项目当毕设用论文结构可以直接参考这样来组织绪论写清楚企事业单位内部的办公管理现状和痛点说清楚系统要解决的问题、国内外研究现状就差不多了需求分析把系统分为管理员端、经理端、员工端列出每类角色的功能需求和非功能需求画Use Case图辅助说明系统设计数据库表的设计说明、系统架构图、每个模块的时序图和流程图这是论文核心部分系统实现按照模块逐个贴关键代码和页面截图重点说明每一个功能的实现思路系统测试功能测试用例表按模块列出测试项、预期结果、实际结果这个表格不用写得多高级但一定要有总结说明系统完成了什么哪些地方还可以优化。写作建议是以模块为单位来组织内容别按页面写一个模块一个模块捋下来读者跟着你的思路走比自己东看一页西看一屏要舒服得多。6.2 调试文档的核心思路调试文档的价值是明天我自己看不懂了翻一翻就知道当时怎么解决的。我写调试文档的固定套路是环境清单JDK版本、Maven版本、MySQL版本、SpringBoot版本、端口号全部写清楚启动步骤先做什么再做什么包括导入数据库脚本、修改配置文件、启动前端、启动后端常见报错索引按错误信息关键字列索引比如Invalid bound statement对应什么解决方案、端口占用怎么处理每条给两三行结论测试账号说明管理员、经理、普通员工三种角色的测试账号和密码方便别人快速进入系统看效果。最关键的技巧是遇到问题解决问题的过程中第一件事就是把报错信息复制到调试文档里。别等系统搞完再回来补到那个时间点早忘干净了。6.3 代码讲解环节别陷入流水账给代码录制讲解视频或者做文档时最容易犯的错是拿着代码从上往下读。正确做法是先讲场景再讲代码。比如讲审批模块先画一张图展示审批链路的流转让人明白数据从哪里来、经过哪些环节、最终到哪里去然后才开始讲代码。代码从入口方法切入一步步走讲到一个关键方法就把断点停在那里解释一遍让学习者清楚这一步代码存在的目的是什么。这里多花十分钟讲思路比照着代码干讲一小时更有效。这套办公管理系统做完我最大的感受就是技术选型不求新但求顺业务建模一定要先想清楚再动手调试文档趁热写别拖到最后。如果你按这个思路做从需求拆分到完整交付逻辑会清晰很多。遇到具体问题拿不准的优先查自己项目的配置和版本对应关系九成问题出在那两个点上。