后端复盘(2):从功能接口到状态流转,后端复杂度是怎么出现的

  • 本文来自《后端系统设计复盘:从游戏项目到通用后端》专栏。
  • 文章内容源于我正在实现的一个 Balatro 风格实时游戏后端项目,但本文不会重点讲某个具体游戏功能怎么写,而是抽出一个更通用的后端设计问题:为什么一个后端接口最开始看起来只是功能调用,但做着做着就会变成状态流转。
  • 这个问题不只存在于游戏后端,在电商下单、活动奖励、审批流程、任务系统里也很常见。

这篇文章想表达的核心观点是:

  • 后端接口一开始看起来像是在"执行功能",但只要这个操作会影响后续流程,它就会逐渐变成一次"状态推进"。
  • 状态流转型接口的重点,不只是本次请求算对了没有,而是请求结束后,系统是否进入了正确状态。
  • 后端复杂度不是突然出现的,而是从状态开始互相影响、接口开始推动流程时慢慢出现的。

文章目录

一、这个问题是怎么出现的

  • 下面我会用一次游戏操作举例,但这里讨论的不是游戏特有问题,而是很多后端系统都会遇到的"接口从功能调用变成状态推进"的问题。
  • 在电商系统里,它可能是一次下单;在活动系统里,它可能是一次奖励领取;在审批系统里,它可能是一次提交审批。表面上看只是一个接口,实际却会带动一串状态变化。

在项目早期设计这些接口时,我并不是没有意识到后端接口会维护状态,只是当时更多把它们当一个个具体功能来拆分。

比如有一个功能,就先写一个对应的接口:

  • 发牌:返回一组手牌
  • 出牌:接收用户选择的牌
  • 弃牌:移除用户选择的牌
  • 计分:根据牌型算出分数

这里先简单解释一下几个游戏相关概念,避免读者被游戏名词劝退。

项目里的概念 可以理解成 普通业务里的类比
手牌 当前用户可操作的数据集合 购物车商品、待处理任务、当前审批单
牌堆 后续可补充的数据来源 库存池、任务池、候选资源池
出牌 用户提交一次有效操作 下单、领取、提交审批
弃牌 用户放弃或移除一部分内容 删除商品、取消任务、撤回选择
计分 根据规则计算本次结果 计算金额、发放奖励、判断审批结果

在最早期,每个功能看起来都比较独立,前端调用一下,后端处理一下,然后返回结果。

这种感觉很像:请求进来 -> 执行函数 -> 返回结果

但随着项目往后推进,我慢慢发现事情不是这么简单。因为有些接口执行完之后,并不是"返回结果就结束了",它还会改变后续流程。

比如一次出牌操作,表面看只是:

用户选择一部分当前可操作的数据,后端判断这次操作会产生什么结果。

但真正放到一个持续运行的系统里,它其实会影响很多状态:

  • 当前手牌会减少
  • 牌堆会被继续抽取
  • 剩余出牌次数会减少
  • 当前操作分数会变化
  • 累计分数会变化
  • 当前阶段是否结束也可能变化

也就是说,这个接口已经不只是"算一个结果",而是在推动系统状态往前走。

这时候我才意识到:

后端系统复杂度不是从代码行数开始的,而是从"一个操作会影响后续状态"开始的。


二、功能接口为什么一开始看起来很简单

在项目早期,接口通常都比较像一个独立函数。

比如一个牌型判断接口,输入几张牌,返回一个牌型结果:

json 复制代码
输入:["AH", "KH", "QH", "JH", "10H"]
输出:Royal Flush

这个过程里,服务端并不需要关心太多上下文。

它只需要回答一个问题:

这几张牌是什么牌型?

这种接口的特点很明显。

特点 说明
输入清楚 接口需要什么参数比较明确
输出清楚 返回结果比较稳定
不依赖历史状态 不太关心用户之前做过什么
不影响后续流程 调用完成后,系统状态通常不会推进
测试成本低 相同输入通常得到相同输出

所以它很像一个纯计算函数。

这种功能很好写,也很好测试,因为只要保证:相同输入 -> 得到相同输出,基本就可以了。

但业务系统不可能永远停留在这种状态。

一旦接口开始依赖"当前用户处于什么状态",并且执行之后还会改变这个状态,它就不再只是一个普通的功能接口,而是开始进入状态流转了。


三、从"算结果"到"改状态"

真正让接口复杂起来的,不是参数变多了,而是它开始修改状态了。

比如发牌,如果只是单独看发牌,好像就是:

从牌堆里取几张牌返回给前端。

但如果服务端要维护真实牌堆,那发牌就不是简单地返回几张牌,而是一次状态变更。

它实际做的是:牌堆减少 -> 用户手牌增加 -> 服务端保存新的牌堆状态

也就是说,发牌的本质不是"返回数组",而是:

修改服务端维护的运行时状态。

出牌也是一样。

如果只从接口入参看,出牌可能就是:

json 复制代码
{
  "selectedCards": ["AH", "KH"],
  "action": "play"
}

但服务端真正要做的事情远不止这些。

它要判断:

  • 这些牌是否真的在当前手牌中
  • 当前用户是否还有出牌次数
  • 当前流程是否还在进行中
  • 这次操作是否需要计分
  • 出牌后是否需要补牌
  • 补牌后手牌状态是什么
  • 当前分数是否达到目标
  • 当前流程是否应该结束

这时候,接口已经不再是:输入参数 -> 返回结果

而是变成了:读取当前状态 -> 校验当前操作 -> 执行业务规则 -> 更新运行时状态 -> 判断流程推进 -> 返回最新状态

这就是从功能接口到状态流转的变化:

接口不再只是处理一次请求,而是在读取状态、校验状态、修改状态,并推动系统进入下一个状态。
#mermaid-svg-FP9XeWQFZufq8z8E{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FP9XeWQFZufq8z8E .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FP9XeWQFZufq8z8E .error-icon{fill:#552222;}#mermaid-svg-FP9XeWQFZufq8z8E .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FP9XeWQFZufq8z8E .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FP9XeWQFZufq8z8E .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FP9XeWQFZufq8z8E .marker.cross{stroke:#333333;}#mermaid-svg-FP9XeWQFZufq8z8E svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FP9XeWQFZufq8z8E p{margin:0;}#mermaid-svg-FP9XeWQFZufq8z8E .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster-label text{fill:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster-label span{color:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster-label span p{background-color:transparent;}#mermaid-svg-FP9XeWQFZufq8z8E .label text,#mermaid-svg-FP9XeWQFZufq8z8E span{fill:#333;color:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .node rect,#mermaid-svg-FP9XeWQFZufq8z8E .node circle,#mermaid-svg-FP9XeWQFZufq8z8E .node ellipse,#mermaid-svg-FP9XeWQFZufq8z8E .node polygon,#mermaid-svg-FP9XeWQFZufq8z8E .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FP9XeWQFZufq8z8E .rough-node .label text,#mermaid-svg-FP9XeWQFZufq8z8E .node .label text,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape .label,#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape .label{text-anchor:middle;}#mermaid-svg-FP9XeWQFZufq8z8E .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FP9XeWQFZufq8z8E .rough-node .label,#mermaid-svg-FP9XeWQFZufq8z8E .node .label,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape .label,#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape .label{text-align:center;}#mermaid-svg-FP9XeWQFZufq8z8E .node.clickable{cursor:pointer;}#mermaid-svg-FP9XeWQFZufq8z8E .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FP9XeWQFZufq8z8E .arrowheadPath{fill:#333333;}#mermaid-svg-FP9XeWQFZufq8z8E .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FP9XeWQFZufq8z8E .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FP9XeWQFZufq8z8E .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FP9XeWQFZufq8z8E .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FP9XeWQFZufq8z8E .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FP9XeWQFZufq8z8E .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FP9XeWQFZufq8z8E .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster text{fill:#333;}#mermaid-svg-FP9XeWQFZufq8z8E .cluster span{color:#333;}#mermaid-svg-FP9XeWQFZufq8z8E div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FP9XeWQFZufq8z8E .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FP9XeWQFZufq8z8E rect.text{fill:none;stroke-width:0;}#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape p,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FP9XeWQFZufq8z8E .icon-shape .label rect,#mermaid-svg-FP9XeWQFZufq8z8E .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FP9XeWQFZufq8z8E .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FP9XeWQFZufq8z8E .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FP9XeWQFZufq8z8E :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 状态流转
功能接口
允许
拒绝
开始依赖状态
输入参数
执行计算
返回结果
读取当前状态
判断是否允许
执行业务规则
更新多个状态
返回最新状态
返回失败原因


四、一次操作为什么会牵扯多个状态

一开始按功能拆接口时,很容易把一个接口理解成一个独立动作。

比如:

  • play 就是出牌
  • discard 就是弃牌
  • draw 就是补牌
  • score 就是算分

但实际写下去会发现,这些动作不是完全独立的。

一次出牌,至少包含下面几件事:

选择手牌 -> 校验选择是否合法 -> 移除已选择的牌 -> 从牌堆补牌 -> 计算本次分数 -> 扣除出牌次数 -> 累加总分 -> 判断是否结束 -> 返回最新状态

真正落到服务端状态维护时,这个动作背后会连出一整串状态变化。

它不是一个点,而是一条链路。
#mermaid-svg-Jj7TZI4U2WCVdrjg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Jj7TZI4U2WCVdrjg .error-icon{fill:#552222;}#mermaid-svg-Jj7TZI4U2WCVdrjg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Jj7TZI4U2WCVdrjg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .marker.cross{stroke:#333333;}#mermaid-svg-Jj7TZI4U2WCVdrjg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Jj7TZI4U2WCVdrjg p{margin:0;}#mermaid-svg-Jj7TZI4U2WCVdrjg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster-label text{fill:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster-label span{color:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster-label span p{background-color:transparent;}#mermaid-svg-Jj7TZI4U2WCVdrjg .label text,#mermaid-svg-Jj7TZI4U2WCVdrjg span{fill:#333;color:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .node rect,#mermaid-svg-Jj7TZI4U2WCVdrjg .node circle,#mermaid-svg-Jj7TZI4U2WCVdrjg .node ellipse,#mermaid-svg-Jj7TZI4U2WCVdrjg .node polygon,#mermaid-svg-Jj7TZI4U2WCVdrjg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .rough-node .label text,#mermaid-svg-Jj7TZI4U2WCVdrjg .node .label text,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape .label,#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape .label{text-anchor:middle;}#mermaid-svg-Jj7TZI4U2WCVdrjg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .rough-node .label,#mermaid-svg-Jj7TZI4U2WCVdrjg .node .label,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape .label,#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape .label{text-align:center;}#mermaid-svg-Jj7TZI4U2WCVdrjg .node.clickable{cursor:pointer;}#mermaid-svg-Jj7TZI4U2WCVdrjg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .arrowheadPath{fill:#333333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jj7TZI4U2WCVdrjg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Jj7TZI4U2WCVdrjg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jj7TZI4U2WCVdrjg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster text{fill:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg .cluster span{color:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Jj7TZI4U2WCVdrjg rect.text{fill:none;stroke-width:0;}#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape p,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Jj7TZI4U2WCVdrjg .icon-shape .label rect,#mermaid-svg-Jj7TZI4U2WCVdrjg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jj7TZI4U2WCVdrjg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Jj7TZI4U2WCVdrjg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Jj7TZI4U2WCVdrjg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-Jj7TZI4U2WCVdrjg .action>*{fill:aliceblue!important;stroke:cornflowerblue!important;stroke-width:2px!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .action span{fill:aliceblue!important;stroke:cornflowerblue!important;stroke-width:2px!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .action tspan{fill:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .check>*{fill:cornsilk!important;stroke:orange!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .check span{fill:cornsilk!important;stroke:orange!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .check tspan{fill:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .resource>*{fill:honeydew!important;stroke:mediumseagreen!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .resource span{fill:honeydew!important;stroke:mediumseagreen!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .resource tspan{fill:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flow>*{fill:lavender!important;stroke:mediumpurple!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flow span{fill:lavender!important;stroke:mediumpurple!important;color:black!important;}#mermaid-svg-Jj7TZI4U2WCVdrjg .flow tspan{fill:black!important;} 操作后变化
流程状态变化
阶段是否结束
是否进入结算
资源状态变化
手牌 / 牌堆
次数 / 分数
操作前校验
手牌是否合法
次数是否足够
流程是否进行中
一次用户操作

也就是说,一次用户操作并不是只改变一个字段。这里最容易忽略的是:

每一步都可能改变后面的判断。

比如:

  • 移除手牌后,手牌数量变了
  • 补牌后,牌堆数量变了
  • 扣除次数后,用户是否还能继续操作变了
  • 累加分数后,当前阶段是否达成目标也变了。

所以一次用户操作,实际是在推动多个状态同步变化。

这也是后端复杂度开始上来的地方,因为从这一刻开始,后端要保证的不只是"这次结果算对了",还要保证:

这次操作之后,整个系统状态仍然是对的。


五、功能接口和状态流转的区别

可以先简单对比一下。

对比项 功能接口 状态流转型接口
核心问题 这次请求要返回什么? 这次操作后,系统应该变成什么?
主要关注点 输入、计算、输出 当前状态、操作合法性、状态更新
是否依赖上下文 通常较少 强依赖当前运行状态
是否影响后续流程 通常较弱 通常会影响下一步判断
测试重点 输入输出是否符合预期 状态推进是否正确、边界是否被拦截
常见风险 算错、字段返回错 状态错乱、重复执行、前后端不一致
适合的设计方式 按函数逻辑拆分 按状态生命周期拆分

功能接口更像是:

我问你一个问题,你给我一个答案。

状态流转更像是:

我做了一个动作,你要判断这个动作是否合法,并把系统推进到下一个正确状态。

这两者的思考方式是不一样的。

  • 前者重点是:算得对不对
  • 后者重点是:推进得对不对

六、为什么状态流转会让后端变复杂

因为一旦进入状态流转,后端就要开始处理更多边界情况。

1. 同一个请求,在不同状态下结果不同

比如用户点击"出牌"。

  • 如果当前流程还在进行中,并且还有出牌次数,那这个请求可以执行;
  • 如果当前流程已经结束,或者出牌次数已经用完,那同样的请求就应该被拒绝。

也就是说,接口能不能执行,不只取决于参数,还取决于当前状态。

请求 当前状态 后端应该怎么处理
action = play 游戏进行中,还有出牌次数 允许执行
action = play 游戏已经结束 拒绝执行
action = play 没有出牌次数 拒绝执行
action = play 手牌里没有这些牌 拒绝执行

这时候接口就不能只看入参了,它必须读取服务端当前状态,再决定下一步。

2. 一个状态更新,可能影响多个后续判断

比如一次出牌后,剩余出牌次数减少。

这看起来只是一个数字变化,但它会影响:

  • 用户是否还能继续出牌
  • 当前阶段是否应该结束
  • 结算时是否还有剩余次数奖励
  • 前端按钮是否应该禁用
  • 后续请求是否应该被拦截

所以状态字段不是孤立的。很多字段看起来只是一个值,但它背后连着后续流程判断。

3. 状态没有及时更新,会导致后续流程错误

比如服务端返回给前端的"剩余出牌次数"是新的,但服务端自己保存的运行时状态没有更新,那么下一次请求进来时,服务端读取到的仍然是旧状态。

这样就会出现很麻烦的问题:

不一致的位置 可能结果
前端展示的是新状态,服务端保存的是旧状态 用户看到和实际判断不一致
服务端没有扣减次数 用户可能重复操作
服务端没有更新分数 阶段结束判断可能错误
服务端没有更新流程状态 已结束流程还可能继续执行

所以状态流转里很重要的一点是:

返回给前端的数据,必须和服务端真实保存的状态一致。

否则系统迟早会出现前后不一致的问题。


七、状态流转需要一个清晰的处理顺序

当接口从功能调用变成状态流转后,处理顺序就变得很重要。

如果顺序混乱,代码很容易变成:先改一点状态,再校验一点参数,再算一点结果,中间又补一次状态,最后再判断是否结束。

短期可能能跑,但后续维护会很痛苦。

我现在更倾向把一次状态流转拆成几个固定步骤:

步骤 做什么 为什么重要
读取当前状态 获取服务端可信状态 后续判断不能只依赖前端入参
校验请求是否合法 判断当前状态下能不能执行 避免错误状态下继续推进
执行业务规则 计算本次操作结果 把规则计算集中处理
更新运行时状态 写入本次变化 保证服务端状态真实变化
判断流程是否结束 判断是否进入结算或下一阶段 避免漏结算、漏推进
组装返回结果 返回最新状态快照 保证前端基于服务端状态渲染

用图表示,大概是:
#mermaid-svg-i0XMl4z65WiAv7lu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i0XMl4z65WiAv7lu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i0XMl4z65WiAv7lu .error-icon{fill:#552222;}#mermaid-svg-i0XMl4z65WiAv7lu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i0XMl4z65WiAv7lu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i0XMl4z65WiAv7lu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i0XMl4z65WiAv7lu .marker.cross{stroke:#333333;}#mermaid-svg-i0XMl4z65WiAv7lu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i0XMl4z65WiAv7lu p{margin:0;}#mermaid-svg-i0XMl4z65WiAv7lu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster-label text{fill:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster-label span{color:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster-label span p{background-color:transparent;}#mermaid-svg-i0XMl4z65WiAv7lu .label text,#mermaid-svg-i0XMl4z65WiAv7lu span{fill:#333;color:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .node rect,#mermaid-svg-i0XMl4z65WiAv7lu .node circle,#mermaid-svg-i0XMl4z65WiAv7lu .node ellipse,#mermaid-svg-i0XMl4z65WiAv7lu .node polygon,#mermaid-svg-i0XMl4z65WiAv7lu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-i0XMl4z65WiAv7lu .rough-node .label text,#mermaid-svg-i0XMl4z65WiAv7lu .node .label text,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape .label,#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape .label{text-anchor:middle;}#mermaid-svg-i0XMl4z65WiAv7lu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-i0XMl4z65WiAv7lu .rough-node .label,#mermaid-svg-i0XMl4z65WiAv7lu .node .label,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape .label,#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape .label{text-align:center;}#mermaid-svg-i0XMl4z65WiAv7lu .node.clickable{cursor:pointer;}#mermaid-svg-i0XMl4z65WiAv7lu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-i0XMl4z65WiAv7lu .arrowheadPath{fill:#333333;}#mermaid-svg-i0XMl4z65WiAv7lu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-i0XMl4z65WiAv7lu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-i0XMl4z65WiAv7lu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-i0XMl4z65WiAv7lu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-i0XMl4z65WiAv7lu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-i0XMl4z65WiAv7lu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-i0XMl4z65WiAv7lu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster text{fill:#333;}#mermaid-svg-i0XMl4z65WiAv7lu .cluster span{color:#333;}#mermaid-svg-i0XMl4z65WiAv7lu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-i0XMl4z65WiAv7lu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-i0XMl4z65WiAv7lu rect.text{fill:none;stroke-width:0;}#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape p,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-i0XMl4z65WiAv7lu .icon-shape .label rect,#mermaid-svg-i0XMl4z65WiAv7lu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-i0XMl4z65WiAv7lu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-i0XMl4z65WiAv7lu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-i0XMl4z65WiAv7lu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段三:返回结果
阶段二:推进状态
阶段一:进入接口
不通过
通过


读取当前状态
校验请求
执行业务规则
更新运行时状态
流程是否结束
返回最新状态
返回结算结果
返回错误

不修改状态

这个顺序的好处是:

  • 校验失败时,不修改状态
  • 状态更新集中发生
  • 流程结束判断有明确位置
  • 返回结果来自最新状态
  • 后续扩展时更容易插入新逻辑

这也是我后来写很多接口时慢慢形成的习惯:

先不要急着写具体逻辑,先把状态推进顺序想清楚。


八、状态流转里,返回结果不只是"提示成功"

在简单接口里,返回结果可能只是:

json 复制代码
{
  "code": 200,
  "message": "success"
}

但在状态流转型接口里,这通常是不够的。

因为前端不只是需要知道"成功了",还需要知道:

成功之后,当前系统状态变成什么了。

比如一次出牌成功后,前端可能需要:

  • 当前最新手牌
  • 剩余出牌次数
  • 剩余弃牌次数
  • 当前得分
  • 累计分数
  • 当前阶段是否结束
  • 如果结束,结算结果是什么

所以状态流转型接口的返回值,往往不是一个简单提示,而是一份"最新状态快照"。

返回内容 为什么需要
最新手牌 前端需要重新渲染用户可操作内容
剩余次数 前端需要判断按钮是否可用
当前得分 前端需要展示本次操作结果
累计分数 前端需要展示阶段进度
阶段是否结束 前端需要切换页面或展示结算
结算结果 前端需要展示奖励、失败原因或下一步操作

这也是为什么前端不应该自己猜状态。

比如前端不能自己认为:

我刚才出了 2 张牌,所以我本地从手牌里删掉 2 张,再自己补 2 张就好了。

更稳的方式是:前端提交操作 -> 服务端处理并更新状态 -> 服务端返回最新手牌和状态 -> 前端按服务端返回重新渲染

这里其实也是一个很重要的后端设计原则:

前端可以负责展示和交互,但关键业务状态最好由服务端统一计算、保存和返回。


九、从功能接口到状态流转的通用场景

这个问题不只存在于游戏后端。很多普通后端系统也是这样慢慢变复杂的。

场景 表面上的接口 实际上的状态流转
电商下单 创建订单 校验商品、锁库存、算金额、进入待支付
活动奖励 点击领取 校验资格、检查库存、防重复领取、发放奖励
审批流程 提交审批 校验权限、推进节点、流转审批人、更新单据状态
任务系统 完成任务 校验完成条件、更新进度、发放奖励、解锁下一任务
游戏操作 出牌 / 弃牌 校验手牌、扣次数、更新分数、判断阶段结束

1. 电商下单

早期看,下单接口可能只是:

提交商品 ID 和数量 -> 生成订单

但真正进入状态流转后,它会变成:

读取库存 -> 校验商品是否可售 -> 校验优惠券是否可用 -> 锁定库存 -> 计算订单金额 -> 生成订单 -> 等待支付

这里的复杂度不只是"创建订单"本身,而是订单状态、库存状态、优惠券状态、支付状态之间开始产生关系。

2. 活动奖励领取

早期看,领取奖励就是:

用户点击领取 -> 发放奖励

但真正做起来会发现要判断:用户是否完成任务活动是否还有效奖励是否已经领取库存是否充足是否满足领取次数限制以及发放失败时是否需要回滚状态

这也不是一个简单接口,而是一条状态流转链路。

3. 审批流程

提交审批看起来也只是一个接口。

但它背后可能涉及:当前单据状态是否可提交当前用户是否有权限下一审批人是谁是否需要多级审批审批通过后是否进入下一阶段以及驳回后状态回到哪里

所以审批系统的核心也不是"提交按钮",而是状态推进。


十、我现在对后端复杂度的理解

1. 复杂度不是来自代码多,而是来自状态互相影响

以前我会觉得,后端复杂度可能主要来自代码量多、逻辑分支多、表结构复杂、接口数量多。

这些当然也会带来复杂度。

但现在做这个项目后,我会更明显地感觉到:

真正让后端系统变复杂的,是状态之间开始互相影响。

当一个接口只是单纯计算时,它很容易控制;但当一个接口会修改状态,并且这个状态会影响后续流程时,系统复杂度就开始出现了。

比如:

出牌影响手牌 -> 手牌影响下一次操作 -> 下一次操作影响得分 -> 得分影响是否结算 -> 结算影响是否进入下一阶段 -> 阶段影响下一次初始化

这些东西一串起来,系统就不再是几个函数的组合,而是一个持续推进的状态网络。

所以后端设计里真正需要想清楚的是:

  • 当前状态是什么
  • 这个操作能不能执行
  • 执行后哪些状态会变化
  • 变化后是否触发下一阶段
  • 返回给前端的状态是否和服务端一致

这些问题想清楚了,代码通常不会太离谱。但如果这些没想清楚,代码即使能跑,后面也很容易重构。

2. 状态流转型接口设计检查表

所以我现在再设计这类接口时,会尽量先问自己下面几个问题:

检查项 要问的问题 如果没想清楚,可能出现的问题
当前状态 当前用户 / 对象处于什么状态? 同一个请求在错误状态下被执行
操作合法性 这个状态下能不能执行这个操作? 越权操作、重复操作、流程错乱
状态变化 执行后哪些字段会被修改? 只改了一部分状态,导致数据不一致
后续影响 这些变化会不会触发下一阶段? 该结算没结算,该结束没结束
返回结果 前端拿到的是不是最新状态? 前端展示和服务端真实状态不一致
异常处理 中间失败时状态要不要回滚? 部分成功、部分失败,留下脏状态

这个表不一定能覆盖所有情况,但至少能提醒自己:

状态流转型接口,重点不是"这次有没有成功",而是"成功之后系统是否进入了正确状态"。


十一、这次复盘得到的设计结论

这次从功能接口到状态流转的过程,让我得到几个比较明确的结论。

设计结论 对应含义
后端接口不只是函数入口 很多接口表面是一个动作,实际上是一次状态推进
状态流转要先于代码实现 先想清楚推进顺序,再写代码会更稳
前端不应该推导关键状态 关键业务状态最好由服务端统一计算和返回
返回结果应该表达最新状态 状态流转型接口返回的不是成功提示,而是处理后的状态快照
复杂度来自状态关系 一个状态变化影响另一个状态时,系统复杂度就开始出现

1. 后端接口不只是函数入口

很多接口表面看是一个动作,但实际上可能是一次状态推进。

所以设计接口时,不应该只问:

这个接口接收什么参数,返回什么结果?

还应该问:

这个接口执行后,系统状态发生了什么变化?

2. 状态流转要先于代码实现

如果一个接口会影响多个状态,最好先把状态流转顺序画出来。

比如:读取状态 -> 校验 -> 执行规则 -> 更新状态 -> 判断结束 -> 返回结果

先有这个顺序,再写代码,会比边写边补判断稳定很多。

3. 前端不应该推导关键状态

前端可以做展示,也可以做交互反馈。

但关键业务状态,比如次数、分数、进度、奖励、权限,最好都由服务端返回。否则前后端很容易各算一套,最后状态对不上。

4. 返回结果应该表达最新状态

状态流转型接口返回的不是"成功提示",而是处理后的最新状态。

这样前端才能基于服务端状态重新渲染,而不是自己猜。


十二、本篇小结

这一篇主要复盘了一个问题:

为什么后端接口做着做着,会从功能调用变成状态流转?

我的理解是:

只要一个操作会影响后续流程,它就不再是简单功能接口,而是一次状态推进。

所以后端复杂度不是突然出现的,它往往是在这些时刻慢慢出现的:

  • 一个接口开始依赖当前状态
  • 一个操作开始修改多个字段
  • 一个状态变化开始影响后续判断
  • 一个返回结果开始承载最新状态
  • 一个流程开始需要判断是否进入下一阶段

如果用一句话总结:

功能接口关注本次返回什么,状态流转关注系统接下来变成什么。

这也是后续继续做状态拆分、生命周期设计、接口职责收敛和返回结构分层时,很重要的基础。

下一篇会继续复盘:

聚合状态拆分设计:如何避免一个对象变成状态垃圾桶。


🃏 关于这个项目

本复盘专栏的内容,来自我正在实现的一个 Balatro 风格实时游戏后端项目。

主线系列 《实时游戏后端工程实践:从 Balatro 出发》 会记录项目从 0 到 1 的实现过程;而本专栏会从项目里抽出一些更通用的后端设计问题,把具体功能背后的设计取舍讲清楚。

如果你对状态设计、接口拆分、规则系统、奖励系统和游戏后端工程实践感兴趣,也可以顺着主线系列继续看。