做内部管理系统,最怕的不是技术难,而是方案选得花里胡哨,最后交付的时候连维护的人都找不到。去年我接了一个办公自动化系统的活儿,需求特别典型:公司里请假、报销、用章审批还在靠纸质流程和邮件往来,行政和财务每天光催流程就要花掉大半天。我最终定下的技术组合是 Java + Spring Boot + FreeMarker + MySQL + Maven + MyBatis + JPA。这套方案兼顾了开发效率、交付速度和后续维护成本,跑了大半年一直很稳。这篇就把整个项目的设计思路、核心实现和踩坑过程完整拆一遍,给正在做同类OA系统的朋友一个参考。
1. 项目拆解:这套OA系统到底要做什么
1.1 OA系统的核心业务范围
很多人一听到"OA办公自动化",第一反应是做一个包含所有办公功能的超级平台,结果需求收不住,开发周期无限拉长。我接手之后做的第一件事是收敛需求,跟各个业务部门逐一确认,最后把范围控制到四个核心模块:
- 员工管理:账号开户、部门调整、职位信息维护,属于最基础的增删改查,承担系统数据底座的作用。
- 审批中心:请假、报销、用章三个审批场景,支持多级审批流,是整套系统里状态流转最复杂的部分。
- 通知公告:后台发布公告,可以全员可见,可以指定部门可见,附带发布记录。
- 会议管理:会议室预约、参会人选择、会议通知发送、会议记录留存。
这四个模块覆盖了CRUD、审批流、消息通知、文件上传这几类最常见的企业应用场景。而且它们之间的耦合度控制得比较好——员工和部门是公共基础数据,审批中心依赖这套数据,公告和会议相对独立。这样的边界划分,让每个模块都可以单独迭代而不至于互相拖累。
1.2 角色权限模型设计
OA系统的权限设计我采用的是RBAC简化模型,没有去做复杂的用户组、数据权限、字段权限那套。后台系统的权限模型越复杂,开发阶段的规则分支就越多,测试成本直线上升。真正实用的角色其实只需要三种:
- 超级管理员:拥有全部权限,负责系统初始化、部门维护、角色分配。
- 部门管理员:管理本部门员工数据,负责本部门审批流的节点审批。
- 普通员工:提交各类型申请、查看公告、预约会议室、查看自己的审批进度。
权限模型落地用两张关联表:sys_user_role保存用户与角色的关系,sys_role_permission保存角色拥有的权限码。权限码是字符串形式的,比如leave:audit表示审批权限、notice:publish表示公告发布权限。登录成功后一次性查出用户的所有权限码,放进Session或内存缓存里。
这里有一个重要的设计原则:不要在业务代码里到处写"if (role == 管理员)"这种逻辑。业务层只关心"当前操作是否通过权限校验",具体校验由统一的拦截器完成。这样后续新增模块时,只需要在注解里声明权限码,业务代码完全不用动。
2. 技术选型的取舍:为什么是Spring Boot + FreeMarker + MyBatis/JPA
2.1 后端框架选择的务实理由
用Spring Boot做OA系统,基本是现阶段的默认答案。自动配置省掉了Spring MVC、事务管理、JSON序列化这些繁琐的XML配置,内嵌Tomcat让部署变成直接丢一个jar包,这对内部系统来说是巨大的效率提升。更重要的是Spring Boot的生态足够成熟,遇到问题基本都能搜到现成的解决方案。
版本上选的是Spring Boot 2.x,搭配Java 8。这个组合经过了大量生产环境验证,稳定性有保障。Maven作为构建工具,依赖管理上用到了Spring Boot的BOM(Bill of Materials)机制,子模块不需要手动写版本号,只需要继承父工程,从根本上避免依赖版本冲突的问题。
我要提醒的一点是:不要一开始就想着上微服务、注册中心、分布式事务那套。内部OA系统并发量通常低得可怜,单体应用就是最合适的架构。微服务的复杂度是实实在在的,而你当前根本用不上那些能力。等哪天业务量真的上来,再考虑拆分也不迟。
2.2 模板引擎FreeMarker的取舍
在这个项目里,没有选择前后端分离,而是用FreeMarker做服务端渲染。原因非常现实:内部系统的用户量小,功能页面多,每个页面的交互复杂程度也有限。用服务端渲染,一个Controller配一个模板就是一个完整页面,没有跨域问题,没有前端构建流程,浏览器刷新就能看到最新效果,调试效率极高。
跟JSP相比,FreeMarker最大的好处在于"职责分离"。JSP页面里很容易混进大段的Java代码,而FreeMarker的${}和<#if>标签逼着你把数据在Controller里提前处理干净,模板只做展示。这个约束在多人协作时尤其有效——前端同学改样式的时候,不用担心不小心破坏掉一段Java逻辑。
项目里的模板工程结构是这样组织的:
src/main/resources/templates/ ├── common/ │ ├── navbar.ftl 公共导航 │ ├── sidebar.ftl 侧边栏 │ └── pagination.ftl 分页组件宏 ├── employee/ │ ├── list.ftl │ └── form.ftl ├── approval/ │ ├── leave_list.ftl │ ├── leave_form.ftl │ └── audit.ftl ├── notice/ │ ├── list.ftl │ └── publish.ftl └── meeting/ ├── list.ftl └── book.ftl通用部分通过FreeMarker宏(macro)抽取,比如分页条、状态徽章、表单按钮组都做成宏文件,页面里一行引用就可以。这样一套公共组件,后期改样式只需要动一个宏文件,不用翻二十个页面。
2.3 持久层MyBatis和JPA并存的分工
这是我被同行问得最多的问题:MyBatis和JPA一起用,是不是重复造轮子?我的答案是:分工不同,各有价值。
- 员工、部门这类基础数据的单表CRUD,操作就是标准的增删改查,我用JPA的Repository接口。
findByUsername、findByDeptId这类方法根据方法名自动生成SQL,一行Java代码都不用写,开发速度极快。 - 审批历史记录、待办统计、各部门申请数量汇总这类场景,往往涉及多表关联、条件动态拼接、分组汇总。用JPA写这种复杂查询,要么QueryDSL学起来成本高,要么原生SQL跟Entity绑定在一起很别扭。MyBatis的
<where>和<if>动态SQL在这里就是真正的生产力工具。
两者共存还有一个隐含的好处:当你对查询性能不满的时候,可以随时用MyBatis写一条优化后的SQL来替代JPA的自动生成查询,而不需要改动任何业务代码。这种"逃逸门"机制,让架构在性能优化时有足够的弹性。
这里要注意的是事务控制必须统一。不管底层是JPA的Repository还是MyBatis的Mapper,最终操作的是同一个DataSource和同一个PlatformTransactionManager,在Service方法上统一加@Transactional注解即可。
3. 数据库设计:一张表规划背后的业务逻辑
3.1 核心表结构规划
数据库设计我习惯从业务流程出发,先把核心对象抽出来,再补关系表。这套OA系统核心表大概有这些:
| 表名 | 用途 | 关键字段 |
|---|---|---|
sys_user | 用户表 | username,password_hash,real_name,dept_id,status |
sys_dept | 部门表 | dept_name,parent_id(支持树形) |
sys_role | 角色表 | role_code,role_name |
sys_permission | 权限表 | perm_code,perm_name |
sys_user_role | 用户角色关联表 | user_id,role_id |
sys_role_permission | 角色权限关联表 | role_id,permission_id |
oa_leave | 请假申请表 | apply_user_id,leave_type,start_time,end_time,reason,status |
oa_expense | 报销申请表 | apply_user_id,amount,category,status |
oa_approval_record | 审批记录表 | business_type,business_id,approver_id,approve_action,comment |
oa_notice | 公告表 | title,content,publisher_id,target_dept_id |
oa_meeting | 会议表 | title,meeting_time,room_id,creator_id |
这里重点说下审批流程的通用设计方案。我没有为请假、报销各建一套审批表,而是用一张oa_approval_record表统一记录所有业务类型的审批动作。业务表里只存一个status字段表示最新状态,完整的审批链路通过查询oa_approval_record拿。这样做的好处是:后续新增"采购审批""加班审批",只需要加一张业务表,审批记录的查询和统计逻辑完全复用。
还有一个细节是target_dept_id的设计:这张表存公告的目标部门ID,为-1时表示全员可见。相比用一个单独的oa_notice_dept关系表来存可见范围,这种方案在公告数量不大、需求简单的前提下,查询效率更高,代码也更简洁。
3.2 状态字段设计:用整型还是用字符串
审批状态字段我见过很多项目直接用字符串存"审批中""已通过",初看可读性不错,但真要维护起来就是灾难。一旦状态名字改了要UPDATE历史数据,代码里if判断还容易拼写出错。我的方案是整型加枚举类,请假单状态定义:
public enum ApprovalStatusEnum { DRAFT(0, "草稿"), APPROVING(1, "审批中"), APPROVED(2, "审批通过"), REJECTED(3, "审批驳回"), CANCELED(4, "已撤销"); private final Integer value; private final String desc; // 构造方法和getter省略 }数据库存0、1、2、3、4,代码里用ApprovalStatusEnum.APPROVING.getValue()做判断。页面渲染时,通过一个工具方法把状态值映射成中文名称和对应的颜色样式。这样业务里的状态判断都是类型安全的,不会出现"字符串拼写不一致导致Bug"这种低级的错误。
同样逻辑也用在审批动作上,approve_action字段我只存两个值:agree和reject,配合整型的业务状态,整个审批状态机变得非常清晰。
3.3 字段类型和索引设计的经验
这一节补充几个实际踩过坑的字段设计事项:
金额字段一律用DECIMAL(10,2),不要用DOUBLE或FLOAT。浮点数在MySQL里是近似值存储,报销金额一旦涉及对账,小数点后几位的偏差就能让人崩溃。
时间字段用DATETIME而不是TIMESTAMP。TIMESTAMP的范围只到2038年,而且和时区绑定,每次查询都会做时区转换。DATETIME没有这些问题,8字节存储,业务系统足够了。
状态查询字段必须加索引。审批列表最常见查询是WHERE status = 1 AND current_approver_id = ?,不建索引,当数据量到几万条的时候,查询速度会明显变慢。我建了联合索引idx_current_approver_status(current_approver_id, status),把两个频繁查询的过滤条件都覆盖进去,实测在大约十万条审批数据下,查询耗时从800多毫秒降到20毫秒以内。
4. 核心功能模块的实现思路
4.1 用户认证与权限控制
认证这块没有直接引入Spring Security,原因是这个OA系统的权限模型足够简单,不需要那套复杂的过滤器链和配置体系。我用Spring Boot拦截器加Session的方案,代码量少、逻辑清晰、可控性强。
登录流程:
- 登录表单提交
username和password。 - Controller中从
sys_user表查询用户,校验status为正常。 - 用
BCryptPasswordEncoder.matches(rawPassword, encodedPassword)验证密码。 - 校验通过后,查出该用户的权限码列表,放Session。
这里单独说一下密码存储,我用的是BCrypt哈希而不是MD5或SHA。MD5和SHA是快速哈希,攻击者可以用彩虹表快速的穷举破解;而BCrypt是慢哈希,每次计算故意加了大量运算,并且为每个用户生成不同的随机盐值,即便数据库泄露,破解成本也高得多。我特意只引入Spring Security中的BCryptPasswordEncoder类,而没把整个Security框架引进来——这种"按需取用"的方式比拉一个大框架进来要清爽得多。
权限控制走自定义注解加拦截器的方案。先定义一个注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String value(); }然后在需要权限控制的Controller方法上标注:
@RequirePermission("leave:audit") public String audit(@RequestParam Long id) { ... }拦截器AuthInterceptor的preHandle方法里,先检查Session有没有用户,再检查权限码集合里是否包含注解要求的权限。这种方案的直观程度远高于XML配置URL权限:写业务代码的时候,一眼就能看出当前接口需要什么权限。
4.2 审批流程的实现
审批中心是OA系统里最容易写乱的部分,核心在于把状态流转想清楚。我把它拆成提交、审批、撤销三个动作。
提交申请做了三件事:
- 校验当前用户可以提交,状态必须是草稿或驳回。
- 更新业务表状态为审批中。
- 插入一条
oa_approval_record,记录提交动作。 - 按照规则找到第一个审批人(这里是部门管理员),更新业务表的
current_approver_id字段。
审批动作分同意和驳回两类。同意的逻辑是:判断当前审批人是否有上级审批人,如果有就更新current_approver_id为下一级,没有就直接把业务状态置为审批通过。驳回的逻辑就简单了:置为审批驳回,流程终止。
撤销申请只允许在审批中的状态下操作,撤销后状态回到草稿,同时也要写入一条审批记录。
这套逻辑里最关键的一点是,审批是一个跨表的复合操作,必须在一个事务里完成。状态更新、审批记录插入、待办转移,任何一个环节失败都应该整体回滚,不允许出现"业务状态变成审批通过,审批记录却没写进去"这种数据不一致的情况。
4.3 待办事项与前端页面渲染
"我的待办"是审批人日常使用频率最高的入口。我的设计用了冗余字段方案:业务表里直接存一个current_approver_id,表示当前等待谁审批。查询"我的待办"就变成:
SELECT * FROM oa_leave WHERE current_approver_id = #{userId} AND status = 1 ORDER BY apply_time DESC有人批评这种设计不符合第三范式,但OLTP场景里,冗余带来的是查询的简单和索引的高命中率。规范的代价是每次提交审批时多更新一次冗余字段,而这个代价是可以接受的——审批操作本身就不频繁。
FreeMarker渲染待办列表时,我习惯把展示属性在Controller层封装成ViewObject。比如审批状态,数据库里存的是整型,模板里需要的是带有颜色的中文标签。我在Controller里就把它组装好:
vo.setStatusName(ApprovalStatusEnum.of(entity.getStatus()).getDesc()); vo.setStatusClass("badge-" + statusColorMap.get(entity.getStatus()));模板里只做${vo.statusName}和${vo.statusClass}的简单替换。这样做的好处是模板逻辑最薄,以后即使换前端框架,展示逻辑也不至于散落在模板里难以维护。
5. 关键代码实现与细节剖析
5.1 数据源与MyBatis的配置细节
连接池直接用的Spring Boot默认的HikariCP,这个连接池性能很强,内部系统并发不高的情况下,只需要关注几个核心参数:
spring: datasource: url: jdbc:mysql://localhost:3306/oa_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 pool-name: OaHikariPool连接池大小设置为10就够了,内网OA系统的并发一般几十个同时在线,10个连接足以应对。开太多反而容易打满MySQL的最大连接数,影响数据库整体稳定性。
MyBatis的配置有几个细节容易被忽略:
mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case必须在关闭下划线转驼峰时保证数据库字段apply_user_id能映射到Java的applyUserId。这个配置不开启,后台一定会报"could not set property"之类的错,排查起来也很头疼。
log-impl在开发阶段非常建议打开,直接看控制台打印的SQL语句和参数,比任何Debug都直观。生产环境记得关掉,避免日志刷屏。
5.2 FreeMarker集成和页面布局
FreeMarker在Spring Boot里集成很简单,引入依赖后配置文件里加一行:
spring: freemarker: suffix: .ftl template-loader-path: classpath:/templates/真正让FreeMarker发挥价值的是它的宏布局能力。我封装了一个名叫page_layout的宏,公共导航、侧栏、底部都放在里面:
<#macro page_layout title activeNav> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>${title} - OA系统</title> <link rel="stylesheet" href="/css/app.css"> </head> <body> <#include "/common/navbar.ftl"> <div class="main-wrapper"> <#include "/common/sidebar.ftl"> <div class="content"> <#nested> </div> </div> <script src="/js/common.js"></script> </body> </html> </#macro>业务页面使用时只需要这样:
<@page_layout title="请假申请" activeNav="approval"> <form action="/approval/leave/submit" method="post"> <!-- 具体表单字段 --> </form> </@page_layout>这样公共布局只有一份,业务页面只关心自己的表单和表格。还有一个好处,公司要求改Logo和菜单时,我只需要动一个navbar.ftl文件,所有页面自动生效。
5.3 事务控制的几种场景
OA系统里的业务操作大多数是跨表的,事务控制不当就会出现数据不一致。这个项目里我用到了两种事务控制方式。
一种是声明式事务,就是在Service方法上加@Transactional注解。用得最多,但有一个大坑必须注意:@Transactional只对Spring代理对象的方法调用生效。同一个Service类里,A方法调用B方法,如果A上面加了@Transactional而B没有,B的异常不会触发A的事务回滚——因为这个调用发生在这个类内部,没有走代理。
这个坑在审批模块几乎必然出现,因为审批涉及状态更新、记录插入、待办更新三步操作。我的解法是:要么拆分Service,把审批相关的更新操作放到不同的Bean里,通过注入的方式调用以确保经过代理;要么干脆把所有更新逻辑在同一个Service方法内完成,不去依赖方法间的调用来保证事务边界。
另一种事务场景是MyBatis和JPA混用时的回滚一致性。在同一个Service方法里,先操作JPA Repository,再操作MyBatis Mapper,只要它们用的是同一个DataSource和同一个PlatformTransactionManager,Spring事务就能把它们纳入同一个事务边界。这是配置层面的事,确认清楚了就不用担心回滚不一致。
6. 开发过程中真正折磨人的问题
6.1 Spring Boot 2.6路径匹配策略变化
这是一个升级版本引发的连环坑。项目一开始用的Spring Boot 2.3.7,系统开发完准备上线前,出于安全考虑想把版本升到2.6.x。结果升级后发现所有静态资源CSS、JS全部404了,整个系统的页面都变成了"裸奔"状态。
排查后发现,Spring Boot 2.6开始默认使用PathPatternParser作为Spring MVC的路径匹配器,而很多HandlerInterceptor配置和老代码用的是AntPathMatcher的/**模式,两种匹配规则在静态资源匹配上有差异。之前注册的静态资源放行路径全部失效。
解决方案很简单,在配置类里显式换回旧版匹配策略:
spring: mvc: pathmatch: matching-strategy: ant_path_matcher这个参数在Spring Boot启动时就会应用,放进去问题立刻解决。但这件事也让我养成了一个习惯:升级框架版本之前,一定要先看官方发布说明里的Breaking Changes列表。框架升级不是简单的把版本号改一下那么轻松,很多细微的策略变更会潜移默化地影响运行行为。
6.2 分页查询的"隐形翻页"问题
这个Bug很隐蔽,花了我半天才定位。OA系统的请假列表页,用的是MyBatis的通用分页插件PageHelper。当查询条件为空时一切正常,但一旦加上一个查询条件比如"只看我提交的申请",总记录数突然变了,而且翻页数据错乱。
排查后发现,PageHelper的分页原理是基于ThreadLocal保存分页参数,在SQL执行前拦截并拼接LIMIT语句。问题出在startPage()的调用位置和动态SQL的<if>存在冲突。具体是我把PageHelper.startPage(pageNum, pageSize)放在了Mapper方法的调用处之前,但那个Mapper方法里的SQL有动态条件判断,涉及一个查询参数对象的复用问题,最终导致PageHelper在统计total时走了错误的SQL分支。
解决方法是严格要求使用规范:startPage必须紧跟在Mapper方法调用前的最后一行,并且分页参数不能和业务查询参数混在同一个对象返回。现在项目里的分页写法统一为:
PageHelper.startPage(pageNum, pageSize); List<LeaveVO> list = leaveMapper.selectMyApplyList(param); PageInfo<LeaveVO> pageInfo = new PageInfo<>(list);这个顺序绝不能乱,像queryParam.setStatus()这种业务参数设置必须放在startPage之前,否则就可能在统计总数时排除掉条件。
6.3 MySQL时区导致的时间错乱
这个问题是在部署测试环境时发现的。开发机上连接的是本地MySQL,一切正常;测试环境的数据库跑在Docker容器里,同一个查询接口返回的时间跟本地差了整整8个小时。一开始还以为是代码逻辑问题,排查到根因之后发现是时区设置不一致。
开发机的MySQL默认时区是东八区,Docker容器里的MySQL默认是UTC,而JDBC连接串里没有显式配置serverTimezone。Spring Boot取的是JVM所在时区,两边一比对,时间就出现偏移了。
统一解决办法是在JDBC URL里显式指定时区:
jdbc:mysql://localhost:3306/oa_db?serverTimezone=Asia/Shanghai同时把MySQL服务端时区也改成了Asia/Shanghai。这里给大家的建议是:数据库连接串里的时区参数一定要显式配置,不要依赖默认值。环境一多,默认值的不确定性就会变成实打实的Bug。
另外这里还要多说一句,涉及时间字段的比较和存储,统一用DATETIME类型,并且所有环境都用手工配置的Asia/Shanghai时区。这套方案跑了大半年,再没出现过时间错乱的情况。
说一下这套系统跑到现在的一些体会。FreeMarker服务端渲染虽然被很多人认为"不够现代",但在内部管理系统这种场景下,它带来的开发和维护效率提升非常实在。MyBatis加JPA并存的组合,让简单操作和复杂查询各得其所,不用为了追求架构上的"统一性"而牺牲开发效率。技术选型这件事,真的不是越新越好、越复杂越好,永远是匹配业务场景才是最好的。希望这篇实战记录能帮正在做同类项目的朋友少走点弯路。