背景:一个抽象场景
在任务流系统里,资源通常会经历“候选、预占、执行、扣减、释放”几个阶段。比如一个现场操作系统中,移动端先扫描资源,后端把资源挂到某个任务下,执行过程中再根据实际使用情况扣减数量。等任务进入结束流程时,系统还需要判断哪些资源已经真正参与执行,哪些只是被提前预占但没有使用。
这个问题看起来像是一个普通的状态判断,实际容易变成数据一致性问题:如果未执行的资源被当作已执行资源参与扣减,库存会被多扣;如果结束时没有释放它们,后续任务又会认为资源仍被占用;如果释放逻辑过宽,已经上机或已经产生执行痕迹的资源又可能被误删。
这次实践可以抽象成一个问题:终态流程里,如何让“可扣减”和“可释放”都只作用在正确的资源集合上?
问题拆解
1. 预占不等于已执行
很多流程会在执行前做资源预占。预占的价值是提前锁定可用资源,避免多个任务同时抢同一个对象。但预占本身不代表资源已经被实际消耗。
如果终态结算时只看“资源是否关联到任务”,就会把两类完全不同的资源混在一起:
- 已执行资源:已经进入现场操作、产生使用痕迹,可以参与扣减。
- 未执行资源:只是被提前锁定,没有真正参与操作,应当在任务结束时释放。
这就是终态流程里最容易犯的错:把关联关系当作执行事实。
2. 前端提示不能替代后端规则
移动端需要在提交前给用户明确提示。例如未执行资源不会参与扣减,继续结束会释放占用;已执行资源数量不足时,需要提示可能扣减不足。这些提示可以减少误操作,但它们只能作为用户体验层面的防线。
真正的规则必须放在后端。因为终态提交可能来自不同入口,页面展示的数据也可能因为分页、缓存、弱网或重复点击而滞后。后端必须重新计算资源集合,并用同一套条件决定:
- 哪些资源允许参与扣减。
- 哪些资源需要释放占用。
- 哪些资源已经有执行痕迹,不能被清理。
3. 释放逻辑要比扣减逻辑更谨慎
扣减的核心是“只扣真正使用过的资源”;释放的核心则是“只释放没有使用过、也没有进入执行位置的资源”。后者更危险,因为释放通常会修改占用关系,甚至删除临时分配记录。
所以释放条件不应该只判断一个状态位,而要组合多个事实:
- 资源属于非首批或补充类资源。
- 资源没有被标记为已使用。
- 资源没有扣减数量。
- 资源没有执行痕迹。
- 资源当前不在执行位置。
少一个条件,都可能造成误释放。
方案设计
后端:用同一语义切分资源集合
终态扣减前,先定义一个稳定的判断函数,用来描述“资源是否已经进入可扣减集合”。这个函数不关心页面从哪里来,只关心资源自己的事实状态。
private boolean canDeduct(ResourceAllocation allocation) { if (allocation == null) { return false; } if (allocation.isInitialResource()) { return true; } return allocation.isExecuted();}扣减流程只遍历 canDeduct 返回 true 的资源。这样可以避免未执行资源被数量结算逻辑误吃掉。
释放流程则使用另一个更严格的判断函数:
private boolean canReleaseUnused(ResourceAllocation allocation) { if (allocation == null) { return false; } return allocation.isSupplementResource() && !allocation.isExecuted() && allocation.deductedQty().signum() <= 0 && !allocation.hasExecutionTrace() && !allocation.isAtExecutionPosition();}这两个函数看起来相似,但职责不同:canDeduct 是扣减白名单,canReleaseUnused 是释放白名单。不要为了少写代码把它们合并成一个模糊判断,否则后续维护者很难知道某个条件到底是为了扣减正确,还是为了释放安全。
移动端:把风险说清楚,而不是替用户做决定
移动端在提交前应把资源拆成“已执行”和“未执行”两类展示。对于未执行资源,提示重点不是“有异常”,而是“它不会参与扣减,结束后会释放占用”。
const usedQty = allocations .filter((item) => item.kind === "initial" || item.executed) .reduce((sum, item) => sum + item.availableQty, 0)
const unusedItems = allocations.filter( (item) => item.kind === "supplement" && !item.executed,)
if (unusedItems.length > 0) { warnings.push("存在未执行资源,本次不会扣减,提交后将释放占用")}这里的关键不是前端算得多精确,而是提示语要和后端真实规则一致。前端提示“不会扣减”,后端就必须真的排除;前端提示“会释放”,后端就必须只释放满足安全条件的资源。
协作边界:页面负责可见性,服务端负责权威性
一个可复用的边界是:
- 页面负责展示风险、减少误点、给出可理解的状态标签。
- 服务端负责重新查询、重新判断、事务内扣减与释放。
- 日志负责记录释放了哪些临时占用,便于之后排查。
flowchart LR A["加载任务资源"] --> B["区分已执行/未执行"] B --> C["移动端展示风险提示"] C --> D["提交终态确认"] D --> E["后端重新计算可扣减集合"] E --> F["扣减已执行资源"] F --> G["释放未执行预占资源"] G --> H["刷新任务终态"]这条链路的重点是“后端重新计算”。移动端可以帮助用户看见风险,但不能成为状态正确性的唯一来源。
关键实现示例
扣减时跳过未执行资源
终态扣减通常会按分配顺序依次消耗资源。如果不提前过滤,未执行资源可能排在已执行资源之前,导致数量被错误扣掉。
for (ResourceAllocation allocation : orderedAllocations) { if (!canDeduct(allocation)) { continue; }
BigDecimal deductQty = allocation.availableQty().min(remainingQty); inventoryService.deduct(allocation.resourceId(), deductQty); allocation.markDeducted(deductQty);
remainingQty = remainingQty.subtract(deductQty); if (remainingQty.signum() <= 0) { break; }}这段逻辑里,continue 比事后修正更重要。因为一旦扣减流水、库存数量、任务进度已经写入,再补偿会牵涉更多副作用。
释放时用白名单条件
释放未执行资源可以放在扣减之后、终态更新之前。这样可以确保只有没有扣减结果的预占资源会被清理。
List<ResourceAllocation> releasable = allocations.stream() .filter(this::canReleaseUnused) .toList();
if (!releasable.isEmpty()) { occupationService.release(releasable); allocationRepository.deleteTemporary(releasable); log.info("released unused allocations, taskId={}, count={}", taskId, releasable.size());}这里要避免黑名单式写法,例如“不是 A 状态就释放”。终态流程通常会不断新增状态,黑名单很容易漏掉新状态;白名单则要求每个释放条件都被明确确认。
常见坑
把历史占用当成当前占用
流程系统里常见一种历史残留:某个辅助记录没有闭环,查询时仍能看到“活跃占用”。如果终态流程完全相信这条历史记录,就可能拦截本应允许的操作。
更稳的做法是先确认这类记录是否仍能代表当前事实。如果它只是历史辅助状态,就不应该直接作为强拦截条件;真正的强拦截应该来自资源当前位置、执行痕迹、当前任务关系等更权威的数据。
清理自动生成记录时没有排除执行中资源
有些资源关系是系统自动生成的,理论上可以回收。但“自动生成”不等于“一定安全”。一旦资源已经进入执行位置,或者已经产生操作痕迹,就必须从自动清理集合中排除。
前后端口径不一致
如果前端提示“未执行资源不扣减”,但后端仍把它算进可扣数量,用户会被提示误导;如果后端会释放,而前端完全不展示,用户又会觉得资源突然消失。终态流程的提示文案、数量计算和后端条件必须使用同一套语义。
可复用经验
- 不要用“是否有关联记录”代替“是否已经执行”。
- 扣减集合和释放集合要分别建模,两个判断函数不要混用。
- 释放逻辑优先使用白名单,条件宁可多确认,也不要依赖模糊状态。
- 移动端提示要和后端真实规则一致,但不能替代后端权威校验。
- 对历史辅助状态保持警惕,能强拦截流程的状态必须代表当前事实。
- 对会删除或释放占用的动作记录日志,方便定位误清理和重复释放问题。
总结
终态流程的难点不在“结束任务”这一个动作,而在结束动作背后要同时完成扣减、释放、清理、同步和提示。只要资源存在预占阶段,就必须把“预占”和“已执行”拆开看。
一个可靠的设计是:扣减只看已执行集合,释放只看严格白名单,移动端负责把风险说清楚,服务端在事务里重新计算并落库。这样既能避免未执行资源被误扣,也能避免任务结束后留下无效占用,让资源生命周期真正闭环。