简介这是一份面向Java Web开发者与企业信息化实施人员的OA办公自动化系统项目源码包围绕需求分析、系统设计、流程引擎、权限管理、移动办公、报表分析与项目实施维护等环节提供可参考的工程实现与模块组织思路。rar压缩包共收录1212个文件约17.92MB以JavaScript、GIF、Java类文件、XML配置、JAR依赖、HTML/CSS/JSP页面及FreeMarker模板等为主兼有工作流相关字节码、配置和说明文档能够较完整地呈现传统OA项目的分层结构与前端资源组织。目前已有387人学习适合需要了解审批流、权限控制、系统集成和后台管理页面搭建的读者。通过梳理其中的Java源码、JSP页面、流程与权限模块以及静态资源可辅助理解OA系统从业务建模到部署实施的各个环节为课程设计、毕业项目或企业办公系统二次开发提供结构参照与实现线索。1. 从一张请假单说起这套 OA 系统项目的功能边界上周一个做行政的朋友把一份 OA 系统项目丢给我说他们三十多号人的请假、报销、用章、公文流转还压在 Excel 加微信群里想找个能直接跑起来的底座。我拆了两天这套 OA 系统项目不是只做了登录页的演示壳子组织架构、角色权限、审批流转、待办消息、附件归档这几条主链路都是通的代码分层也比较规整。它适合两类人。一类是想给中小团队快速搭一套内部办公系统的开发者改改表单和流程节点就能上线另一类是想拿一个完整的前后端分离工程练手的同学登录鉴权、分页查询、文件上传、状态机这些常见套路在里面都能找到对应实现。下面按我实际拆解的顺序走先看技术栈和目录再进核心链路读代码然后从建库一路跑到登录接着是几个我踩过的坑最后讲怎么把它改成自己的业务系统。2. 技术栈与目录结构先认清骨架再动手拿到一个陌生工程我一般不会急着git clone完就run而是先把pom.xml和目录树过一遍搞清楚它是不是前后端分离、权限模型怎么设计的、有没有引入第三方工作流引擎。这套 OA 系统的骨架不算复杂属于典型的单体后端加独立前端读起来负担小。2.1 技术选型为什么是单体加自研审批流后端是 Spring Boot MyBatis-Plus 的组合MySQL 存业务数据Redis 承担会话缓存和待办计数鉴权走 JWT 无状态令牌文件默认落本地磁盘配置里留了对象存储的开关。前端是 Vue 3 Vite Element Plus路由按菜单动态生成按钮级权限用自定义指令控制显隐。这里有个值得说的取舍审批流没有直接引入 Flowable 这类重型引擎而是自己建了流程定义、流程实例、任务、流转记录四张表用状态机驱动。常见做法是流程节点不多、审批规则以串行和简单分支为主的内部系统自研表驱动比上 BPMN 引擎更好维护出问题的时候你能直接查表定位不用去啃引擎的几十张内部表。代价是复杂会签、驳回重走、加签这些高级玩法要自己补后面二次开发那一章会讲怎么补。技术栈大致是这样层次选型在项目里的角色后端框架Spring Boot启动、依赖注入、统一异常处理持久层MyBatis-Plus单表 CRUD、分页插件、逻辑删除数据库MySQL用户、部门、流程、表单数据缓存Redis登录会话、待办数量、字典缓存鉴权JWT 拦截器接口级鉴权、按钮权限码下发前端Vue 3 Vite Element Plus动态路由、表单渲染、列表页文件本地磁盘或对象存储附件上传、预览、归档选型上没有特别花哨的东西都是中小团队能接得住的技术。真正决定这套代码好不好改的是分层是否清晰这就得看目录了。2.2 目录结构从启动类到 Mapper 的完整分层工程根目录下分后端和前端两块后端按业务域拆模块前端按功能页拆目录。我整理了一份关键路径清单第一次读源码的时候对照着看会快很多路径作用二次开发时的改动频率oa-admin启动类、全局配置、拦截器注册低oa-common统一返回体、业务异常、工具类、常量中oa-system用户、部门、角色、菜单、字典高oa-flow流程定义、实例、任务、审批记录高oa-file附件上传、下载、预览中oa-web/src/api按模块拆的接口封装高oa-web/src/views页面组件按业务目录组织高oa-web/src/router静态路由与动态菜单注入中后端每个业务模块内部都是controller / service / mapper / entity / dto五层dto负责接收入参和出参转换entity和数据库表一一对应。这种分层很多人觉得啰嗦但好处是改接口的时候不会牵一发动全身前端传参变化只动 DTO表结构变化只动 entity 和 mapper。我一般会先看oa-common里的统一返回体和异常定义因为所有 Controller 的返回都套这一层看懂了后面读接口就顺了。前端的router目录里有一份静态路由表登录后拿到的菜单数据会被拼进去这一点在排查「菜单不显示」类问题的时候特别关键后面避坑章节会展开。2.3 配置与环境数据源、Redis、文件路径怎么改配置文件在oa-admin/src/main/resources下按环境拆了application.yml、application-dev.yml、application-prod.yml。第一次跑之前至少要改三处数据库连接、Redis 地址、附件存储路径。核心片段大概长这样server: port: 8080 servlet: context-path: /api # 前端代理要跟这里保持一致 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/oa_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 0 servlet: multipart: max-file-size: 20MB # 单文件上限跟 Nginx 配置要一起改 max-request-size: 50MB oa: jwt: secret: change-this-to-your-own-secret # 生产必须换掉否则令牌可伪造 expire-hours: 2 file: storage: local # local 或 oss切换实现类即可 local-path: D:/oa-upload # Linux 下换成 /data/oa-upload 并确认写权限context-path设成/api之后前端开发服务器的代理必须指向同一个前缀否则会出现「接口 404 但浏览器看着像跨域」的玄学现象。jwt.secret是签名密钥源码里给的默认值只能用于本地调试上线前务必替换长度建议不少于 32 位。oa.file.storage是一个策略开关local走本地磁盘实现oss走对象存储实现两个实现类都实现了同一个上传接口切换时不用改业务代码。还有一处容易忽略的是 MyBatis-Plus 的逻辑删除配置deleted字段默认 0 表示未删查询时会自动拼上deleted 0。如果你手动写 XML 的关联查询记得自己补这个条件不然会查出已删除的数据这种问题在列表页上表现得很隐蔽。3. 核心链路拆解一次审批从提交到归档经过哪些代码骨架看完接下来是最值得读的部分。我把这套 OA 系统的主链路拆成四段登录拿令牌、提交审批生成任务、审批通过推进节点、附件跟着单据走。每一段都有对应的核心类读懂了这四段剩下的模块基本都是同一套模板。3.1 登录鉴权令牌签发与拦截器校验登录入口在AuthController真正的逻辑在AuthService.login()。这段代码值得逐行看因为它串起了用户查询、密码校验、状态判断、权限加载、令牌签发、会话缓存六个动作Service public class AuthService { Resource private SysUserMapper userMapper; Resource private MenuMapper menuMapper; Resource private JwtUtil jwtUtil; Resource private PasswordEncoder passwordEncoder; Resource private StringRedisTemplate redisTemplate; public LoginVO login(LoginDTO dto) { // 1. 按用户名查未删除用户 SysUser user userMapper.selectOne(new LambdaQueryWrapperSysUser() .eq(SysUser::getUsername, dto.getUsername()) .eq(SysUser::getDeleted, 0)); // 账号不存在和密码错误返回同一句提示避免被枚举账号 if (user null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { throw new BizException(账号或密码错误); } // 2. 停用账号直接拦掉 if (user.getStatus() 0) { throw new BizException(账号已停用请联系管理员); } // 3. 加载权限码前端按钮显隐和接口鉴权共用这一份 ListString perms menuMapper.selectPermsByUserId(user.getId()); // 4. 签发令牌payload 里带用户 id 和权限版本号 String token jwtUtil.create(user.getId(), user.getPermVersion()); // 5. 写入 Redis支持续期和服务端踢人 redisTemplate.opsForValue().set(oa:token: user.getId(), token, jwtUtil.getExpireHours(), TimeUnit.HOURS); LoginVO vo new LoginVO(); vo.setToken(token); vo.setUserId(user.getId()); vo.setNickname(user.getNickname()); vo.setPerms(perms); return vo; } }几个参数值得单独说。permVersion是权限版本号管理员改了角色权限之后这个值加一拦截器比对令牌里的版本号和数据库里的版本号不一致就强制重新登录这是解决「权限改了不生效」最省事的办法。oa:token:{userId}这个 Redis key 让服务端具备踢人能力校验时除了验签还要比对缓存里的令牌是否一致管理员点「强制下线」只要删掉这个 key。passwordEncoder用的是 BCryptmatches内部做了盐值比对不要自己写 MD5源码里也没这么干。拦截器那边挂在WebMvcConfig上放行登录、验证码、静态资源三类路径其余请求统一走JwtInterceptor。拦截器里做三件事解析令牌、比对 Redis 会话、把用户上下文塞进ThreadLocal请求结束时在afterCompletion里清理不然线程池复用会串数据。3.2 流程流转四张表撑起一条审批链自研审批流的核心是四张表建表语句能直接反映出设计思路-- 流程定义一个业务类型对应一条定义version 支持灰度 CREATE TABLE oa_flow_def ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flow_key VARCHAR(64) NOT NULL COMMENT 流程标识如 leave_apply, flow_name VARCHAR(128) NOT NULL COMMENT 流程名称, node_config TEXT NOT NULL COMMENT 节点配置 JSON按顺序存审批节点, version INT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, UNIQUE KEY uk_key_version (flow_key, version) ) COMMENT 流程定义表; -- 流程实例一次提交就是一条实例 CREATE TABLE oa_flow_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flow_key VARCHAR(64) NOT NULL, biz_id BIGINT NOT NULL COMMENT 业务单据 id如请假单 id, title VARCHAR(255) NOT NULL, submitter_id BIGINT NOT NULL, current_node INT NOT NULL DEFAULT 0 COMMENT 当前节点下标, status TINYINT NOT NULL DEFAULT 0 COMMENT 0审批中 1通过 2驳回 3撤回, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_submitter (submitter_id), KEY idx_biz (flow_key, biz_id) ) COMMENT 流程实例表; -- 审批任务待办列表就是查这张表 CREATE TABLE oa_flow_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_index INT NOT NULL, node_name VARCHAR(128) NOT NULL, handler_id BIGINT NOT NULL COMMENT 当前处理人, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待办 1已办, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_handler_status (handler_id, status) ) COMMENT 审批任务表; -- 流转记录审计和幂等都靠它 CREATE TABLE oa_flow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_index INT NOT NULL, handler_id BIGINT NOT NULL, action VARCHAR(16) NOT NULL COMMENT approve/reject/cancel, comment VARCHAR(500) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_instance_node_handler (instance_id, node_index, handler_id) ) COMMENT 审批记录表;node_config存的是 JSON 数组每一项包含节点名、处理人类型指定人、角色、部门负责人和处理人 id服务层解析后按current_node下标顺序推进。uk_instance_node_handler这个唯一索引是关键它同时承担幂等职责同一个节点同一个人重复提交审批第二次会直接撞唯一键事务里捕获后返回「请勿重复操作」比在代码里查一遍再插入更可靠。推进节点的核心方法是FlowService.approve()逻辑不复杂但顺序不能乱先查任务并校验处理人身份再写流转记录然后判断当前节点是不是最后一个是就更新实例状态为通过并回调业务侧的完成方法不是就把current_node加一并为下一节点生成新任务。整个过程包在一个事务里oa_flow_record的插入放在最前面一旦重复提交会在这一步抛异常回滚后面的状态推进不会发生。驳回的处理稍有不同实例状态置为驳回当前待办任务全部关闭单据回到提交人手上提交人修改后重新提交会生成一条新的实例而不是在原实例上回退。这种「驳回即重开」的做法会多几条实例数据但省掉了回退路径上的状态清理对中小系统来说更稳。3.3 待办与消息计数缓存和状态一致性待办列表直接查oa_flow_task里handler_id 当前用户 and status 0的记录分页和条件筛选都在 Service 层拼。首页那个「待办数量」小红点用的是 Redis 里的计数器key 形如oa:todo:count:{userId}任务生成时incr审批完成时decr。这里有个常见的一致性问题如果计数和数据库写入不在同一个事务边界内Redis 写失败就会导致数字和列表对不上。这套代码的处理方式是在任务生成后发送一个应用内事件由监听器异步更新计数同时提供一个「刷新计数」的兜底接口前端在进入首页时如果发现数字异常可以手动触发。我一般会在项目里再加一层定时对账每天凌晨按用户维度重新统计一次把偏差抹平毕竟缓存这类东西你永远不能假设它和数据库完全同步。消息通知部分没有接第三方走的是站内信表加轮询。每次任务生成往oa_message插一条记录前端每 30 秒拉一次未读数。这种做法在几十人的团队完全够用量大了再换 WebSocket 或者消息队列代码里已经把发送逻辑抽成MessageSender接口替换实现不影响业务。3.4 附件归档上传白名单与预览链路附件模块的入口是FileController.upload()接收MultipartFile和一个业务类型参数落盘路径按「业务类型/年月/雪花 id」组织避免单目录文件过多。上传前做三件事校验大小、校验扩展名白名单、校验文件头。扩展名白名单建议只放开pdf/doc/docx/xls/xlsx/png/jpg这几类文件头校验是为了防止改了后缀的可执行文件混进来虽然内部系统风险低但这一步不费事。预览走的是后端流式输出Content-Disposition用inline让浏览器直接打开 PDF 和图片Office 文档则返回下载。数据库里oa_file表记录原始文件名、存储路径、大小、上传人和业务关联 id业务单据删除时附件不物理删除只标记逻辑删除这是留后悔药的做法误删了还能找回。归档动作挂在流程实例变为「通过」的回调里把附件从临时目录挪到归档目录并更新路径。这里要注意路径更新和文件移动的顺序我一般先移动文件再更新数据库移动失败就直接抛异常回滚反过来会出现数据库指向一个不存在的文件。4. 跑起来从建库到第一个账号登录代码读得差不多了接下来是把它跑起来。这一章按我实际操作的顺序走建库、起后端、起前端、登录验证每一步都给命令和验证方式。4.1 数据库初始化与种子数据先建库再导脚本脚本在oa-admin/src/main/resources/sql下一般分schema.sql和data.sql两个。执行方式有两种命令行或者客户端工具都行# 登录 MySQL 并建库字符集必须 utf8mb4否则审批意见里的特殊字符会乱码 mysql -uroot -p -e CREATE DATABASE oa_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 依次导入表结构和种子数据 mysql -uroot -p oa_system oa-admin/src/main/resources/sql/schema.sql mysql -uroot -p oa_system oa-admin/src/main/resources/sql/data.sql # 验证应该能看到用户、菜单、流程定义等表都有数据 mysql -uroot -p oa_system -e SELECT COUNT(*) FROM sys_user; SELECT flow_key, flow_name FROM oa_flow_def;schema.sql里包含全部建表语句和索引data.sql里是初始管理员账号、基础菜单、字典数据和两条示例流程定义。初始密码一般是加密后的固定值脚本注释里会写明文第一次登录后立刻改掉。如果导入报外键约束错误多半是表的创建顺序问题按脚本里的顺序执行就行别自己拆开单条跑。4.2 后端启动与接口自测后端用 Maven 起第一次会拉依赖耐心等# 在项目根目录执行-DskipTests 跳过测试第一次跑建议先跳 mvn clean package -DskipTests # 开发模式直接跑启动类所在的模块 mvn -pl oa-admin spring-boot:run -Dspring-boot.run.profilesdev # 或者用打好的 jar 启动生产环境这么干 java -jar oa-admin/target/oa-admin.jar --spring.profiles.activedev启动日志里重点看三行Tomcat 监听的端口、数据源初始化是否成功、Redis 连接是否正常。看到Started OaApplication基本就成了。然后拿登录接口自测一下确认后端是通的# 请求登录接口注意 context-path 是 /api curl -X POST http://127.0.0.1:8080/api/auth/login \\ -H Content-Type: application/json \\ -d {username:admin,password:脚本注释里的初始密码} # 返回体里应该有 token 和 perms 两个字段token 拿去请求带鉴权的接口 curl http://127.0.0.1:8080/api/system/user/page?pageNum1pageSize10 \\ -H Authorization: Bearer 上一步拿到的token如果登录返回 401 而账号密码确认没错先去日志里找异常栈大概率是 Redis 没连上导致会话写入失败或者jwt.secret在某处被覆盖成了空值。4.3 前端启动与联调代理前端在oa-web目录装依赖起服务cd oa-web npm install # 依赖装完后确认 node_modules 里没有报错 npm run dev # 默认起在 5173 端口开发服务器的代理配置在vite.config.js里必须和后端的context-path对上否则接口全 404// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { // 前端请求 /api/xxx 会被转发到后端路径前缀保持一致 /api: { target: http://127.0.0.1:8080, changeOrigin: true // 注意后端 context-path 已经是 /api这里不要再 rewrite 掉前缀 } } } })changeOrigin: true是为了让后端拿到的 Host 头是目标地址某些校验来源的接口会用到。这里最常见的翻车点是画蛇添足加了一句rewrite: path path.replace(/^\\/api/, )结果后端收到的是/auth/login而不是/api/auth/login直接 404。记住一个原则后端 context-path 里有的前缀代理层原样透传。浏览器打开http://127.0.0.1:5173用种子数据里的管理员账号登录能进首页、侧边栏菜单正常渲染、待办列表有示例数据这套 OA 系统就算跑通了。5. 常见问题排查这套 OA 项目最容易翻车的五个地方跑通只是开始改起来才是真正的战场。下面这五条是我在拆这套代码和帮人排查时反复遇到的每条按现象、原因、解决三步走遇到同类问题可以直接对号入座。5.1 登录与鉴权类问题现象一登录接口返回成功页面闪一下又跳回登录页。打开浏览器网络面板能看到首页请求带着令牌但接口返回 401。原因通常有两个一是前端存令牌用的 key 和请求拦截器取令牌用的 key 不一致比如登录页存的是token请求拦截器读的是Access-Token二是后端拦截器没有放行OPTIONS预检请求跨域场景下预检被拦真正的请求根本没发出去。解决办法是统一在src/utils/request.js里定义常量供两处引用同时在拦截器的放行列表里加上OPTIONS方法判断预检请求直接放行不校验令牌。现象二管理员改了角色权限用户刷新页面后按钮还是没变。原因是权限码在登录时一次性下发到前端并存在了本地状态里后端改了角色菜单关系前端手里的旧数据不会自动更新。这套代码里预留了permVersion机制解决方式是管理员保存角色权限时把该角色下所有用户的perm_version加一用户下次请求时拦截器发现版本不一致返回特定的状态码前端捕获后清空本地令牌跳登录页重新走一遍。如果没有这个机制最土的办法是让用户退出重登但体验很差。5.2 流程与数据类问题现象三单据提交成功但审批人的待办列表里什么都没有。按顺序查三处。先看oa_flow_def里对应flow_key的记录status是不是 1停用的流程定义提交时会走「无可用流程」的分支但有些实现里这个分支只打日志不抛异常表现就是静默成功。再看node_config解析出来的第一个节点处理人列表是不是空的指定人离职或者角色下没有成员都会导致生成不出任务。最后看oa_flow_task表里有没有数据有数据但列表查不到那就是待办查询的条件写错了比如漏了status 0或者按部门过滤时把处理人过滤掉了。现象四审批人连点两次通过产生了重复的流转记录和重复的后续任务。这是典型的并发提交问题。数据库层面oa_flow_record有唯一索引兜底但前提是插入语句在事务最前面执行如果代码先更新任务状态再插记录两次请求都能更新成功唯一索引就白设了。解决方式是把「写流转记录」作为第一道闸门插入冲突直接抛异常回滚同时前端按钮点击后立刻置灰双保险。5.3 环境与文件类问题现象五上传附件报 413或者上传成功但文件名变成乱码。413 是请求体超限要改三处Spring 的max-file-size和max-request-size、Nginx 的client_max_body_size、以及网关层如果有的话也有自己的限制少改一处都不行。文件名乱码多半是编码问题检查 MySQL 库和表的字符集是不是utf8mb4检查 Tomcat 的URIEncoding还要看前端FormData里文件名有没有被二次编码。我一般的做法是后端接收时统一用new String(file.getOriginalFilename().getBytes(ISO-8859-1), UTF-8)做一次兜底转换虽然不优雅但能挡住大部分历史遗留环境的问题。另外提一个时间字段的坑审批记录显示的创建时间比实际早或晚 8 小时。这条链路上一共三个时区配置点JDBC 连接串的serverTimezone、Jackson 序列化的time-zone、以及容器本身的TZ环境变量。三个都设成Asia/Shanghai就不会出问题只改其中一两个反而更乱。这种问题查起来像玄学但只要记住「一处不对处处对不上」按链路逐个核对就行。6. 二次开发把通用 OA 改成你自己的业务系统这套代码真正值钱的地方不是功能有多全而是它把「新增一个业务模块」的路径铺得很短。我拿请假单改造成设备报修单试过一次从建表到流程跑通大概半天。做法是四步。第一步建业务表字段按报修场景定义保留id、create_by、create_time、deleted这几个公共字段MyBatis-Plus 的自动填充能直接用。第二步照抄请假单的 Controller、Service、Mapper 三层把实体和 DTO 换掉分页查询和详情接口几乎不用改。第三步在oa_flow_def里插一条flow_key repair_apply的定义node_config按报修审批链配好节点和处理人。第四步在菜单表里加一条记录配上权限码前端路由会自动挂出来页面组件复制一份请假单的列表页改字段即可。流程节点的扩展点在FlowNodeHandler这个接口上不同处理人类型对应不同实现新增一种「按设备所属部门找负责人」的规则只要实现接口并注册成 BeanComponent public class DeptLeaderHandler implements FlowNodeHandler { Resource private DeptMapper deptMapper; // 返回该节点实际需要生成待办的用户 id 列表 Override public ListLong resolveHandlers(FlowNode node, FlowInstance instance) { Long deptId deptMapper.selectDeptIdByBizId(instance.getBizId()); if (deptId null) { throw new BizException(单据未关联部门无法确定审批人); } Long leaderId deptMapper.selectLeaderId(deptId); // 负责人为空时回退到管理员避免流程卡死 return leaderId null ? Collections.singletonList(1L) : Collections.singletonList(leaderId); } // 与 node_config 里的 handlerType 对应 Override public String type() { return deptLeader; } }注册之后在node_config的 JSON 里把对应节点的handlerType写成deptLeader就能生效不用改流程引擎的核心代码。这种「策略接口 类型字符串」的写法是我比较推荐的扩展方式比在 Service 里堆if-else判断处理人类型要好维护得多新人接手也能一眼看出扩展点在哪。验证环节别偷懒。接口层用 curl 或者接口工具把新增、提交、审批、驳回、撤回五条路径各跑一遍数据层直接查oa_flow_instance和oa_flow_task确认状态和节点下标对得上前端重点看列表页的权限码有没有配对按钮该显示的显示、该隐藏的隐藏。改完流程定义记得把旧版本status置 0不然同一个flow_key存在多条启用记录提交时会随机命中一条这种问题排查起来非常费劲。我现在的习惯是每接一套这类 OA 系统项目都先把sql目录下的脚本、application.yml里的三个外部依赖、还有拦截器放行列表强制过一遍再动手写第一行业务代码。这三处不出问题后面基本不会卡在环境上。希望帮到你。本文还有配套的精品资源点击获取