本章目录
第 4 章
用户填到一半跑了怎么办
给签到 bot 加个「兑换奖品」——先问换什么,再问确认。这一问一答之间,程序怎么知道用户回的是哪个问题?
一个新需求,把老写法逼到头了
签到 bot 攒了积分,总得能花。加个兑换功能:
用户: /redeem机器人:兑换什么?贴纸(30) / 会员一天(100) / 定制头像(500)用户: 贴纸机器人:确认用 30 分换「贴纸」?回 是 或 否用户: 是机器人:兑换成功,剩余 470 分
看着简单,但这里有个第 3 章的写法根本处理不了的问题:
第 3 章那四个 handler 全都是绑在命令上的(/checkin、/me、/rank)。命令自带标识,一看就知道该谁处理。但「贴纸」是一句普通的话,它的含义取决于上一句问了什么。
最容易想到的办法,和它的问题
大部分人第一反应是拿个字典记着谁走到哪一步了:
user_step = {} # {用户id: "正在选商品"}user_item = {} # {用户id: "贴纸"}@router.message()async def any_message(message: Message): step = user_step.get(message.from_user.id) if step == "正在选商品": ... elif step == "正在确认": ...
它确实能跑。真正的问题不在功能,在它会怎么烂掉:
| 问题 | 会怎样 |
|---|---|
| 重启就没了 | 你改一行代码重启,所有正在兑换的人都卡住 —— 他们回「是」,程序完全不知道在说什么。 |
| 状态和数据分家 | user_step 和 user_item 两个字典要同步维护。再加个流程就是第三、第四个字典。 |
| 忘记清理 | 用户选到一半跑了,他的记录永远留在字典里。下次他发任何一句话,都会被当成在兑换。 |
| 每加一个流程,判断就长一截 | 那个 if/elif 会膨胀成几百行,而且拦截了所有消息 —— 以后加别的功能全得从它手里绕。 |
把「现在在第几步」变成正经东西
这套东西有个名字叫状态机(FSM)。听着唬人,其实就三件事:
| 有哪些状态 | 「正在选商品」「正在确认」—— 提前声明清楚,不是随手写字符串 |
| 当前在哪个 | 每个用户各有各的,框架帮你存 |
| 怎么转移 | 选完商品 → 进确认;确认完 → 清空回到平常 |
好处是 aiogram 内置了这套,而且 handler 可以直接按状态过滤 —— 你不用再写那个吃掉所有消息的大 if。
先声明状态
新建 redeem.py:
from aiogram import Router, Ffrom aiogram.filters import Command, StateFilterfrom aiogram.fsm.context import FSMContextfrom aiogram.fsm.state import State, StatesGroupfrom aiogram.types import Messageimport storageREWARDS = { "贴纸": 30, "会员一天": 100, "定制头像": 500,}router = Router()class Redeem(StatesGroup): """兑换流程的两步。""" choosing = State() # 等他说要换什么 confirming = State() # 等他确认
StatesGroup 里每个 State() 就是一步。声明出来的好处是写错名字会立刻报错,不像手写字符串——打错一个字,程序不吭声,用户卡死。
三个 handler,对应三步
入口:检查够不够分,然后进入第一个状态。
@router.message(Command("redeem"))async def redeem_start(message: Message, state: FSMContext) -> None: if message.from_user is None: return row = storage.get_user(message.from_user.id) points = row[1] if row else 0 cheapest = min(REWARDS.values()) # 进流程之前先挡住不可能成功的情况,别让人白填一遍 if points < cheapest: await message.answer( f"你只有 {points} 分,最便宜的要 {cheapest} 分。\n" f"发 /checkin 多签几天。" ) return menu = "\n".join(f" {k}({v} 分)" for k, v in REWARDS.items()) await state.set_state(Redeem.choosing) await message.answer( f"你有 {points} 分。兑换什么?直接回名字:\n{menu}\n\n" f"不想换了发 /cancel" )
第二步。注意 @router.message(Redeem.choosing) —— 这个 handler 只在用户处于 choosing 状态时才会被调用,其他时候完全不碰。这就是不用写大 if 的原因。
@router.message(Redeem.choosing)async def redeem_choose(message: Message, state: FSMContext) -> None: item = (message.text or "").strip() if item not in REWARDS: await message.answer("没有这个。照着上面的名字回一个,或者 /cancel") return # 状态不变,他可以重说一次 cost = REWARDS[item] row = storage.get_user(message.from_user.id) points = row[1] if row else 0 if cost > points: await message.answer(f"「{item}」要 {cost} 分,你只有 {points} 分。换一个?") return await state.update_data(item=item, cost=cost) # 存进这个用户的流程数据 await state.set_state(Redeem.confirming) await message.answer(f"确认用 {cost} 分换「{item}」?回 是 或 否")
第三步,扣分收尾:
@router.message(Redeem.confirming)async def redeem_confirm(message: Message, state: FSMContext) -> None: answer = (message.text or "").strip() if answer not in {"是", "否"}: await message.answer("回 是 或 否") return if answer == "否": await state.clear() await message.answer("那就算了") return data = await state.get_data() await state.clear() # 先清状态:扣分失败也不能让他卡在流程里 left = storage.spend(message.from_user.id, data["cost"]) if left is None: await message.answer("分不够了,可能刚才在别处花掉了。再来一次吧") return await message.answer(f"兑换成功:{data['item']}\n剩余 {left} 分")
顺序反过来的话,扣分那步一旦抛异常,clear() 就执行不到,用户永远卡在 confirming 状态 —— 之后他说什么都会被当成在回答「是或否」。
清状态要早,别指望后面的代码一定跑得到。
扣分函数:让数据库保证不透支
storage.py 里加一个。这里又用到第 3 章那个手法 —— 把条件写进 SQL,而不是先查再改:
def spend(user_id: int, cost: int) -> int | None: """扣分。成功返回剩余分;分不够返回 None。""" with connect() as conn: cur = conn.execute( "UPDATE users SET points = points - ? " "WHERE user_id = ? AND points >= ?", # ← 关键在这个条件 (cost, user_id, cost), ) if cur.rowcount == 0: return None # 一行都没改到 = 分不够 return conn.execute( "SELECT points FROM users WHERE user_id = ?", (user_id,) ).fetchone()[0]
AND points >= ? 这个条件让数据库自己判断够不够。改到 0 行就说明分不够,余额永远不会变成负数,哪怕用户在两个设备上同时点确认。
100 分的账户,10 个线程同时各扣 30 —— 成功 3 次,余额剩 10 分。换成「先查够不够、再扣」的写法,同样的测试会把余额扣成负数。
第 6 章会把这类问题讲透。
/cancel 不是可选的
只要有多步流程,就必须给用户一条退出的路。而且它得在任何状态下都管用:
@router.message(Command("cancel"), StateFilter("*"))async def cancel_any(message: Message, state: FSMContext) -> None: if await state.get_state() is None: await message.answer("你没在做什么需要取消的事") return await state.clear() await message.answer("已取消")
StateFilter("*") 的意思是「不管在哪个状态」。这个 handler 要注册在其他状态 handler 之前,否则 /cancel 会先被 Redeem.choosing 接走,当成商品名处理。
挂上去
bot.py 改两行:
from aiogram.fsm.storage.memory import MemoryStorageimport redeemfrom handlers import router as main_router... dp = Dispatcher(storage=MemoryStorage()) dp.include_router(redeem.router) # 先挂它,/cancel 才拦得住 dp.include_router(main_router)
顺手在 /start 的说明里加一行 /redeem。
跑一遍,把这几条都试到
多步流程的 bug 大多藏在不按套路走的路径上,所以别只测正常流程:
| 你做什么 | 应该看到 |
|---|---|
| 正常走完一遍 | 兑换成功,积分扣掉了 |
| 选商品那步回一句「你好」 | 「没有这个」,还停在选商品,能重说 |
| 选一个买不起的 | 提示分不够,让你换一个 |
| 确认那步回「否」 | 「那就算了」,分没扣 |
中途发 /cancel | 已取消。之后发 /me 能正常用 —— 这条最重要,验证状态真的清了 |
没在流程里发 /cancel | 「你没在做什么需要取消的事」 |
| 兑换到一半,Ctrl+C 重启,再回「是」 | 机器人没反应 ← 这是下一章的题目 |
/me 不好使),说明状态没清干净 —— 检查是不是漏了 clear()。最后一条测试暴露的问题
重启之后,正在兑换的用户就卡住了:他回「是」,机器人当没听见。
因为 MemoryStorage 就是字面意思 —— 状态存在内存里,进程一没就全丢了。我们绕开了自己写字典的那些毛病,但没绕开这一条。
这不是 bug,是这一章特意留的口子:先让流程跑起来,再解决存哪儿的问题。下一章就干这件事,顺便把第 3 章欠的时区问题一起还了。
这一章的要点
- 命令自带标识,普通消息没有 —— 一句「贴纸」是什么意思,取决于上一句问了什么,这就是需要状态机的原因
- handler 按状态过滤,不用写吃掉所有消息的大 if
- 清状态要早,别放在可能抛异常的代码后面
- /cancel 必须有,而且要
StateFilter("*")并且先注册 - 扣分的条件写进 SQL(
AND points >= ?),让数据库保证不透支
给兑换加一条记录:新建一张 redeems 表,记下谁、换了什么、什么时候、花了多少分,再加个 /history 命令看自己的兑换记录。
提示:扣分和写记录要在同一个事务里,否则可能扣了分没记上。这个话题第 6 章展开。
多步对话是第一个真正有点绕的东西。哪一步没讲明白,或者你的流程遇到别的情况,都可以说。留不留联系方式都行。