后端复盘(4):阶段结束不等于流程结束,一个 finished 字段为什么不够用
- 本文来自《后端系统设计复盘:从游戏项目到通用后端》专栏。
- 文章内容源于我正在实现的一个 Balatro 风格实时游戏后端项目,但本文不会重点讲某个具体游戏功能怎么写,而是抽出一个更通用的后端设计问题:当一个系统里同时存在“小阶段结束”和“大流程结束”时,为什么不能只用一个状态字段硬扛。
- 这个问题不只存在于游戏后端,在订单系统、审批流程、活动任务、工作流系统里也很常见。很多状态混乱,不是因为代码写错了,而是因为一个字段被迫表达了太多层含义。
⚠️ 比如finished = true,到底表示什么?
- 当前操作完成了?
- 当前阶段结束了?
- 整个流程结束了?
- 只是进入了结算页?
如果这些问题需要靠上下文猜,那这个状态字段就已经开始失控了。
这篇文章想表达的核心观点是:
- 当系统里同时存在“当前阶段结束”和“整体流程结束”时,不能只用一个
gameOver、finished或done来硬扛。- 不同层级的结束,往往代表不同的后续动作:有的要结算,有的要推进,有的要保留状态,有的要清理状态。
- 生命周期拆分的重点,不是多加几个字段,而是让系统知道:当前结束的是哪一层流程,以及接下来应该做什么。
文章目录
- 一、问题是怎么出现的
- 1. 早期只有一层结束状态
- 2. 多阶段流程出现后,问题开始暴露
- 3. 为什么这不是游戏特有问题
- 二、为什么一个 finished 字段不够用
- 1. 它表达不了“阶段结束但流程未结束”
- 2. 代码会开始依赖组合判断
- 3. 前端和服务端都会开始猜语义
- 三、阶段结束和流程结束到底差在哪
- 1. 三种结束不是同一层概念
- 2. 阶段结束只是节点完成,流程结束才是链路完成
- 四、拆分后,状态应该怎么表达
- 1. 用 stageOver 表达当前阶段是否结束
- 2. 用 flowOver 表达整体流程是否结束
- 3. 不要用 finished + result 反推状态层级
- 五、拆分前后,流程判断有什么变化
- 1. 拆分前:一个 gameOver 承载所有结束含义
- 2. 拆分后:先判断阶段,再判断整体流程
- 六、结束之后,哪些状态保留,哪些状态清理
- 1. 阶段胜利:保留跨阶段状态,清理阶段内状态
- 2. 阶段失败:进入整体结束,清理运行时状态
- 3. 每次结束后都要问的几个问题
- 七、通用后端里也很常见
- 1. 订单系统:支付完成不等于订单完成
- 2. 审批系统:一审通过不等于审批完成
- 3. 活动任务:单个任务完成不等于整个活动结束
- 八、状态命名要表达层级
- 1. 太泛的命名会被复用成多种含义
- 2. 字段名要带上生命周期上下文
- 九、不要急着上复杂状态机,但要先分清层级
- 1. 简单流程,一个 status 就够用
- 2. 多层流程出现后,就不能再硬扛
- 3. 要避免两个极端
- 十、本篇小结
一、问题是怎么出现的
- 下面我会用一个游戏里的多阶段流程举例,但这里讨论的不是游戏特有问题,而是很多后端系统都会遇到的“多层生命周期”问题。
- 在订单系统里,它可能是“支付完成不等于订单完成”;在审批系统里,它可能是“一审通过不等于整个审批通过”;在活动系统里,它可能是“单个任务完成不等于整个活动结束”。
为了让这篇文章可以独立阅读,先解释几个后面会出现的词。
| 名词 | 在本文里的含义 | 普通业务里的类比 |
|---|---|---|
Blind | 游戏里的一个阶段关卡 | 一个任务阶段、一个订单节点、一个审批节点 |
small blind/big blind/boss blind | 当前游戏流程中的不同阶段 | 普通任务、关键任务、最终节点 |
ante | 更大的进度层级,可以理解成一组阶段 | 第几轮、第几期、第几个大流程 |
gameOver | 当前游戏整体是否结束 | 订单是否结束、审批是否结束、活动是否结束 |
| 生命周期 | 一个状态从开始、运行、结束到清理的过程 | 订单创建、支付、发货、完成、取消 |
1. 早期只有一层结束状态
在项目早期,流程还比较简单,所以我当时并没有把“结束状态”拆得很细。
那时候系统更像是一个单局流程:用户开始一局游戏,然后出牌、弃牌、补牌、算分,最后判断这一局是赢了还是输了。
在这种复杂度下,一个gameOver或gameStatus确实够用。
比如:gameOver: boolean;
或者:gameStatus: "playing" | "finished";
因为那个时候,流程大概是这样的:开始游戏 -> 用户操作 -> 判断是否结束 -> 结束后返回结算
整个系统只有一层生命周期:
当前这一局是否结束。
2. 多阶段流程出现后,问题开始暴露
但后面继续往下做的时候,流程不再只有一层,“结束”这个词开始出现不同含义。因为系统开始不只是“一局打完就结束”,而是出现了多个阶段。
比如:small blind -> big blind -> boss blind -> next ante
这时候,某一个阶段结束,并不代表整个流程结束。
用户通过当前阶段后,应该进入下一个阶段。只有用户失败,或者整个大流程达到最终条件时,整体流程才真正结束。
也就是说,系统里开始出现了两种“结束”:
- 当前阶段结束
- 整体流程结束
这个时候,如果还只用一个gameOver,就会开始变得很别扭。
因为它已经表达不了真实业务含义了。
3. 为什么这不是游戏特有问题
虽然这个问题是在游戏流程里暴露出来的,但它并不是游戏后端特有的问题。
只要一个系统里存在“节点推进”或“阶段流转”,就很容易出现类似情况:某个小阶段已经完成,但整个业务流程还没有结束。
比如订单系统里,支付完成不等于订单完成;审批系统里,一审通过不等于整个审批通过;活动任务里,单个任务完成也不等于整个活动结束。
二、为什么一个 finished 字段不够用
1. 它表达不了“阶段结束但流程未结束”
这里的gameOver只是当前项目里的字段名。放到更通用的系统里,它可能叫finished、done、completed或processOver。
一开始只有gameOver时,它的含义很清楚:游戏是否结束。
但当系统开始出现阶段推进后,gameOver就开始有点尴尬了。
比如当前阶段通过了,这个时候当前阶段确实结束了,但整场流程并没有结束。
如果把gameOver设成true,前端可能会以为整场游戏结束了;如果把gameOver设成false,又表达不出当前阶段已经结算完成。
这就说明一个问题:
一个状态字段正在被迫表达两层含义。
2. 代码会开始依赖组合判断
这种状态很危险,因为它会让代码开始出现很多奇怪判断。
比如:
if(gameOver&&result==="WIN"){// 其实不是整场结束,只是当前阶段结束}if(gameOver&&result==="LOSE"){// 这次才是真的整场结束}看起来能写,但语义已经开始混了。
真正的问题不是代码能不能跑,而是:
字段名字和业务含义已经对不上了。
3. 前端和服务端都会开始猜语义
如果继续用一个gameOver硬扛,至少会带来三个问题:
| 问题 | 具体表现 |
|---|---|
| 前端不知道展示什么 | gameOver = true到底是阶段结算页、失败页,还是最终结束页 |
| 服务端推进逻辑混乱 | 阶段胜利要推进,阶段失败要清理,但字段表达不出来 |
| 后续扩展越来越痛 | 加奖励、商店、临时效果时,只能继续补判断 |
比如gameOver = true时,前端到底应该展示当前阶段结算页、整场失败页、下一阶段入口,还是最终结束页?
如果还要再看result、reward、nextStage等字段才能猜出来,那前端逻辑就会越来越依赖隐含规则。
服务端自己也会遇到类似问题。当前阶段结束后,有些情况要保留状态,有些情况要清理状态。如果只用一个gameOver,代码里就很容易把“阶段结算”和“整体结束”混在一起。
所以这类字段不是不能继续加判断,而是每次改都很怕影响别的流程。
三、阶段结束和流程结束到底差在哪
1. 三种结束不是同一层概念
我后来才更明确地意识到:
阶段结束和流程结束,是两个不同层级的生命周期。
它们不是同一个概念。
可以先做一个简单对比:
| 生命周期层级 | 表示什么 | 结束后通常做什么 | 典型例子 |
|---|---|---|---|
| 操作结束 | 一次请求或动作处理完成 | 返回本次操作结果,更新局部状态 | 一次出牌、一次提交、一次支付请求 |
| 阶段结束 | 当前小阶段完成或失败 | 阶段结算、准备下一阶段、重置阶段状态 | 当前 Blind 结束、审批节点完成、任务完成 |
| 流程结束 | 整个业务链路完成或失败 | 返回最终结果、清理运行时状态、持久化结果 | 游戏失败、订单完成、审批结束、活动结束 |
2. 阶段结束只是节点完成,流程结束才是链路完成
放回当前项目里,就会更明显:
| 场景 | 当前阶段是否结束 | 整体流程是否结束 | 应该怎么处理 |
|---|---|---|---|
| 当前阶段分数达标 | 是 | 否 | 结算当前阶段,进入下一阶段 |
| 当前阶段失败 | 是 | 是 | 返回失败结果,清理运行时状态 |
| 当前阶段还在进行中 | 否 | 否 | 返回当前状态,继续操作 |
| 最终一个阶段通过 | 是 | 可能是 | 根据规则进入下一大阶段或最终结算 |
也就是说:
阶段结束 = 当前节点完成 流程结束 = 整条链路完成这两个状态有关系,但不能混成一个字段。
如果换到普通业务系统里,也很好理解。
订单里“支付成功”只是支付阶段结束,不代表订单完成;审批里“一审通过”只是当前节点完成,不代表整个审批通过;活动里“任务 A 完成”只是一个任务完成,不代表整个活动结束。
所以生命周期拆分的关键不是字段数量,而是语义层级。
四、拆分后,状态应该怎么表达
1. 用 stageOver 表达当前阶段是否结束
更合理的做法,是把生命周期拆开。
比如:
typeProgress={stageOver:boolean;flowOver:boolean;};放回当前项目里,可以是:
typeProgress={blindOver:boolean;gameOver:boolean;};这里的重点不是字段名,而是语义:stageOver/blindOver:当前阶段是否结束
2. 用 flowOver 表达整体流程是否结束
另一个字段则专门表达整体流程是否已经结束,也就是:flowOver/gameOver表示整体流程是否结束
这样状态表达会清楚很多。
stageOver | flowOver | 含义 | 后续动作 |
|---|---|---|---|
| false | false | 当前阶段继续 | 返回当前状态 |
| true | false | 当前阶段完成,但整体流程继续 | 结算阶段,推进下一阶段 |
| true | true | 当前阶段完成,整体流程结束 | 返回最终结果,清理状态 |
| false | true | 通常不合理 | 需要检查状态设计 |
这里最关键的不是字段名,而是状态层级:
stageOver表达当前阶段是否结束,flowOver表达整体流程是否结束。它们可以有关联,但不应该被混成一个字段。
如果出现:
stageOver = false flowOver = true大多数时候都说明状态设计有问题。因为整体流程都结束了,当前阶段一般也应该已经结束。
3. 不要用 finished + result 反推状态层级
如果只用一个finished,后面很容易写成这样:
typeProgress={finished:boolean;result?:"WIN"|"LOSE";};这时finished = true还不够,还要继续看result才能判断下一步。
| 字段组合 | 可能含义 |
|---|---|
finished = true+WIN | 可能只是当前阶段通过 |
finished = true+LOSE | 可能才是整体流程结束 |
字段本身没有表达清楚状态层级,只能靠组合判断补语义。
更清楚的写法是:
typeProgress={stageOver:boolean;flowOver:boolean;result?:"WIN"|"LOSE";};这样前端和服务端都能直接知道:当前结束的是阶段,还是整个流程。
五、拆分前后,流程判断有什么变化
1. 拆分前:一个 gameOver 承载所有结束含义
拆分之前,流程可能是这样的:
这个结构在单局阶段没问题。
但一旦进入多阶段,它就开始不够用了。
因为gameOver = true可能表示:
- 当前阶段结束
- 整体流程结束
- 当前阶段胜利
- 当前阶段失败
这些含义都挤在一起了。
2. 拆分后:先判断阶段,再判断整体流程
拆分后,流程会变成两层判断。
第一层判断:
当前阶段有没有结束?
第二层判断:
整体流程有没有结束?
可以用一张图表示:
这张图其实就是多层生命周期最核心的思路:
- 操作完成,不代表阶段结束
- 阶段结束,不代表流程结束
- 流程结束,才意味着整个业务实例进入最终状态
阶段结束负责阶段结算,流程结束负责最终收口。两层判断各自负责一层生命周期,不再混在一起。
六、结束之后,哪些状态保留,哪些状态清理
1. 阶段胜利:保留跨阶段状态,清理阶段内状态
拆分生命周期之后,很重要的一点是:
不同层级的结束,应该触发不同的后续动作。
阶段结束时,可能要做:
- 生成当前阶段结算
- 返回当前阶段结果
- 准备下一阶段配置
- 重置当前阶段临时状态
- 保留跨阶段进度
整体流程结束时,可能要做:
- 返回最终结果
- 清理运行时状态
- 释放临时资源
- 持久化最终记录
- 禁止后续继续操作
这两类动作明显不一样。
可以整理成表格:
| 场景 | 阶段是否结束 | 流程是否结束 | 应该保留什么 | 应该清理什么 |
|---|---|---|---|---|
| 阶段未结束 | 否 | 否 | 当前运行时状态 | 通常不清理 |
| 阶段胜利,流程继续 | 是 | 否 | 用户资源、跨阶段进度、后续阶段配置 | 当前阶段分数、阶段内临时状态 |
| 阶段失败,流程结束 | 是 | 是 | 最终结果、日志记录 | 运行时状态、临时效果 |
| 重新初始化 | 是 | 是 | 可选历史记录 | 旧运行时状态 |
比如当前阶段胜利后,是:当前阶段结束 -> 整体流程继续。
这时候不能直接清空所有状态,因为用户还要进入下一阶段。
所以应该保留用户资源、当前进度、后续阶段配置,以及可能跨阶段生效的状态。但当前阶段得分、当前阶段临时计数、本阶段内的临时状态,就可以根据规则重置或清理。
2. 阶段失败:进入整体结束,清理运行时状态
而当前阶段失败后,是:当前阶段结束 -> 整体流程结束。
这时候就可能需要清理运行时状态,禁止后续继续操作,并返回最终失败结果。
所以生命周期设计不是只判断true / false,而是要决定状态的保留和清理策略。
这时候继续保留局内运行时状态,反而可能影响后续重开或恢复逻辑。
3. 每次结束后都要问的几个问题
实际设计时,我会倾向于在每次“结束”后问这几个问题:
| 判断问题 | 目的 |
|---|---|
| 当前结束的是操作、阶段,还是整体流程? | 先确定生命周期层级 |
| 结束后是否还会进入下一阶段? | 判断是否保留跨阶段状态 |
| 哪些状态只在当前阶段有效? | 判断需要清理哪些临时状态 |
| 哪些状态需要带到后续流程? | 判断需要保留哪些长期状态 |
| 前端下一步应该展示什么页面? | 判断返回结构应该带哪些字段 |
| 后续请求是否还允许继续执行? | 判断是否进入最终结束状态 |
七、通用后端里也很常见
这个问题其实不只存在于游戏后端。很多业务系统都有“阶段结束不等于流程结束”的情况。
这些场景的共同点是:某个节点完成了,但整个业务实例还没有结束。如果只用一个finished字段,就很容易把“节点完成”和“流程完成”混在一起。
| 业务场景 | 小阶段结束 | 整体流程结束 | 如果混成一个状态会怎样 |
|---|---|---|---|
| 订单系统 | 支付成功、发货完成 | 订单完成 / 取消 | 支付成功可能被误认为订单结束 |
| 审批系统 | 当前节点通过 | 整个审批通过 / 驳回 | 一审通过可能被误认为流程完成 |
| 活动任务 | 单个任务完成 | 整个活动完成 / 过期 | 任务完成可能被误认为活动结束 |
| 游戏后端 | 当前 Blind 结束 | 整局游戏失败 / 最终完成 | 阶段胜利可能被误认为游戏结束 |
1. 订单系统:支付完成不等于订单完成
订单支付成功,只代表支付阶段结束,并不代表订单整体完成。
更清楚的拆法是:
| 状态 | 含义 |
|---|---|
paymentStatus | 支付是否完成 |
deliveryStatus | 发货是否完成 |
orderStatus | 订单整体是否完成 |
如果只用一个finished,就很容易把“支付成功”和“订单完成”混在一起。
2. 审批系统:一审通过不等于审批完成
一个审批节点结束,不代表整个审批流程结束。
比如:提交申请 -> 一审通过 -> 二审通过 -> 最终通过
一审通过只是当前节点结束,整体审批流程还要继续往下走。
所以这里也应该区分:
| 状态 | 含义 |
|---|---|
currentNodeStatus | 当前审批节点状态 |
approvalStatus | 整体审批流程状态 |
nextNode | 下一审批节点 |
3. 活动任务:单个任务完成不等于整个活动结束
一个任务完成,不代表整个活动结束。
比如:完成任务 A -> 领取奖励 A -> 解锁任务 B -> 完成整个活动。
这里至少有三类状态:
- 单个任务状态
- 奖励领取状态
- 活动整体状态
如果全部只靠一个finished,后面肯定会混乱。
所以本质上,这类问题都可以归纳成一句话:
节点完成,不等于流程完成。
八、状态命名要表达层级
1. 太泛的命名会被复用成多种含义
这次复盘里,我觉得还有一个点挺重要:
状态字段的命名,应该能表达它属于哪一层生命周期。
比如:
gameOver:表达的是整体游戏是否结束。blindOver:表达的是当前Blind阶段是否结束。
如果是通用系统,也可以类似使用taskOver、processOver,或者stageCompleted、flowCompleted。
命名不一定非要固定,但一定要避免一个字段表达多层含义。
可以简单对比一下:
| 不太清楚的命名 | 问题 | 更清楚的命名 |
|---|---|---|
finished | 不知道是任务结束、阶段结束,还是流程结束 | stageFinished/flowFinished |
over | 语义太泛,容易被复用 | taskOver/processOver |
status | 不知道是哪类状态 | paymentStatus/orderStatus |
done | 容易变成万能完成标记 | nodeDone/workflowDone |
2. 字段名要带上生命周期上下文
如果字段名太泛,很容易后面被复用成各种含义。
比如一个finished,刚开始可能表示当前任务结束,后面又被拿来表示整个流程结束,再后面又用来表示奖励已领取。
最后代码里到处都是:
if(finished){// ...}但没人敢确定这个finished到底代表什么。
所以我现在会更倾向于让字段名多一点上下文。
不是为了啰嗦,而是为了减少误解。
九、不要急着上复杂状态机,但要先分清层级
1. 简单流程,一个 status 就够用
这里也需要注意一点。
我不是说一开始就必须上复杂状态机。如果系统还很简单,只有一个开始和结束,那一个status完全够用。
比如:status: "pending" | "finished";没有问题。
2. 多层流程出现后,就不能再硬扛
真正需要拆分生命周期,是在系统开始出现多层流程时。
比如:
- 当前操作结束后,流程还会继续
- 当前阶段结束后,还有下一个阶段
- 某些状态需要跨阶段保留
- 某些状态只在当前阶段有效
- 不同结束原因触发不同处理动作
这时候才需要拆。
如果把具体业务抽掉,我现在会更倾向于这样理解:
| 生命周期层级 | 结束条件示例 | 影响什么 |
|---|---|---|
| 操作生命周期 | 当前请求处理完成 | 本次请求返回成功还是失败 |
| 阶段生命周期 | 当前阶段目标达成或失败 | 是否生成阶段结算、是否进入下一阶段 |
| 整体流程生命周期 | 所有阶段完成或发生最终失败 | 是否清理运行时状态、是否禁止继续操作 |
也就是说,后端在处理请求时,不只是返回“成功 / 失败”,而是要判断:
| 请求结束后要判断的问题 | 对应生命周期 |
|---|---|
| 这次操作是否成功? | 操作生命周期 |
| 当前阶段是否结束? | 阶段生命周期 |
| 整体流程是否结束? | 整体流程生命周期 |
这三个问题都可能有不同答案。
3. 要避免两个极端
所以我的理解是:
不要为了架构而提前设计复杂状态机,但也不要在多层流程已经出现后,还用一个状态字段硬扛。
这里要避免两个极端:
- 一个是系统还很简单时,就提前设计一套复杂状态机;
- 另一个是多层流程已经出现了,还继续用一个
finished或gameOver表达所有结束状态。
真正要做的,是在复杂度出现时及时拆分,而不是一开始就把所有可能性都设计出来。这中间其实是一个平衡。
十、本篇小结
这一篇主要复盘了一个问题:
为什么阶段结束不等于流程结束?
我的理解是:
当系统里出现多层流程时,一个“结束状态”往往不够用。因为当前阶段完成,只代表一个节点结束,不代表整个业务流程结束。
可以把这次复盘得到的结论整理成下面几条:
| 设计结论 | 对应含义 |
|---|---|
| 不是所有“结束”都是同一种结束 | 操作结束、阶段结束、流程结束要分开看 |
| 生命周期拆分的关键是后续动作不同 | 如果结束后处理方式不同,就不该混成一个状态 |
| 状态字段要表达层级 | stageOver比over更清楚 |
| 生命周期设计会影响状态清理 | 清理哪些状态,取决于结束的是哪一层生命周期 |
| 不要过早复杂化 | 简单流程可以简单处理,多层流程出现后再拆 |
如果用一句话总结:
生命周期状态要按层级拆分,不同层级的结束,应该触发不同的后续动作。
在后端设计里,很多状态问题不是字段本身复杂,而是字段背后的生命周期没分清楚。
如果生命周期层级清楚,后面的状态保留、状态清理、流程推进、返回结构,都会更自然。
下一篇会继续复盘:
业务配置归属设计:配置不是常量,放错位置后面会很痛。
🃏关于这个项目
本复盘专栏的内容,来自我正在实现的一个 Balatro 风格实时游戏后端项目。
主线系列《实时游戏后端工程实践:从 Balatro 出发》会记录项目从 0 到 1 的实现过程;而本专栏会从项目里抽出一些更通用的后端设计问题,把具体功能背后的设计取舍讲清楚。
如果你对状态设计、接口拆分、规则系统、奖励系统和游戏后端工程实践感兴趣,也可以顺着主线系列继续看。
