ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OA系统开发实战:Spring Boot + FreeMarker + MyBatis/JPA构建内部管理系统

OA系统开发实战:Spring Boot + FreeMarker + MyBatis/JPA构建内部管理系统 做内部管理系统最怕的不是技术难而是方案选得花里胡哨最后交付的时候连维护的人都找不到。去年我接了一个办公自动化系统的活儿需求特别典型公司里请假、报销、用章审批还在靠纸质流程和邮件往来行政和财务每天光催流程就要花掉大半天。我最终定下的技术组合是 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/JPA2.1 后端框架选择的务实理由用Spring Boot做OA系统基本是现阶段的默认答案。自动配置省掉了Spring MVC、事务管理、JSON序列化这些繁琐的XML配置内嵌Tomcat让部署变成直接丢一个jar包这对内部系统来说是巨大的效率提升。更重要的是Spring Boot的生态足够成熟遇到问题基本都能搜到现成的解决方案。版本上选的是Spring Boot 2.x搭配Java 8。这个组合经过了大量生产环境验证稳定性有保障。Maven作为构建工具依赖管理上用到了Spring Boot的BOMBill 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,statussys_dept部门表dept_name,parent_id支持树形sys_role角色表role_code,role_namesys_permission权限表perm_code,perm_namesys_user_role用户角色关联表user_id,role_idsys_role_permission角色权限关联表role_id,permission_idoa_leave请假申请表apply_user_id,leave_type,start_time,end_time,reason,statusoa_expense报销申请表apply_user_id,amount,category,statusoa_approval_record审批记录表business_type,business_id,approver_id,approve_action,commentoa_notice公告表title,content,publisher_id,target_dept_idoa_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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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 charsetUTF-8 title${title} - OA系统/title link relstylesheet href/css/app.css /head body #include /common/navbar.ftl div classmain-wrapper #include /common/sidebar.ftl div classcontent #nested /div /div script src/js/common.js/script /body /html /#macro业务页面使用时只需要这样page_layout title请假申请 activeNavapproval form action/approval/leave/submit methodpost !-- 具体表单字段 -- /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和同一个PlatformTransactionManagerSpring事务就能把它们纳入同一个事务边界。这是配置层面的事确认清楚了就不用担心回滚不一致。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); ListLeaveVO list leaveMapper.selectMyApplyList(param); PageInfoLeaveVO 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?serverTimezoneAsia/Shanghai同时把MySQL服务端时区也改成了Asia/Shanghai。这里给大家的建议是数据库连接串里的时区参数一定要显式配置不要依赖默认值。环境一多默认值的不确定性就会变成实打实的Bug。另外这里还要多说一句涉及时间字段的比较和存储统一用DATETIME类型并且所有环境都用手工配置的Asia/Shanghai时区。这套方案跑了大半年再没出现过时间错乱的情况。说一下这套系统跑到现在的一些体会。FreeMarker服务端渲染虽然被很多人认为不够现代但在内部管理系统这种场景下它带来的开发和维护效率提升非常实在。MyBatis加JPA并存的组合让简单操作和复杂查询各得其所不用为了追求架构上的统一性而牺牲开发效率。技术选型这件事真的不是越新越好、越复杂越好永远是匹配业务场景才是最好的。希望这篇实战记录能帮正在做同类项目的朋友少走点弯路。
返回列表