说实话,这两年做企业办公类系统,最常被问到的就是“签到怎么防重复打卡”“日程怎么跟其他系统打通”“一个OA搞这么复杂有必要吗”。我手里这个项目,就是从一套单体SpringBoot应用一步步拆成SpringCloud微服务架构的完整案例。前后端技术栈是SpringBoot + Vue + SpringCloud全家桶,核心业务覆盖员工日程安排、上下班签到、审批流转这些办公自动化场景。如果你正在纠结“要不要拆微服务”“拆了之后分布式事务怎么处理”“前端权限怎么跟后端服务对应起来”,这篇文章应该能给你一个比较完整的参考。
这个项目最后跑起来的形态是:Nacos做注册中心和配置中心,Gateway做统一入口,业务按领域拆成用户、日程、签到、审批、消息五个服务,前端用Vue3 + Element Plus,整体容器化部署。下面我会从拆分思路开始,把核心模块设计、基础设施搭建、前后端联调、线上踩坑这几个层面逐一摊开讲,重点是那些文档里查不到、必须自己试一遍才知道的细节。
1. 项目整体设计与微服务拆分思路
1.1 从单体到微服务的演进逻辑
先交代背景。这个项目最初的形态是单体SpringBoot应用,单模块打包,所有代码混在一个工程里。功能倒是不复杂:用户管理、日程管理、签到打卡、审批流程,大部分都是CRUD。但跑了半年之后,问题陆续暴露。第一个是签到功能在上下班高峰会明显变慢,因为打卡逻辑和审批流程共用一个数据库连接池,高峰期相互拖累,一个慢SQL能把整个应用的接口拖到超时。第二个是发布很被动,改一个报错提示或者调一个注释也要把整个应用重新打包发布,周末凌晨被叫起来升级的情况发生过不止一次。第三个是新人接手代码仓库的时候,启动依赖和Bean之间的耦合关系理起来非常费劲,一个配置项传错,整个应用起不来。
这些问题让我重新做了评估。我从业务、团队、运维三个维度去判断该不该拆:
- 业务维度:每个模块的用户量和访问特征差异很大。签到是高频短事务,集中在上下班两个时间点;日程是中频读写,分散在白天工作时间;审批是低频但涉及多步骤状态流转,而且经常要跨部门会签。把高频和低频混在一个应用里,互相拖累是必然的。
- 团队维度:如果一直是单人维护,单体确实更省事。但业务明确说要扩到多个开发并行,如果不从物理层面把模块边界切开,两个人同时改一个工程,Git冲突就能耗掉半天时间。
- 运维维度:单体部署简单,但故障隔离能力几乎为零。某个接口发生内存溢出,整个应用不可用,所有业务全挂。微服务拆完之后,某个服务挂了,至少其他服务还能继续提供服务。
所以这个项目改走微服务,不是因为“微服务更时髦”,而是因为拆分之后能把故障隔离、独立扩展、独立发布这三个收益真正落到业务上。
1.2 服务拆分边界怎么划
拆服务最怕的不是拆得不够细,而是拆得没有依据。我用的方法是先画业务域,再定服务边界,最后才考虑技术实现。业务域里很清晰的有四块:人员组织、日程计划、考勤签到、流程审批。加上一个横切的消息通知能力,一共五个服务:
- 用户服务(user-service):组织架构、用户信息、角色权限。
- 日程服务(schedule-service):日程的增删改查、共享、提醒。
- 签到服务(attendance-service):打卡、补卡、考勤统计。
- 审批服务(approval-service):请假、加班、出差审批流程。
- 消息服务(message-service):站内信、邮件通知、WebSocket推送。
判断服务边界画得对不对,我有一个比较笨但有效的办法:看服务之间能不能用清晰的API调用描述清楚,数据表之间能不能做到不跨库join。比如用户服务里的sys_dept表和签到服务里的attendance_record表,如果业务场景里需要join查询部门出勤汇总,说明边界画错了。正确的做法是让签到服务通过Feign调用用户服务的接口,拿到部门信息后在内存里做聚合。这个设计听起来简单,但实际落地时我画了三四轮才把数据归属和调用关系理干净,最基本的原则就是:数据归属谁,谁就拥有这张表的写权限;其他服务要用数据,只能走接口,不能直连库。
1.3 技术选型对比与取舍
SpringCloud和SpringBoot的搭配是当前Java微服务体系里比较主流的一套。我也对比过SpringCloud和Dubbo:Dubbo的服务治理能力更强,更偏RPC性能,但和网关、配置中心、消息总线这些配套组件整合起来需要额外做不少工作,生态不如SpringCloud完整。SpringCloud全家桶的好处是组件齐全,从注册发现到网关、熔断、配置中心都有对应的标准组件,团队学习和排查问题的曲线相对平滑。
具体组件我选的是:Nacos(注册中心+配置中心)、Spring Cloud Gateway(网关)、OpenFeign(服务间调用)、Sentinel(限流熔断)、Seata(分布式事务)。为什么没有选Eureka?Eureka 2.0的维护状态一直不太明朗,而Nacos在阿里内部有大规模生产验证,控制台能看到实时健康状态、权重配置,还兼具配置中心能力,一个组件解决两件事。Gateway对比Zuul的优势在于基于Spring WebFlux,性能更好,而且社区热度高,踩坑案例多,出问题好查。
这里多说一句:如果只是学习或者企业内部小范围使用,架构可以大幅简化。注册中心用Nacos单机就行,配置先写本地,网关也可以去掉,直接用服务间调用。但做真正面向企业场景的办公系统,网关的价值在于统一鉴权、统一限流、统一日志,这些都是后续运维排查的刚需,不是可有可无的装饰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心业务模块设计与数据库落地
2.1 日程管理模块的设计细节
日程服务是整个系统里看着最简单、实际最繁琐的部分。表面上是增删改查,但企业场景里它有几个硬骨头。
第一个是循环日程。比如每周一上午十点的项目例会,不能每次手动新建,得用RRULE表达式存规则。我在schedule表里加了一个rule字段,存类似RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=20这样的规则串。查询某天日程时,把所有带规则的任务加载出来做一次时间展开,符合条件的就返回。这个方案比直接存多条具体日程节省存储,但展开逻辑要特别小心时区问题,我在测试的时候发现服务器时区配置不一致,展开出来的时间差了8个小时,排查了半天才反应过来是serverTimezone的问题。
第二个是日程的参与者状态。日程分几种可见范围:仅个人可见、部门共享、跨部门邀请。被邀请的参与者会看到“待确认、已接受、已拒绝”三个状态,发起人能在详情页看到统计结果。这里我用了一张schedule_participant中间表,把日程ID和用户ID关联,状态字段用tinyint存,0待定、1接受、2拒绝。查询某人的日程列表时,通过用户ID关联这张中间表,既能拿到他创建的日程,也能拿到他被邀请的日程。
第三个是日程冲突检测。需求明确“同一用户的日程时间不能重叠”,我在服务层做了一个校验接口,创建和更新时都检查时间段交叉:start_time < new_end_time AND end_time > new_start_time。这个逻辑如果放在数据库层做,需要排除当前日程ID再做范围查询,实测数据量超过一万条之后性能明显下滑。所以最终做成两级校验:先从Redis缓存里查该用户当天的日程索引,再精确比对时间段,命中冲突就直接拒绝。
2.2 签到模块:防重复、防作弊与高并发
签到服务的核心诉求有三个:不能重复打卡、上下班时间可配置、考勤结果可统计。
签到表attendance_record的核心字段有:user_id、sign_date、morning_time、evening_time、status。考虑到一个用户一天只能有一条记录,我建了唯一索引uk_user_date (user_id, sign_date),这是防重复打卡最底层的一道保险。如果没有这个唯一索引,哪怕业务代码里判断了“是否已打卡”,在高并发下也会出现两条记录。
但并发场景下,同一时刻大量用户同时打卡,如果都走“查库判断是否存在记录再插入”,会有竞态问题。这里我引入了Redis分布式锁,锁的key是attendance:lock:{userId}:{date},用setnx加过期时间,拿到锁之后再去查库、更新或插入。这个方案实测下来,单机Redis支撑几千人的同时打卡压力不大,锁粒度按用户维度切分,并发性能足够。锁的释放逻辑也很重要,不能用完直接del,必须比对当前线程持有锁的唯一标识,防止误删别人的锁,这个细节在第5部分排查经验里我会详细说。
“防作弊”这一块我做了取舍。企业OA场景下的签到,核心是考勤规则跟审批流程联动,GPS定位、IP白名单那些偏外勤强校验的能力我没有放在第一期。补卡记录单独落在attendance_adjust表,跟正常打卡分开存储,月底统计时用左连接合并。外勤打卡必须关联审批单ID,这个联动逻辑打通了签到服务和审批服务,算是这个系统的精髓之一。
2.3 用户权限与组织架构的数据模型
用户服务管的不只是账号密码,还有组织架构。两张核心表:sys_user和sys_dept,用户表里有dept_id外键。权限模型用的是RBAC(基于角色的访问控制),五张表:用户、角色、权限、用户角色关联、角色权限关联。
这里有个容易踩坑的地方:微服务拆分后,登录鉴权放在网关层做,但权限数据在用户服务里。我的做法是网关做登录态校验,JWT解析通过后就放行请求;具体到某个接口的访问权限,由各业务服务自己通过对该用户的角色做校验来判断。有人问过为什么不在网关层把所有权限都校验完,答案很简单:网关拿不到业务数据,比如“这个日程是不是属于当前用户”,这类数据级权限必须下推到业务服务判断。前端路由的权限则是通过登录后拉取用户信息和权限列表,动态生成侧边栏菜单,这个在第四部分讲前端的时候再展开。
2.4 分布式ID与跨服务数据一致性
微服务拆分后,数据库自增主键不再适用,因为多个服务同时插入数据,自增ID在跨服务场景下没法保证全局唯一。我用的方案是雪花算法(Snowflake),自己封装了一个ID生成器,基于时间戳+机器ID+序列号,单机每毫秒可生成4096个ID,全局唯一且趋势递增。这里要提醒一点:机器ID的分配必须避免冲突,我在配置中心给每个服务分配了独立的workerId,否则两个服务各发各的,序列号不同但时间戳和机器ID相同,照样会撞。
跨服务的数据一致性我用了两种策略结合。像“签到成功要发站内信提醒”这种场景,一致性要求没那么实时,我直接走异步消息:签到服务发MQ消息,消息服务消费后发通知。像“审批通过后同时更新审批单状态、生成补卡记录、写一条消息通知”这种需要跨服务强一致的操作,我使用Seata的AT模式,在方法上加@GlobalTransactional注解,通过分支事务实现回滚。这里给一个很实在的建议:能异步就不要上分布式事务。Seata的AT模式调试起来相当费劲,每张参与事务的表都要加undo_log表,性能有损耗,而且全局事务协调器一旦网络抖动,处理超时的逻辑不写好的话,整个流程会卡住很久。
3. SpringCloud基础设施搭建实操
3.1 Nacos注册中心与配置中心落地
这一步我踩的坑最多,先说版本。Nacos版本和SpringCloud Alibaba版本对应关系非常严格,很多人直接用最新版本,结果服务注册不上或者控制台打不开。我自己用的版本组合:SpringBoot 2.7.x + SpringCloud 2021.0.x + SpringCloud Alibaba 2021.0.5.0 + Nacos Server 2.2.x。这个组合在公司内部跑了半年多,稳定性没问题。强烈建议按这个组合来,不要追新。
注册中心配置很简单,服务里引入spring-cloud-starter-alibaba-nacos-discovery,然后在application.yml里写:
yaml复制spring:
application:
name: schedule-service
cloud:
nacos:
discovery:
server-addr: 192.168.1.100:8848
namespace: office-prod
启动服务后到Nacos控制台的服务列表里能看到所有实例的IP和端口。这里最关键的是namespace的使用。我建了office-dev、office-test、office-prod三个命名空间,实例数据天然隔离,避免开发环境的服务注册到生产注册中心,导致网关把请求转发到开发机上的实例。这个问题我之前在另一个项目里亲身经历过,排查到崩溃。
配置中心我复用同一个Nacos,配置里写:
yaml复制spring:
cloud:
nacos:
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
file-extension: yaml
然后把原来application.yml里的公共配置(数据库连接、Redis连接、消息队列)全部挪到Nacos的配置列表里做动态管理。修改配置之后不用重启服务,配合@RefreshScope注解,业务Bean里的配置值就能实时生效。这个能力在做签到规则调整时非常方便:下午临时调整打卡截止时间,改完配置调用一次刷新接口,马上生效,不需要重新发布。
3.2 Gateway网关统一入口配置
网关是系统的统一门面,前端只跟网关打交道,不直接感知后端某个服务跑在哪个端口。网关配置分三块:路由规则、全局过滤器、跨域配置。
路由规则用RouteLocator实现,每个微服务对应一条路由规则:
java复制@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("user", r -> r.path("/api/user/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://user-service"))
.route("schedule", r -> r.path("/api/schedule/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://schedule-service"))
.build();
}
注意lb://前缀,配合Nacos做服务发现和负载均衡。stripPrefix(1)的作用是剥掉第一层路径/api,这里有个大坑,我在第5部分会详细讲。
全局过滤器里做了两件事。第一是JWT登录校验:请求头带Authorization: Bearer <token>,过滤器解析token,校验通过就放行,否则返回401。白名单路径(比如登录接口、验证码接口)在过滤器里显式排除,我是用一个List<String>维护白名单数组。第二是访问日志:每个请求的耗时、目标服务、状态码统一打印。排查线上问题的时候,这份日志是第一个看的地方。
跨域配置也放在网关层。前端Vue开发服务器跑在8080端口,后端接口在网关的8080端口,端口不同就跨域。我在Gateway里配置了全局CORS,允许指定来源的请求跨域访问,这样开发环境下前端不需要额外配代理。
3.3 Feign服务间调用与超时设置
服务间调用我用的是OpenFeign。用法很简单,定义接口加注解:
java复制@FeignClient(name = "user-service", path = "/api/user")
public interface UserClient {
@GetMapping("/info/{id}")x
UserInfoDTO getUserInfo(@PathVariable("id") Long id);
}
需要注意两个点。第一个是Feign默认的超时时间是1秒,对内部系统来说这个值太小了,跨服务调用往往要查库、做业务校验,1秒很容易超时。我在配置里统一调整为连接超时3秒、读取超时5秒,签到服务这种关键链路的调用单独延长到10秒。第二个是Feign底层默认用JDK的HttpURLConnection,连接池能力较弱,我换成了OkHttp,实测吞吐量有提升。
服务调用链上还加了Sentinel做熔断降级。Feign接口定义完之后,在配置里给特定方法设置熔断规则,比如连续错误比例超过50%就熔断5秒。这样即使下游用户服务挂了,签到服务也能返回兜底的空列表,而不是把线程池占满,把整个服务拖死。
3.4 分布式事务的实现取舍
分布式事务是这个项目里最好说也是最难说的一块。好说,是因为业务里真正需要跨服务强一致的场景并不多;难说,是因为一旦真要实现,锅就来了。
我实际用Seata的AT模式覆盖的场景只有两个:审批通过后更新审批单状态并生成考勤补卡记录、创建日程时同步生成相关人的待办事项。这三个操作分别落在审批服务、签到服务、日程服务里,用@GlobalTransactional把整个流程包住,Seata会记录undo log,某个分支失败时整体回滚。
但我不建议无脑给所有跨服务操作都加全局事务,原因有两条。第一,AT模式对数据库表有额外要求,所有参与事务的表都要建undo_log表,每次写操作都有额外开销。第二,全局事务协调器一旦出问题,整个调用链会长时间阻塞,在线业务根本等不起。我的经验是:大部分跨服务场景用本地事务加消息最终一致性就够了。比如签到生成通知这种场景,就算消息丢失,用户补卡时也能从页面状态看出来,后台定时对账脚本兜底完全没问题。
4. 前端Vue工程落地与联调实录
4.1 Vue3工程结构设计
前端用的Vue3 + Vite + Element Plus + Pinia + Vue Router。为什么选Vite不用Vue CLI?开发启动速度差一个数量级,热更新秒级响应。Vite在开发环境按需编译,不需要等整个项目构建完成,这个体验一旦用上就回不去了。
创建项目用官方脚手架:
bash复制npm create vite@latest office-admin -- --template vue
工程目录按模块划分:src/api(接口请求封装)、src/views(页面)、src/router(路由)、src/store(Pinia状态管理)、src/components(公共组件)。每个业务模块的接口请求单独一个文件,比如schedule.js里封装所有日程相关的axios调用,不要全堆在一个request.js里,不然页面多了根本找不到接口在哪里。
4.2 axios封装与token处理
接口请求统一走axios实例,封装了三层逻辑。第一是请求拦截器,从Pinia里取token放到请求头。第二是响应拦截器,后端返回统一JSON结构{ code, message, data },code为200表示成功;如果code为401,说明token过期,清空用户信息并跳转登录页。第三是兜底错误处理,网络异常、超时、服务端5xx统一弹提示,页面不用每个接口重复写错误处理。
token还有一个静默续期机制:每次请求返回时检查token有效期,剩余少于30分钟就后台调一次刷新接口。这个机制保证了用户长时间在线操作不会突然掉线,我们内部的评价是“感知不到它的存在,就是最好的体验”。
4.3 动态路由与前端权限控制
前端权限控制是配合后端角色体系做的。用户登录成功后,store里保存用户信息和角色列表。路由表分两种:静态路由和动态路由。静态路由只有login、404这些公共页面;动态路由根据角色从后端拉取权限路由数据,用router.addRoute()逐条注册。
这里有个细节容易忽略:后端返回的权限数据不要直接就是路径字符串,最好是带component字段标识的菜单结构。前端维护一个“组件名到组件对象”的映射表,比如dashboard: () => import('../views/dashboard/index.vue')。这样新增页面时只需要在映射表里加一行,后端菜单数据更新后前端自动生效,不用改代码。如果后端直接传路径字符串,前端还得自己拼import,改造起来非常痛苦。
4.4 日程组件选择与签到页面实现
日程页面我用的日历组件是FullCalendar的Vue封装,支持月视图、周视图、日视图切换,可以拖拽创建日程,也可以点击事件查看详情。FullCalendar的版本差异比较大,Vue3下建议直接用@fullcalendar/vue3这个封装包,别自己手写日历渲染,费时费力。日程弹窗里选择参与人时,下拉数据从用户服务动态加载,搜索接口用关键字模糊匹配。
签到页面有两块:一块是打卡按钮区域,顶部显示当前时间和打卡状态;另一块是考勤统计卡片区,显示本月出勤天数、迟到次数、外勤次数。打卡按钮做成大按钮卡片式设计,点击后调签到服务接口,成功后再更新页面状态。这里必须处理一个细节:打卡接口返回的服务器时间和本机时间可能有偏差,页面提示语里显示的时间必须以服务器返回为准,否则用户发现打卡时间跟自己手机差了几分钟,投诉就来了。
4.5 与后端联调时的接口规范
前后端联调最烦的就是接口不规范。项目初期我定了三条规定:统一返回{ code, message, data };分页返回{ total, records };时间格式统一为yyyy-MM-dd HH:mm:ss。后端实体类用@JsonFormat注解规范化序列化格式,前端axios拦截器里统一处理时间字符串,不在业务代码里各自转换。
另一个规范是接口路径的版本管理。所有接口前缀都带/api,网关层根据第二段路径区分服务,比如/api/schedule/list、/api/attendance/sign。这样前后端接口一旦确定,后期网关调整路由规则时不用改前端代码。接口文档我用的是Apifox,每个服务在接口上写清楚入参、出参、异常码,联调效率提升很明显,不用每次都在IM软件里来回对参数。
5. 常见问题与排查经验实录
5.1 Nacos注册上了但服务调不通
这个坑我修了一个晚上。现象是Nacos控制台能看到服务实例,状态是健康的,但Feign调用一直报连接超时。排查到最后发现服务提供方的日志里根本没有请求进来,说明流量根本没到目标服务。原因是网关路由断言里,请求路径/api/user/info/1经过lb://user-service转发后,路径还是完整的,但user-service接口的上下文路径是/api/user,等于路径重复了一遍。解决办法是加StripPrefix=1,把第一层/api剥掉再转发。这个细节,凡是第一次搭Gateway的人几乎都会遇到一次。
5.2 Redis分布式锁的释放问题
Redis分布式锁最经典的坑是“锁忘了释放”和“锁被别的线程释放”。我代码里的释放逻辑必须判断持有者:用Lua脚本比对当前线程的唯一标识,确认是自己持有才执行del。如果没有这个判断,业务A线程因为网络阻塞导致锁超时自动过期,业务B拿到锁正在执行业务,此时A恢复继续执行并释放锁,就把B的锁给删了。两个业务逻辑就会并发执行,后面查数据为什么重复都不知道问题在哪。
锁的续期问题也值得说。如果业务执行时间超过了锁的过期时间,需要主动续期。我这边做了一个定时任务,在锁过期前三分之一时间点自动续期。如果团队不介意引依赖,直接用Redisson的RLock最省心,它内置了看门狗续期机制,默认每10秒检查一次持有状态,比手写续期逻辑稳得多。
5.3 本地多服务启动调试的经验
微服务项目本地调试最大的障碍是端口太多。五个业务服务加网关加前端,一共7个进程。我自己的做法是:写一个docker-compose.dev.yml,把Nacos、Redis、MySQL、RabbitMQ这些中间件全部用Docker启动,业务服务在IDE里手动启动。这样做的好处是能快速分清问题到底出在基础设施还是业务代码,省去来回排查的烦恼。
另外一个建议是把每个服务的启动端口固定下来,在Nacos配置里统一声明。比如user-service是8101、schedule-service是8102、attendance-service是8103。如果不固定端口,每次启动都要翻代码确认,端口冲突排查一次就能折腾半小时。固定之后,IDE里启动哪些服务、连哪个端口一目了然。
5.4 Vue打包后部署到Nginx的注意事项
前端打包命令是npm run build,产物在dist目录。部署到Nginx时有个特别关键的配置:前端路由如果用history模式,刷新页面时Nginx会按请求路径找文件,找不到就404。必须在Nginx配置里加:
nginx复制location / {
try_files $uri $uri/ /index.html;
}
同时,前后端分离部署时,Nginx需要把API请求反向代理到网关服务地址,前端页面请求/api/**路径时都走代理转发。对于办公自动化这类系统,通常部署在内网环境,如果需要公网访问,还要顺手配上HTTPS证书,这个可以根据企业安全规范来定。
5.5 考勤统计不准确的排查思路
考勤统计这块最容易出问题的是跨天场景。比如夜班员工凌晨下班,打卡时间算前一天还是当天?我在attendance_service里配置了考勤日切时间,默认是凌晨5点,也就是说凌晨5点之前的打卡记录归属前一天。这个配置在Nacos里动态调整,遇到节假日还可以临时改。统计不准确时,优先查这个日切配置有没有被改动,其次查补卡记录有没有关联到正确的审批单,最后查时区偏差。
另外还有一个容易忽略的点:数据库表里的时间字段类型。如果用的是datetime,服务器时区设置不正确,展示出来的时间会跟实际相差8个小时。我在所有涉及时间展示的位置都统一走服务端格式化,不依赖前端运行时区判断,这样至少保证同一台服务器上的数据是一致的。
最后再分享一个我个人的体会。整个系统做完之后,我最大的收获不是学会了SpringCloud组件怎么用,而是真正明白了一个道理:微服务是一种手段,不是目标。业务复杂度没上来的时候,单体加缓存照样能跑得很好;一旦用了微服务,就要同步做好监控、日志、链路追踪这些配套工作,不然出问题时排查范围会扩大好几倍。我的建议是,如果团队人数少、业务场景简单,先从模块化单体做起,在代码层面把模块边界画清楚。等到部署、团队、业务三个维度都顶不住的时候,再拆微服务才顺理成章。这个项目之所以拆,是因为它后续要接移动端、接考勤机、接第三方审批系统,提前把服务化扩展点做出来,比业务跑起来之后再重构要划算得多。
如果你正在规划类似的办公自动化系统,建议先把网关、注册中心、配置中心这套基础底座搭好,然后用一个最精简的业务闭环(比如只做签到功能)打通全链路。第一次成功跑通之后,再逐步填充日程、审批这些复杂业务模块,整个过程会顺畅很多。
