我用 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*