site logo

Marico's space

我用 Spring Boot 和 Spring Security 构建 3 层审批引擎

服务器技术 2026-04-25 22:59:13 9
*深入探讨多级工作流逻辑、无状态 JWT 认证、服务层授权守卫,以及完整的 CI/CD 流水线——全部在生产环境运行。* --- ## 为什么我要做这个 大多数后端教程教的都是 CRUD。注册、登录、获取列表。挺好的——但这无法让你为真实企业实际运行的系统做好准备。 几乎每个组织都有某种形式的审批工作流:员工提交请假申请,经理审核,部门负责人最终批准。当这些流程存在于邮件和电子表格中时,事情会丢失,审计变得不可能,权限滥用也无法被发现。 我想构建一个能真正解决这个问题的系统——有真正的状态机逻辑、强制执行的角色边界、完整的审计追踪,以及你在生产后端中会看到的那种安全架构。这就是我构建的东西,下面是它的工作原理。 **在线 Swagger UI:** https://workflow-approval-system-zq0a.onrender.com/swagger-ui.html **GitHub:** https://github.com/DeepsanBhandari/workflow-approval-system --- ## 核心问题:状态机比 CRUD 更难 审批工作流的根本挑战不是端点——而是**状态完整性**。一个工作流请求有多个状态: ``` DRAFT → PENDING_MANAGER → PENDING_ADMIN → APPROVED ↘ ↘ REJECTED REJECTED ↘ CHANGES_REQUESTED ``` 每个转换都有规则: - 只有 `EMPLOYEE` 可以提交 `DRAFT` - 只有 `MANAGER` 可以操作 `PENDING_MANAGER` - 只有 `ADMIN` 可以操作 `PENDING_ADMIN` - 不能跳级 - 不能操作已完成的工作流 如果在**服务层**不强制执行这些,无论你的控制器级检查有多好,你的 API 都会有漏洞。 --- ## 架构:分层 + DTO 优先 系统遵循严格的分层架构: ``` Controller Layer → 接收 HTTP 请求,立即委托 Service Layer → 拥有所有业务逻辑和授权守卫 Repository Layer → 纯数据访问,无业务逻辑 ``` 这不仅仅是整洁代码的理念——它有具体的安全好处。如果把授权逻辑放在控制器中,在测试或内部集成中通过直接的服务调用很容易绕过。**服务层的授权始终会被强制执行**,无论服务如何被调用。 每个 API 契约都通过 DTO 强类型化——`WorkflowResponse`、`ApprovalStepResponse`、`ApiResponseVoid`。没有实体泄露到外部世界。这通过设计强制实现了角色间的零数据泄露。 --- ## 安全:无状态 JWT 与自定义过滤器链 我使用自定义 Spring Security 过滤器实现了无状态 JWT 认证。这里有一个重要的架构决策: **Token 验证发生在边界。业务逻辑与认证 concerns 完全解耦。** 自定义 `JwtAuthenticationFilter` 继承 `OncePerRequestFilter`。它: 1. 提取 `Authorization: Bearer ` 请求头 2. 使用密钥验证 token 签名 3. 加载 `UserDetails` 并设置 `SecurityContextHolder` 4. 传递给过滤器链中的下一个过滤器 ```java // Filter 内部的概念流程 String token = extractToken(request); if (token != null && jwtService.isTokenValid(token)) { UserDetails userDetails = userDetailsService.loadUserByUsername( jwtService.extractUsername(token) ); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); SecurityContextHolder.getContext().setAuthentication(authToken); } filterChain.doFilter(request, response); ``` 为什么是无状态的?因为无状态 JWT 意味着**服务器上不需要会话存储**。系统可以水平扩展,无需 sticky sessions。每个请求都是自包含的——token 携带身份声明。 密码使用 `BCryptPasswordEncoder` 哈希存储。从不以明文存储,从不记录。 --- ## 服务层授权守卫(关键洞察) 这是整个系统最重要的架构决策。 这里是用控制器级角色检查的问题: ```java // ❌ 脆弱——仅在 HTTP 边界强制执行 @PreAuthorize("hasRole('MANAGER')") @PostMapping("/{id}/approve") public ResponseEntity approve(@PathVariable Long id) { workflowService.approve(id); // 内部没有守卫 } ``` 如果 `workflowService.approve()` 从调度器、测试或另一个服务内部调用,角色检查就会被绕过。 这是我使用的替代方法: ```java // ✅ 无论调用者是谁都被强制执行 public WorkflowResponse approve(Long workflowId, String approverUsername) { Workflow workflow = workflowRepository.findById(workflowId) .orElseThrow(() -> new WorkflowNotFoundException(workflowId)); User approver = userRepository.findByUsername(approverUsername) .orElseThrow(() -> new UserNotFoundException(approverUsername)); // 守卫:这个审批者是否被允许操作这个工作流状态? validateApproverCanAct(workflow.getStatus(), approver.getRole()); // 守卫:工作流是否处于可操作状态? validateWorkflowIsActionable(workflow.getStatus()); // ... 继续状态转换 } ``` `validateApproverCanAct` 在 Manager 试图操作 `PENDING_ADMIN` 或反之时抛出 `UnauthorizedActionException`。这不是 Spring Security 的问题——它是**业务规则授权**,它存在于它应该存在的位置:服务层。 --- ## 状态转换引擎 审批步骤是顺序的,不是并行的。每个转换都是明确的: ```java private void validateAndTransition(Workflow workflow, ApprovalAction action, User actor) { WorkflowStatus current = workflow.getStatus(); switch (current) { case PENDING_MANAGER: requireRole(actor, Role.MANAGER); if (action == ApprovalAction.APPROVE) { workflow.setStatus(WorkflowStatus.PENDING_ADMIN); } else if (action == ApprovalAction.REJECT) { workflow.setStatus(WorkflowStatus.REJECTED); } else { workflow.setStatus(WorkflowStatus.CHANGES_REQUESTED); } break; case PENDING_ADMIN: requireRole(actor, Role.ADMIN); if (action == ApprovalAction.APPROVE) { workflow.setStatus(WorkflowStatus.APPROVED); } else { workflow.setStatus(WorkflowStatus.REJECTED); } break; default: throw new InvalidWorkflowStateException(current); } } ``` 没有状态可以跳过。没有参与者可以在其层级外操作。无效的转换抛出类型化异常,被全局异常处理器捕获。 --- ## 审计追踪:每个操作都被记录 每个审批、拒绝或变更请求都被记录为 `ApprovalStep` 实体: ```javascript ApprovalStep { workflowId, actorUsername, actorRole, action (APPROVE / REJECT / REQUEST_CHANGES), comment, timestamp } ``` `GET /api/workflows/{id}/history` 返回完整的顺序历史。对于任何真实的审批系统这是必须的——你需要知道谁做了什么以及何时做的。 --- ## 测试:JUnit 5 + Mockito 达到 80%+ 覆盖率 我把测试覆盖率重点放在最重要的地方——业务规则所在的服务层。 示例:测试 Manager 不能审批 `ADMIN` 级别的步骤: ```java @Test void managerCannotApproveAdminLevelStep() { Workflow workflow = createWorkflowWithStatus(WorkflowStatus.PENDING_ADMIN); User manager = createUserWithRole(Role.MANAGER); assertThrows(UnauthorizedActionException.class, () -> workflowService.approve(workflow.getId(), manager.getUsername()) ); } ``` 测试 happy path 是容易的。测试边缘情况——Manager 试图插队时会发生什么、有人试图操作已批准的工作流时会怎样——这才是真正覆盖率来源。 --- ## CI/CD 流水线:零手动步骤 GitHub Actions 流水线在每次推送到 `master` 时运行: ```yaml jobs: build-and-deploy: steps: - name: Run Tests run: mvn test # 快速失败——在合并前捕获回归 - name: Build Docker Image run: docker build -t workflow-approval-system . - name: Push to Registry run: docker push ... - name: Deploy to Render # Render 在镜像推送时自动从 registry 部署 ``` 如果测试失败,流水线停止。没有什么坏东西能到达生产环境。Dockerfile 使用多阶段构建——在一个阶段编译,在精简的 JRE 镜像中运行——保持最终镜像精简。 --- ## 接下来我会添加什么 对真正的企业系统缺什么保持诚实: 1. **通知**——当工作流到达你的层级时触发邮件/webhook 2. **Kafka 用于审计事件**——将审批事件发布到消息代理而不是同步数据库写入 3. **历史端点分页**——现在没问题,但在大型审计日志下会出问题 4. **Refresh token 轮换**——当前 JWT 实现使用单一 token;生产环境需要 refresh + revocation --- ## 关键要点 如果你正在构建自己的审批系统或类似的状态机后端: - **把授权守卫放在服务层**,不仅仅是控制器 - **显式建模状态转换**——不要依赖散落在代码库各处的 if/else 链 - **每个修改状态的操作都应该被审计** - **业务逻辑上 80%+ 的测试覆盖率是可实现的,值得投入**——不是为了指标,而是因为测试迫使你思考边缘情况 完整系统已上线,通过 Swagger 完整文档化,可在 GitHub 获取。如果你对任何实现细节有疑问,留言——很乐意深入讨论任何部分。 --- *Deepsan Bhandari 是一名 Java/Spring Boot 后端工程师,位于尼泊尔,目前在 IOE Purwanchal Campus 完成土木工程学士学位,同时构建生产级后端系统。开放接受远程实习和自由职业后端工作。* *GitHub: github.com/DeepsanBhandari | LinkedIn: linkedin.com/in/deepsan-bhandari*