采购申请 P401 的部门审批待办原本分给 uA。管理员发现 uA 已调岗,于是把待办转给 uB。几分钟后,uA 在早已打开的页面点了“同意”,uB 也在自己的待办列表里点了“同意”。如果两个动作都成功,审批意见可能重复;如果转办只改了页面列表而引擎仍认 uA,uB 看得见却办不了;如果转办时先删旧任务再建新任务,失败窗口里还可能让待办短暂消失。
这类问题的核心不是“刷新一下列表”,而是当前任务归属、执行权限和并发状态必须以同一个事实为准。本文用一张虚构采购单 P401、实例 I401、待办 T401,说明候选、领取、转办、委派各是什么,怎样设计转办事务与旧页面拒绝,如何用 Java 例子和 MySQL 查询验证最终只有一次合法办理。示例面向 Java 11 / MySQL 8.x;文中的表和程序是独立教学模型,不对应任何产品内部流程配置。
目录
一、先把故障现场和预期结果说清楚
P401 属于 orgA,已进入审批;流程实例为 I401,当前部门审批待办为 T401,状态 OPEN,办理人 uA,修订号 0。管理员执行转办操作 X401,目标是同组织且仍有效的 uB。转办成功后,T401 保持原任务编号,办理人变成 uB,修订号变为 1。uA 已打开的页面仍可能显示旧卡片,但旧数据不能再作为提交依据。
| 转办前 | T401=OPEN, assignee=uA, rev=0 | 可办理 | 不可办理 | uA 提交可通过当前态校验 |
| 转办成功 | T401=OPEN, assignee=uB, rev=1 | 可能残留旧卡片 | 能看到当前待办 | 旧人提交必须拒绝 |
| uB 完成 | T401=COMPLETED, rev=2 | 旧卡片仍可能残留 | 卡片从待办移出 | 同一任务只产生一次完成结果 |
这里的“丢待办”至少有三种不同成因:任务真的没了;任务仍在引擎里但分给不存在或已停用的账号;任务状态正确,列表投影或缓存却没更新。排查时先按 I401/T401 查引擎当前任务,再看成员有效性和列表索引,不能仅凭前端是否显示来判断任务存在与否。

图1:旧页面保留的是过期视图;是否能办理以提交时的任务归属和修订号为准。
二、候选、领取、转办和委派的区别
“把任务交给别人”不是一个操作。先定义业务语义,才能决定改哪个字段、保留谁的责任和生成什么审计记录:
| 候选 | 未指定唯一办理人 | 符合候选用户/组规则的人可领取 | 尚无原办理人 | 部门待办池 |
| 领取 | 从候选池锁定唯一办理人 | 领取成功者 | 其他候选人不再可提交 | 多人择一办理 |
| 转办 | 已有办理人,改为另一人 | 新办理人 | 原办理人失去执行权,历史保留 | 调岗、休假、纠错 |
| 委派 | 保留原负责人,交受托人处理部分工作 | 受托人按委派语义处理 | 原负责人可能在受托人处理后继续负责 | 请人补材料或代办 |
Flowable 对用户任务的说明区分了 assignee、candidate 和 owner;TaskService 文档也把设置办理人、领取、委派与完成任务列为不同能力。一个任务即使是候选池可见,也不意味着所有候选人都能同时完成;领取必须有竞争保护。转办也不能通过在候选组里加上 uB 来代替,因为这样可能让 uA 的办理权仍然有效。
本例使用转办:uA 调岗后不再负责,uB 接受完整办理权。历史上谁曾经负责、谁转给谁、何时转办必须保留,但当下执行者只有 uB。如果业务真正要的是“uB 补充意见后再回到 uA”,应建成委派与回收流程,不能把转办完成后又改回 uA 当成委派的实现。
对于候选组,还要处理成员变化。定义里写“部门经理组”只是选择规则;用户今天是否仍是该组织的有效成员,应在领取和办理时用当前成员关系核对。Flowable 的 API 文档明确提醒,基础身份服务不自动保证运行时用户一定真实有效;应用自己的组织与权限层仍要校验目标人资格。这一点对停用账号、跨公司转办尤其重要。

图2:候选、领取、转办和委派改变的责任关系不同,不能用一个“改办理人”接口混用。
三、转办为什么要和办理争同一个版本
最危险的是同时操作。uA 已打开审批页,拿到的是 rev=0;管理员也拿到 rev=0,准备转给 uB。如果管理员先提交成功,任务变成 assignee=uB, rev=1;随后 uA 的提交必须因“当前办理人不符”或“修订号已过期”被拒绝,提示刷新。反过来,若 uA 先完成任务,管理员转办应该发现 T401 已完成并拒绝,不能把已结束任务改给 uB。
这可用条件更新或引擎提供的并发控制表达:
转办条件:同组织 + T401仍OPEN + 当前修订号0 + 目标成员有效 + 操作者有转办权限
转办结果:assignee uA → uB,修订号0 → 1,写入X401操作记录
办理条件:同组织 + T401仍OPEN + 当前办理人是本人 + 当前成员有效 + 页面修订号仍匹配
办理结果:T401完成,修订号再推进,并写一次审批意见
两个条件必须以同一个当前任务事实判断。页面禁用按钮、缓存删除、消息提醒可以改善体验,但不能提供最后一道并发保障。若任务存储和审计表在同一数据库,可在单事务中条件更新并写审计;若引擎是独立服务,应以引擎 API 的实际执行结果为准,并对业务侧投影做异步确认和对账。业务库的事务不能替远端引擎锁住 T401。
请求超时同样不能盲目重试。转办请求 X401 第一次可能已经成功,只是响应丢失;再次以 X401 请求,应返回原结果或先查询任务当前态。把同一操作改成新操作号再发,可能在任务已经由 uB 转给 uC 之后把它错误地覆盖。操作号必须绑定目标任务与转办目标;同号不同参数应拒绝。

图3:转办先命中 rev=0 后,旧页面携带的修订号已失效,服务端应拒绝其审批提交。
四、列表、详情和提交怎样统一授权
“看得到”和“能提交”属于两个时点。列表可以是缓存或搜索索引,延迟几秒刷新尚可接受;真正写入审批意见的接口必须重新读取当前任务。如果只在列表接口过滤办理人,而详情、附件或办理接口只按 task_id 查任务,就会形成越权通道。
本例的请求校验可以按如下顺序执行:
管理员转办也需要权限:本人能办任务,不自动意味着能把任务转给任意人;平台管理员身份,也不能绕过租户数据边界。系统可以把“允许转办的角色”和“允许作为目标的成员范围”单独配置。转办目标需要实时验证仍在 orgA、账号正常、具备相关岗位或业务授权,而不是输入一个字符串就调用引擎的 setAssignee。如确需跨组织协作,应有明确的跨组织业务流程与授权模型,不应当作普通转办处理。
当 uA 看到旧卡片时,可以在点击后返回“待办已转给 uB,请刷新列表”,这比静默失败更可理解。但错误提示不能暴露 uB 的身份给无权用户。缓存刷新和推送用于缩短过期视图的时间,服务器端权限复核用于阻止过期视图产生错误操作,两者不能互相代替。

图4:当前组织、成员状态、任务归属与修订号必须在执行时重新核对。
五、引擎与业务服务分离时怎样对账
有些系统把待办直接存在业务库;有些系统由流程引擎管理任务,业务系统只保存待办投影。后者最容易出现“引擎已转办,业务列表还显示旧人”。此时不应把业务列表当成最终事实,也不宜直接改引擎数据库表来追平。应保存一条带操作号的转办意图,调用引擎任务 API,按任务 ID 读取引擎当前办理人,然后更新业务投影。响应丢失时按 X401 和 T401 核对状态,再决定确认还是进入人工对账。
| T401 已给 uB | 仍显示 uA | 补写投影、清理相关缓存;旧人提交仍由引擎/服务端拒绝 |
| T401 仍给 uA | 提前显示 uB | 撤销错误投影,检查转办调用是否失败 |
| T401 已完成 | 仍显示待办 | 修复索引与通知,禁止再提交 |
| 同一实例出现两个活动待办 | 不一定是错误 | 先核对模型是否允许并行;不允许时查重复创建来源 |
最后一行需要特别说明:有并行审批设计时,两个活动待办可能是合法结果;不能写一个“同实例待办数必须为 1”的全局约束。本例是单人部门审批节点,因此 T401 在转办前后保持同一个活动任务,完成结果只有一次。上线对账要以当前节点的业务语义决定“预期数”,而不是套用简单计数公式。
六、用 Java 复现旧页面提交和重复请求
下面的 Java 11 程序用内存任务模拟转办、旧页面提交、重复请求和完成操作。它不会模拟 BPMN 引擎事务或真实成员服务,核心目的是验证接口状态契约:转办后原人失权,旧修订号失效,同一操作号重放不生成第二次转办,完成后不能再转办。运行 javac -encoding UTF-8 Stage10TaskTransferExample.java、java Stage10TaskTransferExample,预期最后一行是 TOTAL 15/15 PASS。
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** 教学模型:任务归属、修订号、当前成员身份与重复请求。 */
public class Stage10TaskTransferExample {
static final class Task {
final String tenant, id, instanceId;
String assignee;
int revision;
boolean open = true;
Task(String tenant, String id, String instanceId, String assignee) {
this.tenant = tenant; this.id = id; this.instanceId = instanceId; this.assignee = assignee;
}
}
static final class Service {
final Map<String, Task> tasks = new HashMap<>();
final Map<String, String> operations = new HashMap<>();
final Set<String> activeMembers = new HashSet<>();
int events;
void member(String tenant, String user) { activeMembers.add(tenant + ":" + user); }
boolean canHandle(Task t, String tenant, String user) {
return t.tenant.equals(tenant) && t.open && t.assignee.equals(user)
&& activeMembers.contains(tenant + ":" + user);
}
void transfer(Task t, String tenant, String operator, String target,
int expectedRevision, String operationId) {
if (!t.tenant.equals(tenant) || !activeMembers.contains(tenant + ":" + operator))
throw new IllegalStateException("操作者无效");
String effect = t.id + ":" + target;
String old = operations.get(tenant + ":" + operationId);
if (old != null) {
if (!old.equals(effect)) throw new IllegalStateException("操作号冲突");
return;
}
if (!t.tenant.equals(tenant) || !t.open || t.revision != expectedRevision)
throw new IllegalStateException("待办已变化");
if (!activeMembers.contains(tenant + ":" + operator)
|| !activeMembers.contains(tenant + ":" + target))
throw new IllegalStateException("成员已失效");
if (t.assignee.equals(target)) throw new IllegalStateException("无需转办");
// 示例中的 operator 已获得任务管理授权;真实服务须重新校验操作权限与数据范围。
t.assignee = target;
t.revision++;
operations.put(tenant + ":" + operationId, effect);
events++;
}
void complete(Task t, String tenant, String actor, int expectedRevision, String requestId) {
if (!t.tenant.equals(tenant) || !activeMembers.contains(tenant + ":" + actor))
throw new IllegalStateException("办理人无效");
String effect = t.id + ":complete:" + actor;
String old = operations.get(tenant + ":" + requestId);
if (old != null) {
if (!old.equals(effect)) throw new IllegalStateException("请求号冲突");
return;
}
if (!canHandle(t, tenant, actor) || t.revision != expectedRevision)
throw new IllegalStateException("无权办理或待办已过期");
t.open = false;
t.revision++;
operations.put(tenant + ":" + requestId, effect);
events++;
}
}
static int checks;
static void ok(boolean pass, String label) {
checks++; if (!pass) throw new AssertionError(label);
}
static void rejects(Runnable action, String label) {
boolean rejected = false;
try { action.run(); } catch (IllegalStateException expected) { rejected = true; }
ok(rejected, label);
}
public static void main(String[] args) {
Service s = new Service();
s.member("orgA", "uA"); s.member("orgA", "uB"); s.member("orgA", "manager");
Task t = new Task("orgA", "T401", "I401", "uA");
s.tasks.put(t.id, t);
ok(s.canHandle(t, "orgA", "uA"), "原办理人可见");
ok(!s.canHandle(t, "orgB", "uA"), "跨组织不可见");
rejects(() -> s.transfer(t, "orgB", "manager", "uB", 0, "X401"), "跨组织转办拒绝");
rejects(() -> s.transfer(t, "orgA", "manager", "uC", 0, "X401"), "无效成员拒绝");
s.transfer(t, "orgA", "manager", "uB", 0, "X401");
ok(t.revision == 1 && t.assignee.equals("uB"), "转办推进修订号");
ok(!s.canHandle(t, "orgA", "uA"), "旧办理人失权");
ok(s.canHandle(t, "orgA", "uB"), "新办理人可见");
rejects(() -> s.complete(t, "orgA", "uA", 0, "C401"), "旧页面提交拒绝");
rejects(() -> s.complete(t, "orgA", "uB", 0, "C401"), "过期修订号拒绝");
s.transfer(t, "orgA", "manager", "uB", 0, "X401");
ok(s.events == 1 && t.revision == 1, "同操作号重放不重复转办");
rejects(() -> s.transfer(t, "orgA", "manager", "uA", 1, "X401"), "同操作号不能改目标");
s.complete(t, "orgA", "uB", 1, "C402");
ok(!t.open && t.revision == 2, "新办理人完成一次");
s.complete(t, "orgA", "uB", 1, "C402");
ok(s.events == 2, "重复完成请求不再执行");
rejects(() -> s.complete(t, "orgA", "uA", 0, "C403"), "结束后旧人仍不可办");
rejects(() -> s.transfer(t, "orgA", "manager", "uA", 2, "X402"), "结束后不可转办");
System.out.println("T401: uA -> uB -> COMPLETED; events=2");
System.out.println("TOTAL " + checks + "/" + checks + " PASS");
}
}
预期输出:
T401: uA -> uB -> COMPLETED; events=2
TOTAL 15/15 PASS
events=2 分别代表一次转办与一次合法完成;旧人提交、过期修订号、同号冲突和已完成后转办都没有增加事件。实际系统要把这些条件落实到数据库约束、引擎并发 API 和当前权限判断中。程序中的 operator 已被视为有转办权限,生产服务还必须补上角色与数据范围校验;不能把这个简化示例直接部署。
七、MySQL 约束与上线验证查询
以下 MySQL 8.x 表结构是独立业务侧教学表,展示任务当前态与操作记录。若流程引擎拥有任务表,应通过引擎 API 执行转办和完成,业务表只存投影与对账证据,切勿直接修改引擎内部表。MySQL 的 CHECK 约束需使用会实际执行检查的 8.x 版本;部署前应以目标版本验证。
CREATE TABLE wf_task_view (
tenant_id VARCHAR(32) NOT NULL,
task_id VARCHAR(40) NOT NULL,
instance_id VARCHAR(40) NOT NULL,
business_id VARCHAR(40) NOT NULL,
task_key VARCHAR(64) NOT NULL,
assignee_id VARCHAR(40) NOT NULL,
state_code VARCHAR(20) NOT NULL,
revision_no BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (tenant_id, task_id),
KEY idx_my_open_tasks (tenant_id, assignee_id, state_code, updated_at),
KEY idx_instance_task (tenant_id, instance_id, state_code),
CHECK (state_code IN ('OPEN','COMPLETED','CANCELLED'))
) ENGINE=InnoDB;
CREATE TABLE wf_task_operation (
tenant_id VARCHAR(32) NOT NULL,
operation_id VARCHAR(40) NOT NULL,
task_id VARCHAR(40) NOT NULL,
operation_type VARCHAR(20) NOT NULL,
actor_id VARCHAR(40) NOT NULL,
from_user_id VARCHAR(40),
to_user_id VARCHAR(40),
expected_rev BIGINT NOT NULL,
status_code VARCHAR(20) NOT NULL,
created_at DATETIME(3) NOT NULL,
PRIMARY KEY (tenant_id, operation_id),
KEY idx_task_operations (tenant_id, task_id, created_at),
CONSTRAINT fk_task_operation FOREIGN KEY (tenant_id, task_id)
REFERENCES wf_task_view (tenant_id, task_id),
CHECK (operation_type IN ('TRANSFER','COMPLETE')),
CHECK (status_code IN ('PENDING','DONE','REJECTED','RECONCILE'))
) ENGINE=InnoDB;
同库实现时,转办可在已核对操作者转办权限和目标成员资格之后执行下列条件更新,并在同一事务写入 X401 操作记录。若影响行数不是 1,必须停止,重新读取当前态。唯一的 operation_id 还要与请求参数绑定:重复的 X401 只能重放相同的 T401/uB 效果,不能拿去转别的任务。
START TRANSACTION;
UPDATE wf_task_view
SET assignee_id='uB', revision_no=revision_no+1, updated_at=NOW(3)
WHERE tenant_id='orgA' AND task_id='T401'
AND state_code='OPEN' AND assignee_id='uA' AND revision_no=0;
— 预期 ROW_COUNT()=1;否则 ROLLBACK 并返回冲突。
INSERT INTO wf_task_operation
(tenant_id,operation_id,task_id,operation_type,actor_id,
from_user_id,to_user_id,expected_rev,status_code,created_at)
VALUES ('orgA','X401','T401','TRANSFER','manager',
'uA','uB',0,'DONE',NOW(3));
COMMIT;
— uA 旧页面仍带 rev=0,即使看得见卡片也不能完成:
UPDATE wf_task_view
SET state_code='COMPLETED', revision_no=revision_no+1, updated_at=NOW(3)
WHERE tenant_id='orgA' AND task_id='T401'
AND state_code='OPEN' AND assignee_id='uA' AND revision_no=0;
— 预期 ROW_COUNT()=0。合法完成还应与审批意见/结果记录处于同一事务。
上述示例的 START TRANSACTION 段展示了成功路径;真实服务还要捕获唯一键冲突、按操作号查询已存在结果,并保证失败时回滚。旧人尝试的 UPDATE 是验证用查询,不能当作前端接口给客户端执行。业务单状态 P401=IN_APPROVAL 在该节点转办期间不变;等真正完成节点后,再按流程规则推进下一节点或回写业务状态。
验收时运行以下查询,并同时到流程引擎核对同一 T401 的当前办理人和历史:
SELECT task_id, instance_id, business_id, assignee_id,
state_code, revision_no
FROM wf_task_view
WHERE tenant_id='orgA' AND task_id='T401';
— uB 完成后预期:T401 / I401 / P401 / uB / COMPLETED / 2,仅一行。
SELECT operation_type, actor_id, from_user_id, to_user_id,
operation_id, status_code
FROM wf_task_operation
WHERE tenant_id='orgA' AND task_id='T401'
ORDER BY created_at, operation_id;
— 预期:X401 TRANSFER DONE 一行;C402 COMPLETE DONE 一行。
SELECT assignee_id, COUNT(*) AS open_count
FROM wf_task_view
WHERE tenant_id='orgA' AND instance_id='I401'
AND task_key='dept_approve' AND state_code='OPEN'
GROUP BY assignee_id;
— T401 完成后预期 0 行;转办后、完成前预期仅 uB/1。
若列表仍显示 uA,但任务事实和操作记录已是 uB,优先检查列表索引、缓存和推送时序;若引擎仍是 uA 而业务投影是 uB,要进入对账,不能允许 uB 凭业务投影强行提交。若操作记录出现两条完成事件,则需要沿请求号和任务修订号追查防重缺口,而不是删掉其中一条历史来“修复报表”。

图5:完成后核对 T401、转办与完成操作,以及 P401/I401 的关联;拒绝路径也不能留下审批结果。
八、验收边界与参考资料
上线测试至少覆盖:合法转办、目标成员停用、跨组织转办、原人旧页提交、新人带旧修订号提交、重复 X401、同操作号换目标、转办与完成并发、任务已结束再转办。每个失败样本除了检查接口报错,还要检查没有新增审批意见、没有新增活动任务、业务单未被误推进。对跨服务部署,再让投递或回写故障发生一次,确认对账能从引擎事实恢复业务列表。
本文的结论是:待办允许被缓存展示,但办理权不能被缓存决定。任务归属变更需要有明确语义、当前态竞争和审计证据;执行时重新核对组织、成员、任务和修订号,才能让旧页面和重复请求安全失败。下一篇会继续讨论流程结果已通过、业务单状态却没有及时回写时,如何做回调幂等与状态对账。
参考资料:




