一次由Flowable定时器引发的“401”悬案:深入解析异步线程中的Token传递
目录
一、案情回顾:一个“善变”的接口
二、第一轮排查:代码中的“不在场证明”
三、真相浮现:同步与异步的“岔路口”
四、技术深潜:揭秘Flowable后台的“神秘工人”——Job Executor
五、解决办法
方案一:创建内部专用接口(M2M调用)
方案二:通过流程变量传递用户身份(推荐)
六、结语
在微服务与工作流引擎深度结合的今天,我们时常会遇到一些“灵异”现象:同样的代码逻辑,在流程的一条分支上畅通无阻,而在另一条分支上却神秘地失败。本文将复盘一个真实的业务场景,带您像侦探一样,从蛛丝马迹中追踪并破解一个由Flowable定时器在异步线程中引发的401 Unauthorized
悬案。
一、案情回顾:一个“善变”的接口
我们的业务场景是一个常见的项目审核流程:审稿人可以对用户提交的项目执行“通过”或“驳回”操作。
- 驳回路径:流程正常,项目状态被成功更新。
- 通过路径:流程在定时器触发后,项目状态更新失败,下游的项目服务甚至未收到任何请求日志。
【原始代码:UpdateStatusListener.java
】
@Slf4j
@Component("updateStatusListener")
public class UpdateStatusListener implements JavaDelegate {@Autowiredprivate RuntimeService runtimeService;@Autowiredprivate ProjectClient projectClient;@Overridepublic void execute(DelegateExecution execution) {String processInstanceId = execution.getProcessInstanceId();boolean pass = (boolean) execution.getVariable("pass");String businessKey = runtimeService.createProcessInstanceQuery().processInstanceId(processInstanceId).singleResult().getBusinessKey();ProjectExamineDTO projectExamineDTO = new ProjectExamineDTO();projectExamineDTO.setProjectId(Collections.singletonList(businessKey));// 2:审核不通过,3:审核通过projectExamineDTO.setStatusNumber(pass ? 3 : 2);Result<Boolean> booleanResult = projectClient.examineProject(projectExamineDTO);log.info("项目审核远程调用接口结果:{}", booleanResult);}
}
可以看到,无论是通过(pass=true
)还是驳回(pass=false
),最终都调用了同一个远程方法 projectClient.examineProject
,参数也仅仅是 statusNumber
不同。直觉告诉我们,这不应该有任何问题。
然而,日志输出了截然不同的结果,为我们提供了第一条关键线索:
驳回结果:项目审核远程调用接口结果: Result(code=0, msg=success, data=true)
通过结果:项目审核远程调用接口结果: Result(code=401, msg=未授权, data=null)
在“通过”路径中,接口明确返回了401 未授权
。难道是远程调用时丢失了Token?
二、第一轮排查:代码中的“不在场证明”
根据401
的线索,我们立刻将目光投向了负责传递Token的Feign拦截器。
由此可以看出,在远程调用时,拦截器已经将当前请求线程的请求头内容放到了该次请求头中,那为什么还会出现401呢?为什么驳回流程成功调用呢?分析到这里,已经明确请求头已正确携带,那是为什么呢?
这不由得让我们开始怀疑目标服务接口 examineProject
的代码是否存在逻辑漏洞。
【目标服务代码】
不由得开始怀疑起远程调用的接口是否存在逻辑错误,
@PutMapping(value = "examineProject")
@Operation(summary = "项目审核(statusNumber 0:草稿,1:提交, 2:不通过,3通过)")
@LogOperation("项目审核(statusNumber 0:草稿,1:提交, 2:不通过,3通过)")
@PreAuthorize("hasAnyAuthority('asap:poapproject:examineProject')")
public Result<Boolean> examineProject(@RequestBody ProjectExamineDTO projectExamineDTO) {System.out.println("开始审核项目,项目审核内容是"+projectExamineDTO);Boolean result = poapProjectService.examineProject(projectExamineDTO);return new Result<Boolean>().ok(result);
}@Override
@Transactional(rollbackFor = Exception.class)
public Boolean examineProject(ProjectExamineDTO projectExamineDTO) {logger.info("开始审核项目,项目审核内容是:{}", projectExamineDTO);// ... 省略了详细的数据库操作和消息通知逻辑 ...// 关键点:对于 statusNumber = 2 和 3 的处理,除了调用的通知方法不同,// 核心业务逻辑和权限校验完全一致。if(projectExamineDTO.getStatusNumber() == 2){noticeProducer.projectReviewFail(projectRequest);} else if(projectExamineDTO.getStatusNumber() == 3){noticeProducer.projectReviewSuccess(projectRequest);}return true;
}
在仔细阅读了这段看似平平无奇的业务代码后,我们发现,它并没有针对“通过”和“驳回”设置不同的认证策略。@PreAuthorize
注解的权限检查也是一视同仁。
代码本身似乎无懈可击,那么问题究竟出在哪里?线索,一定隐藏在我们尚未关注到的细节之中。
三、真相浮现:同步与异步的“岔路口”
让我们重新审视“通过”与“驳回”两条链路的本质区别,终于发现了那处微小但致命的差异。
- 驳回链路: 审稿人驳回项目后,流程引擎根据
pass=false
的条件,立即、同步地驱动流程执行“项目驳回”服务任务,并调用监听器。 - 通过链路: 审稿人通过项目后,流程根据
pass=true
的条件,驱动流程进入“等待归档”的用户任务,并激活了边界定时器。流程在此暂停,直到定时器在未来某个时间点触发后续的服务任务。
读到这里,您是否已洞悉了问题的关键?
路径一:审稿人驳回(同步执行)
审稿人通过API(携带用户Token)完成任务,激活了一个HTTP请求线程。从任务完成、网关判断、再到服务任务调用updateStatusListener
,整个过程一气呵成,全部发生在这个同一个HTTP请求线程的生命周期内。因此,由Spring Security管理的用户安全上下文(Token)得以完整保留,Feign拦截器也能成功获取并传递它。
路径二:审稿人通过 -> 定时器触发(异步执行)
审稿人通过API完成任务,流程进入带定时器的等待节点,此时API请求已处理完毕并返回,HTTP请求线程随之销毁。一分钟后,Flowable的后台作业线程(Job Executor)发现了到期的定时器,并开始驱动流程继续执行。
关键的转折点:此时执行updateStatusListener
的,是一个全新的、独立的后台线程。它与之前用户的任何HTTP请求都毫无关联,SecurityContextHolder
是空的,Feign拦截器自然也就获取不到任何Token,导致了401
错误的发生。
四、技术深潜:揭秘Flowable后台的“神秘工人”——Job Executor
为了让大家彻底理解定时器的工作原理,我们补充一下Flowable Job Executor的知识。
1. 核心理念
Job Executor是Flowable引擎中负责处理异步任务和定时事件的核心组件,它是一个持久化、可靠的调度器。当流程遇到需要“等待”或“稍后执行”的节点时,引擎会:
- 创建一个描述了“何时”需要“何事”的**作业(Job)**对象。
- 将这个Job对象持久化到数据库中。
- 结束当前线程,释放资源。
随后,Job Executor会像一个独立的闹钟服务,在后台不断轮询数据库,执行到期的作业。
2. 定时器的生命周期(数据库视角)
- 创建作业 (Job Creation)
-
- 当流程进入“等待归档”节点,引擎会计算出定时器的触发时间(
DueDate = 当前时间 + 1分钟
),并向作业表(如ACT_RU_TIMER_JOB
)中插入一条记录。这条记录包含了流程实例ID、执行ID、到期时间、作业类型等所有必要信息。即使服务器重启,这个“待办事项”也不会丢失。
- 当流程进入“等待归档”节点,引擎会计算出定时器的触发时间(
- 获取作业 (Job Acquisition)
-
- Job Executor的获取线程会定期轮询作业表,执行类似
SELECT * FROM ACT_RU_TIMER_JOB WHERE DUE_DATE_ <= NOW()
的查询,找出所有到期的作业,并使用悲观锁锁定它们以防集群环境下的重复执行。
- Job Executor的获取线程会定期轮询作业表,执行类似
- 执行作业 (Job Execution)
-
- Job Executor的执行线程池中的某个线程会获取到这个作业。它解析作业信息,得知需要触发一个边界定时器事件,然后更新流程实例的状态,驱动流程前进到下一个节点(“执行审核通过逻辑”),并最终调用我们的
updateStatusListener
。
- Job Executor的执行线程池中的某个线程会获取到这个作业。它解析作业信息,得知需要触发一个边界定时器事件,然后更新流程实例的状态,驱动流程前进到下一个节点(“执行审核通过逻辑”),并最终调用我们的
这个机制保证了流程的健壮性和异步处理能力,但也天然地造成了执行上下文的隔离,从而引发了我们文章开头的那场401
悬案。
五、解决办法
针对“后台线程丢失用户身份导致401
”这个典型问题,业界有几种成熟且标准的解决方案。
它们的核心思想都是一致的: 要么为后台线程提供一种新的、受信任的认证方式,要么将原始用户的身份信息“接力”到后台线程中。
方案一:创建内部专用接口(M2M调用)
这种方案的核心是区分“外部用户调用”和“内部服务间调用”。
1. 在目标服务中创建一个新的Controller方法
这个新接口专门用于后台服务间的调用,它不依赖于用户Token,因此不添加 @PreAuthorize
权限校验。
/*** 工作流后台线程调用,不需要携带 token* @param projectExamineDTO* @return*/
@PutMapping(value = "examineProjectPass")
@Operation(summary = "项目审核(statusNumber 3通过)")
@LogOperation("项目审核(statusNumber)")
public Result<Boolean> examineProjectPass(@RequestBody ProjectExamineDTO projectExamineDTO) {System.out.println("开始审核项目,项目审核内容是"+projectExamineDTO);Boolean result = poapProjectService.examineProject(projectExamineDTO);return new Result<Boolean>().ok(result);
}
2. 如何保障安全?
这个“免Token”的内部接口绝不意味着“不设防”。它的安全性由网络层面来保障:
- API网关: 在API网关(Gateway)上配置路由规则,禁止外部流量访问所有以
/internal/
开头的路径。只允许来自内部网络的、可信服务的流量通过。 - 防火墙/VPC: 在云环境中,确保服务都部署在同一个虚拟私有云(VPC)内,服务间的调用不暴露在公网上。
3 修改UpdateStatusListener
进行逻辑分支
在监听器中,根据是“通过”(异步)还是“驳回”(同步)来决定调用哪个Feign接口。
@Override
public void execute(DelegateExecution execution) {
String processInstanceId = execution.getProcessInstanceId();
boolean pass = (boolean) execution.getVariable("pass");
String businessKey = runtimeService.createProcessInstanceQuery()
.processInstanceId(processInstanceId)
.singleResult()
.getBusinessKey();
ProjectExamineDTO projectExamineDTO = new ProjectExamineDTO();
projectExamineDTO.setProjectId(Collections.singletonList(businessKey));
// 2:审核不通过,3:审核通过
projectExamineDTO.setStatusNumber(pass ? 3 : 2);
if(pass){// 该远程调用由工作流后台线程调用,应该选择调用系统内部服务,不携带token跳过网关校验Result<Boolean> booleanResult = projectClient.examineProjectPass(projectExamineDTO);log.info("项目审核通过远程调用接口结果:{}", booleanResult);
}else{// 该请求由 http 请求调用Result<Boolean> booleanResult = projectClient.examineProject(projectExamineDTO);log.info("项目审核驳回远程调用接口结果:{}", booleanResult);
}
}
- 优点:实现简单、逻辑清晰,能快速解决问题。
- 缺点:需要在目标服务新增接口,并依赖网关进行安全管控。
方案二:通过流程变量传递用户身份(推荐)
这种方案更规范,它保留了操作的“归属”,即后台线程执行的业务,我们依然知道是哪个用户最初触发的。
1. 在完成任务时,将用户ID存入流程变量
在审稿人完成审核任务(无论是通过还是驳回)的代码中,获取当前用户的唯一标识(如用户ID),并存入流程变量。
// 在调用 taskService.complete(...) 的地方
Map<String, Object> variables = new HashMap<>();
variables.put("pass", true);
// 从安全上下文中获取当前用户ID
Long currentUserId = SecurityUtils.getUserId();
// 将操作员ID存入流程变量
variables.put("operatorUserId", currentUserId);taskService.complete(taskId, variables);
2. 在UpdateStatusListener
中取出用户ID,并添加到请求头
后台线程执行监听器时,可以从流程变量中取回这个ID。
@Override
public void execute(DelegateExecution execution) {// ...Long operatorUserId = (Long) execution.getVariable("operatorUserId");// ... 准备 projectExamineDTO ...// 调用一个特殊的、能动态添加Header的Feign方法// 注意:这里的实现需要对FeignClient做一些定制Result<Boolean> booleanResult = projectClient.examineProjectWithOperator(projectExamineDTO, operatorUserId);// ...
}
3. 定制FeignClient以动态添加Header
需要有一种机制,能将 operatorUserId
这个变量放入Feign请求的Header中,例如 X-Operator-User-Id: 123
。这通常通过自定义RequestInterceptor
来实现。
4. 修改目标服务的接口和安全逻辑
目标服务的 /examineProject
接口需要修改,使其能够识别这个特殊的Header。安全逻辑也会相应调整:
- 如果请求头中包含
Authorization
(用户Token),则正常走用户权限校验。 - 如果请求头中包含
X-Operator-User-Id
(内部调用),则跳过Token校验,但会信任这个Header,并将操作记在operatorUserId
名下。当然,这个逻辑也需要API网关的配合,确保X-Operator-User-Id
这个Header是可信的内部服务添加的,而不是外部伪造的。 - 优点:安全性更高,审计链路清晰(始终知道操作的发起人),接口统一。
- 缺点:实现相对复杂,需要定制Feign客户端和调整下游服务的安全策略。
六、结语
通过这次复盘,我们不仅解决了一个棘手的401
问题,更深入理解了工作流引擎中同步与异步执行的本质区别。在设计复杂的、包含异步或定时逻辑的流程时,我们必须时刻关注执行上下文的传递问题,尤其是用户身份和安全凭证。无论是通过流程变量手动传递关键信息,还是设计专门的内部免鉴权接口,确保后台线程拥有正确的执行权限,都是保证系统稳定运行的关键。