botfromzero
本章目录
    Telegram 机器人实战 第 4 部分 · 会让你翻车的地方

    第 4 章

    用户填到一半跑了怎么办

    给签到 bot 加个「兑换奖品」——先问换什么,再问确认。这一问一答之间,程序怎么知道用户回的是哪个问题?

    约 20 分钟多步对话aiogram 3.x · 最后验证 2026-08
    01

    一个新需求,把老写法逼到头了

    签到 bot 攒了积分,总得能花。加个兑换功能:

    你想要的对话
    用户: /redeem机器人:兑换什么?贴纸(30) / 会员一天(100) / 定制头像(500)用户: 贴纸机器人:确认用 30 分换「贴纸」?回 是 或 否用户: 是机器人:兑换成功,剩余 470 分

    看着简单,但这里有个第 3 章的写法根本处理不了的问题:

    用户发来「贴纸」这两个字,程序怎么知道他是在回答「兑换什么」,而不是随口在群里说话?

    第 3 章那四个 handler 全都是绑在命令上的(/checkin/me/rank)。命令自带标识,一看就知道该谁处理。但「贴纸」是一句普通的话,它的含义取决于上一句问了什么

    02

    最容易想到的办法,和它的问题

    大部分人第一反应是拿个字典记着谁走到哪一步了:

    能跑,但别这么写
    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_stepuser_item 两个字典要同步维护。再加个流程就是第三、第四个字典。
    忘记清理用户选到一半跑了,他的记录永远留在字典里。下次他发任何一句话,都会被当成在兑换。
    每加一个流程,判断就长一截那个 if/elif 会膨胀成几百行,而且拦截了所有消息 —— 以后加别的功能全得从它手里绕。
    这四条里,「忘记清理」是最阴的:它不报错,只是让用户莫名其妙地进不了正常对话。
    03

    把「现在在第几步」变成正经东西

    这套东西有个名字叫状态机(FSM)。听着唬人,其实就三件事:

    有哪些状态「正在选商品」「正在确认」—— 提前声明清楚,不是随手写字符串
    当前在哪个每个用户各有各的,框架帮你存
    怎么转移选完商品 → 进确认;确认完 → 清空回到平常

    好处是 aiogram 内置了这套,而且 handler 可以直接按状态过滤 —— 你不用再写那个吃掉所有消息的大 if

    04

    先声明状态

    新建 redeem.py

    redeem.py · 前半段aiogram 3.x
    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() 就是一步。声明出来的好处是写错名字会立刻报错,不像手写字符串——打错一个字,程序不吭声,用户卡死。

    05

    三个 handler,对应三步

    入口:检查够不够分,然后进入第一个状态。

    redeem.py · 入口
    @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 的原因。

    redeem.py · 选商品
    @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}」?回 是 或 否")

    第三步,扣分收尾:

    redeem.py · 确认
    @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 再扣分

    顺序反过来的话,扣分那步一旦抛异常,clear() 就执行不到,用户永远卡在 confirming 状态 —— 之后他说什么都会被当成在回答「是或否」。
    清状态要早,别指望后面的代码一定跑得到。

    06

    扣分函数:让数据库保证不透支

    storage.py 里加一个。这里又用到第 3 章那个手法 —— 把条件写进 SQL,而不是先查再改

    storage.py 追加
    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 章会把这类问题讲透。

    07

    /cancel 不是可选的

    只要有多步流程,就必须给用户一条退出的路。而且它得在任何状态下都管用

    redeem.py · 退出
    @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 接走,当成商品名处理。

    08

    挂上去

    bot.py 改两行:

    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

    09

    跑一遍,把这几条都试到

    多步流程的 bug 大多藏在不按套路走的路径上,所以别只测正常流程:

    你做什么应该看到
    正常走完一遍兑换成功,积分扣掉了
    选商品那步回一句「你好」「没有这个」,还停在选商品,能重说
    选一个买不起的提示分不够,让你换一个
    确认那步回「否」「那就算了」,分没扣
    中途发 /cancel已取消。之后发 /me 能正常用 —— 这条最重要,验证状态真的清了
    没在流程里发 /cancel「你没在做什么需要取消的事」
    兑换到一半,Ctrl+C 重启,再回「是」机器人没反应 ← 这是下一章的题目
    倒数第二条如果失败(取消后 /me 不好使),说明状态没清干净 —— 检查是不是漏了 clear()
    10

    最后一条测试暴露的问题

    重启之后,正在兑换的用户就卡住了:他回「是」,机器人当没听见。

    因为 MemoryStorage 就是字面意思 —— 状态存在内存里,进程一没就全丢了。我们绕开了自己写字典的那些毛病,但没绕开这一条。

    这不是 bug,是这一章特意留的口子:先让流程跑起来,再解决存哪儿的问题。下一章就干这件事,顺便把第 3 章欠的时区问题一起还了。

    这一章的要点

    1. 命令自带标识,普通消息没有 —— 一句「贴纸」是什么意思,取决于上一句问了什么,这就是需要状态机的原因
    2. handler 按状态过滤,不用写吃掉所有消息的大 if
    3. 清状态要早,别放在可能抛异常的代码后面
    4. /cancel 必须有,而且要 StateFilter("*") 并且先注册
    5. 扣分的条件写进 SQL(AND points >= ?),让数据库保证不透支
    ✓ 试着改一个

    给兑换加一条记录:新建一张 redeems 表,记下谁、换了什么、什么时候、花了多少分,再加个 /history 命令看自己的兑换记录。
    提示:扣分和写记录要在同一个事务里,否则可能扣了分没记上。这个话题第 6 章展开。

    状态机这块清楚了吗?

    多步对话是第一个真正有点绕的东西。哪一步没讲明白,或者你的流程遇到别的情况,都可以说。留不留联系方式都行。