简介:基于 Spring Boot 开发的企业 OA 管理系统,是一份适合毕业设计、课程设计及期末大作业的完整 Java 项目。面向计算机、软件、人工智能、自动化等专业的学生、教师或从业者,既能帮助初学者理解企业级后端开发流程,也能让有基础者在此基础上扩展业务模块。项目曾作为个人毕业设计使用,答辩评审得分较高,源码经过调试,可运行验证。压缩包内含 159 个文件,大小约 1.44MB,主要文件类型包括 48 个 Java 后端源码、27 个 JavaScript、20 个 HTML、10 个 CSS,以及 SQL 数据库脚本、配置文件、说明文档等,基本覆盖后端接口、前端页面、样式资源和数据初始化。通过项目目录和文档可以学习 Spring Boot 分层结构、业务逻辑组织方式,以及 OA 系统中常见的审批、考勤、权限管理等模块设计思路。目前已有 156 人学习,是一份值得反复拆解和二次开发的实践资源。
1. 这套OA系统的“含金量”不在代码,在选型
拿到一套“Java毕业设计-基于springboot开发的企业oa管理系统源码+数据库”,最容易踩的坑就是直接mvn spring-boot:run,启动失败后才发现自己连数据库都没建。这个标题实际上交付了两样东西:一套Spring Boot工程的完整骨架,和一套已经初始化好的MySQL库。前者决定代码能不能编过,后者决定登录后有没有数据可看。对做毕业设计的人来说,真正的价值不是重复造轮子,而是把“组织架构 + 审批流 + 权限控制”这条主线拆开看明白;对已经在工作的人来说,这套东西最大的意义是能省掉搭基础管理后台的时间,改一改就能接手内部项目。问题的关键从来不是Spring Boot本身,而是数据模型怎么设计、鉴权链路怎么闭合、初始化数据怎么跟代码对齐。以下几章按我自己的实现习惯,把这条链路完整走一遍。
2. 先把要有什么模块想清楚:OA数据模型里的关键表
能叫“企业OA管理系统”的表集合可以很大,考勤、会议、日程、周报、固定资产都能塞进来。但跟评审老师和跟甲方讲的重点完全不一样:毕业设计看的是数据模型是否清晰、权限闭环是否完整、业务状态是否能自洽。我一般会把OA拆成三组表:组织架构(部门、用户、角色)、业务单据(请假、报销、审批记录)、行政信息(公告、日程)。三组之间尽量不混用字段,写Mapper的时候各组独立。
2.1 OA系统的模块边界:必做项与加分项
先给一个务实的分类。必做项是“没有它整个系统立不住”的部分:组织管理(部门/用户/角色/菜单)、登录认证、一项完整的审批业务。请假是审批流里最典型的案例,因为它的状态少、字段少、审批链路短,但状态机模型和报销/用章完全一致,做透一个就能举一反三。
加分项是公告、日程、周报这类单表CRUD,每加一个都意味着多一套Controller、Service、Mapper和前端页面,工作量递增但技术亮点不递增。不建议碰会议预定、固定资产这类牵扯到资源冲突检测和生命周期管理的功能,难度曲线陡,却很难在答辩时讲出彩。模块边界定的原则是:让代码量集中在能体现设计能力的部分,而不是堆砌接口数量。
2.2 组织架构的三张核心表:部门、用户、角色的DDL设计
先看最核心的三张表建表语句,这套结构基本是所有OA和后台管理系统的通用底座:
CREATE TABLE sys_dept ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '部门ID', parent_id BIGINT DEFAULT 0 COMMENT '上级部门ID,0为根', name VARCHAR(50) NOT NULL COMMENT '部门名称', sort INT DEFAULT 0 COMMENT '同级排序号', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_parent (parent_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', dept_id BIGINT COMMENT '所属部门ID,关联sys_dept.id', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', password VARCHAR(100) NOT NULL COMMENT 'BCrypt密文,固定60位', real_name VARCHAR(50) COMMENT '真实姓名', email VARCHAR(100) COMMENT '邮箱', phone VARCHAR(20) COMMENT '手机号', status TINYINT DEFAULT 1 COMMENT '状态:1启用,0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', KEY idx_dept (dept_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '角色ID', role_code VARCHAR(30) NOT NULL UNIQUE COMMENT '角色编码,如ADMIN/LEADER', role_name VARCHAR(50) NOT NULL COMMENT '角色名称', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';逻辑说明:这三张表全部采用逻辑外键而不是物理外键,dept_id用普通索引,原因是物理外键在插入数据时要多一次约束校验,数据量上来之后会影响写入性能,而且一旦业务分层为微服务,物理外键会变成拆分障碍。password字段设VARCHAR(100)而不是VARCHAR(255),因为BCrypt算法固定输出60位密文,留出余量即可,设太大反而浪费InnoDB的索引空间。
用户与角色是多对多关系,需要一张关联表:
CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL COMMENT '用户ID', role_id BIGINT NOT NULL COMMENT '角色ID', PRIMARY KEY (user_id, role_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';角色与菜单的关联表结构完全一致,只是把user_id换成role_id、role_id换成menu_id。菜单表sys_menu需要重点设计的是perms字段:它是权限标识,比如system:user:add、leave:approve。前端按钮的v-if判断和后端接口的@PreAuthorize校验共用同一套perms编码,两边用一套规则,不会出现前端隐藏了、后端却还能直接调接口的情况。
2.3 审批业务用两张表表达:业务单据表加审批记录表
请假、报销这类审批业务,不需要真的引入工作流引擎。用一张业务表记录单据本身,再用一张流水表记录每一次审批动作,就能把状态机完整表达出来:
CREATE TABLE biz_leave ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '请假单ID', applicant_id BIGINT NOT NULL COMMENT '申请人ID', leave_type TINYINT COMMENT '请假类型:1事假,2病假,3年假', start_time DATETIME COMMENT '开始时间', end_time DATETIME COMMENT '结束时间', reason VARCHAR(200) COMMENT '请假事由', status TINYINT DEFAULT 0 COMMENT '状态:0草稿,1审批中,2已通过,3已驳回', current_approver_id BIGINT COMMENT '当前待审批人ID,空闲为NULL', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_status (status), KEY idx_applicant (applicant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='请假业务表'; CREATE TABLE biz_approval_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID', business_type VARCHAR(20) NOT NULL COMMENT '业务类型:leave/expense等', business_id BIGINT NOT NULL COMMENT '业务单据ID', approver_id BIGINT COMMENT '审批人ID', action TINYINT COMMENT '审批动作:1通过,2驳回', remark VARCHAR(200) COMMENT '审批意见', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '审批时间', KEY idx_business (business_type, business_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批记录流水表';逻辑说明:status用TINYINT存数字而不是直接存中文,是为了后期调整文案时不用更新历史数据,前端或者枚举类统一做映射即可。biz_approval_record里的business_type + business_id组合索引,是为了让“查询某个单据的全部审批轨迹”这个高频操作走索引;同一条请假单的通过记录、驳回记录按时间升序排列,就是完整的审批链路图。
2.4 为什么不直接引入Flowable工作流引擎
常见疑问是“OA系统不都应该用Activiti或Flowable吗”。真实企业里几千甚至上万单的审批量,引入引擎有它的价值;但这套OA的定位是毕业设计和轻量内部管理系统,引入Flowable意味着要理解它的几十张引擎表、流程定义表、运行实例表,代码调试成本远远高于业务代码本身。用上面这种“状态字段 + 审批流水表”的模型,审批逻辑完全由自己控制,答辩时能对着表结构把状态流转讲清楚,这就是最大的优势。
3. Spring Boot里的权限链路:从登录到接口鉴权的落地
组织架构表建好之后,接下来就是把Java后台的认证与授权串起来。这套OA的权限设计核心是Spring Security + JWT,登录成功后前端拿token,后续请求在请求头携带token,后端通过拦截器解析并校验。
3.1 技术选型:为什么是Spring Security + JWT而不是Shiro
Spring Security在Spring Boot 2.x之后配置量大减,常见做法是继承WebSecurityConfigurerAdapter或者直接注入SecurityFilterChain,配合@PreAuthorize注解就能实现方法级权限控制。而Shiro虽然轻量,但Spring生态内的注解支持和Security相比不够顺滑,RBAC权限点写在注解上需要额外引入AOP封装。
JWT负责的是“无状态认证”,好处是后端不需要存session,token本身携带用户标识和过期时间,非常适合OA这类前后端分离的管理系统。缺点就是token在过期前无法强制失效,所以登录接口必须配合一个“账号禁用校验”,每次请求都检查sys_user.status字段。
3.2 JWT工具类:生成与解析token的完整代码
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; /** 过期时间,单位秒,默认2小时 */ @Value("${jwt.expire:7200}") private Long expire; public String generateToken(Long userId, String username) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expire * 1000); return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }逻辑说明:setSubject放用户名,claim里塞userId,这样后续拦截器取到Claims就能拿到当前操作人的身份。signWith(SignatureAlgorithm.HS256, secret)中的secret不要写死在代码里,放到application.yml配置,并且长度至少32位。jjwt库的parseClaimsJws如果token被篡改或过期,会抛出SignatureException和ExpiredJwtException,在拦截器里统一捕获并转成401状态码即可。
3.3 登录接口:BCrypt密码校验与token发放
@Service public class AuthService { private final UserMapper userMapper; private final JwtUtil jwtUtil; public String login(LoginRequest request) { SysUser user = userMapper.selectByUsername(request.getUsername()); if (user == null || !BCrypt.checkpw(request.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } if (user.getStatus() == 0) { throw new BusinessException("账号已被禁用,请联系管理员"); } return jwtUtil.generateToken(user.getId(), user.getUsername()); } }逻辑说明:BCrypt.checkpw每次校验时随机加盐,所以同一个密码每次生成的密文都不一样,但checkpw都能正确比对。密码字段在数据库里存的必须是BCrypt密文,不能在库里存明文或MD5。校验顺序上先判断用户是否存在,再看密码,最后看状态,三个条件顺序固定,既避免暴露“用户不存在”这种信息,也保证禁用账号不会拿到token。
3.4 注册拦截器:让每个接口自动完成token解析
@Component public class JwtInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行非Controller方法,比如静态资源映射 if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new UnauthorizedException("未登录或token缺失"); } try { Claims claims = jwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", claims.get("userId")); request.setAttribute("username", claims.getSubject()); return true; } catch (ExpiredJwtException e) { throw new UnauthorizedException("登录已过期,请重新登录"); } catch (Exception e) { throw new UnauthorizedException("无效的token"); } } }以后端接口路径统一前缀/api/**为例,注册方式如下:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { private final JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login"); } }逻辑说明:拦截器把解析出的userId和username放进了request的attribute里,Controller方法中直接用@RequestAttribute Long userId就能取到当前操作人。excludePathPatterns统一维护放行清单,登录接口、验证码接口、Swagger文档这些不需要鉴权的路径都加在这里。能注册拦截器就不要用Filter,因为拦截器能拿到HandlerMethod,可以做方法级权限判断,Filter只能在Servlet层面处理,拿不到具体的处理方法。
3.5 方法级权限:@PreAuthorize控制按钮级操作
在登录链路闭合之后,细粒度的权限控制交给Spring Security的方法注解。Controller的方法上加上权限标识,例如:
@PreAuthorize("hasAuthority('leave:approve')") @PostMapping("/api/leave/{id}/approve") public Result approve(@PathVariable Long id, @RequestBody ApproveRequest req) { leaveService.approve(id, req); return Result.success(); }这一步配合角色的perms集合,实现的效果就是:用户A有leave:approve权限标识才能调用审批接口,没有就返回403。前端页面里同一个按钮的v-if="hasPerm('leave:approve')"和后端hasAuthority对应上,权限控制的闭环就算完整了。
4. 把“源码+数据库”跑起来:一次完整的启动验证
这套交付物里常见的坑不是代码编译失败,而是数据库没有初始化干净。我给一份从环境检查到登录成功的完整步骤,照着走一遍就知道问题出在哪。
4.1 数据库的交付形态:SQL脚本与Dump文件的区别
源码包里数据库文件一般有两种形态。一种是.sql脚本,里面包含CREATE TABLE和INSERT INTO语句,导入时会自动建表插数据;另一种是mysqldump导出的dump文件,头部可能带有CREATE DATABASE语句和USE指令。两者的处理方式完全不同。
判断方法很简单:用文本编辑器打开文件,看到第一行是CREATE DATABASE就用source命令整体导入;如果是直接CREATE TABLE,需要先手动建库再导入。同时关注文件中是否包含INSERT INTO sys_user的语句,这决定登录时能不能找到初始管理员账号。常见的初始账号密码一般是admin/admin123或admin/123456,具体以脚本里INSERT的密文为准,如果是明文就说明脚本没做加密处理,需要检查初始化脚本的正确性。
4.2 从源码到登录页的最小启动步骤
以下操作命令在Windows和Linux通用,项目根目录执行:
# 以root身份进入MySQL,创建数据库 mysql -uroot -p CREATE DATABASE oa_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; exit; # 导入SQL文件,注意文件路径 mysql -uroot -p oa_system < /path/to/oa_system.sql # Maven打包,跳过单元测试 mvn clean package -DskipTests # 启动Spring Boot应用 java -jar target/oa-system-0.0.1-SNAPSHOT.jar逻辑说明:CREATE DATABASE必须显式指定utf8mb4字符集,否则建表时会继承MySQL实例的默认字符集,通常会是latin1或utf8,存中文就可能出现乱码。-DskipTests跳过测试是为了避免测试类里的数据源配置和本地不一致导致打包失败。启动成功后访问http://localhost:8080,能看到登录页说明前端资源打包正常,用初始账号登录成功后跳到首页,整套链路就验证通过了。
4.3 基于Spring Boot的配置文件修改点
application.yml里需要改的一般只有数据库连接和JWT配置:
spring: datasource: url: jdbc:mysql://localhost:3306/oa_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 换成你的数据库密码 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true jwt: secret: 换成自己的32位以上随机字符串 expire: 7200逻辑说明:数据库连接串必须保留serverTimezone=Asia/Shanghai,MySQL 8.x的驱动默认使用UTC时区,不指定的话会报“The server time zone value”错误。map-underscore-to-camel-case开启后,数据库里的create_time字段能自动映射到Java实体里的createTime,这个配置漏了就会导致查询结果里时间字段全是null。
4.4 启动失败的常见症状与排查方向
用一个表格把最常见的问题和排查方向列出来,比逐个搜报错信息快得多:
| 报错现象 | 可能原因 | 处理方式 |
|---|---|---|
| Communications link failure | 连接串缺serverTimezone或数据库端口错误 | 检查url参数和MySQL端口号 |
| Access denied for user | 用户名密码错误或权限不足 | 核对application.yml里的账号权限 |
| Port 8080 was already in use | 端口被其他进程占用 | server.port改成8081,或杀掉占用进程 |
| 登录页中文乱码 | 数据库字符集不是utf8mb4 | 重建库并指定utf8mb4 |
| 启动时找不到数据源 | 没有导入SQL或库名不匹配 | 检查是否存在oa_system库和表结构 |
一个比较容易忽略的问题:Spring Boot版本太高导致启动失败。如果源码是基于Spring Boot 2.x写的,本地电脑装了JDK 17后又手动升级成3.x,可能出现javax.servlet包名找不到的异常,因为这系列源码里引用的部分依赖还是旧版本的javax命名空间,需要检查pom.xml声明的版本号和JDK匹配情况。
5. 从能跑到能答辩:4个值得动手的打磨细节
5.1 给审批状态机补一个“撤销”操作
现在的biz_leave状态是从0到1再到2或3,中间缺少“申请人主动撤回”的路径。补一个status=4 已撤销,并在Service层加对应方法:只有当前状态是“审批中”且当前操作人是申请人本人时才能执行,同时往biz_approval_record里插一条“系统撤销”的记录。这个细节能让审批流的状态图展示得更完整,答辩时可以直接画状态机图。
5.2 用MyBatis的MetaObjectHandler统一填充时间字段
每张表都有create_time和update_time,如果每个Service里都手动set一遍,代码冗余且容易漏。常见做法是注入一个MetaObjectHandler实现类:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }逻辑说明:在实体类的createTime和updateTime字段上加@TableField(fill = FieldFill.INSERT)和@TableField(fill = FieldFill.INSERT_UPDATE)注解,插入和更新时自动填充,新增表结构时不需要再写多余的赋值代码。
5.3 登录接口的体验兜底:验证码与登录日志
加上一张sys_login_log表记录每次登录的账号、IP、时间、成功失败标识,这是一个性价比极高的功能:代码量不大,但能让“系统管理”模块立刻多出一个可展示的页面。验证码端可以用Hutool的CaptchaUtil生成,登录前先校验验证码,校验通过再走BCrypt密码校验,顺序千万别颠倒,否则验证码错误的请求也会消耗数据库查询。
5.4 一张报表慢查询的索引优化示范
如果用户列表页要按部门加时间范围筛选,SQL类似SELECT * FROM sys_user WHERE dept_id = ? AND create_time BETWEEN ? AND ?,那单独建立在dept_id上的索引就不够用,最佳实践是建立联合索引(dept_id, create_time)。在线执行EXPLAIN SELECT ...看key_len和rows就能验证是否命中索引。把这套验证思路写进毕业论文的测试章节,比单纯贴几张页面截图更有说服力。
本文还有配套的精品资源,点击获取