PAYATHON 2026

Aiogram 3.3.0 调用 send_invoice() 无响应

支付老李

结论

send_invoice() 并非没有执行,而是在调用 Telegram Bot API 时抛出了异常。当前代码至少有以下几个问题:

  1. provider_token='TEST_TOKEN' 是占位字符串,必须换成 BotFather 为支付提供商生成的真实 token。
  2. suggested_tip_amounts=[100*100, 300*100] 中的 30000 超过了 max_tip_amount=10000,Telegram 会拒绝这张账单。
  3. 发送账单时使用的 payload 是 test-invoice-payload,预结账处理器却只接受 test_payload。即使账单成功发出,付款也会在预结账阶段被拒绝。
  4. bot=Bot 把类对象设成了默认值,不是规范的依赖注入声明。这里应写成 bot: Bot。
  5. 金额采用货币的最小单位。对于 USD,299 * 100 表示 $299.00,而不是 $2.99。
  6. 当前的异常记录方式没有输出 traceback,很容易让人误以为程序完全没有报错。

正确的 Bot 注入方式

Aiogram 3 会通过 Dispatcher 注入当前的 Bot 实例,处理器应这样声明:

async def pay(message: Message, bot: Bot):
    ...

不要写成:

async def pay(message: Message, bot=Bot):
    ...

后一种写法把 Bot 类本身设成了默认值。虽然 Dispatcher 通常会根据参数名注入 bot,但这种声明的含义仍然不正确,也会给脱离 Dispatcher 的调用或测试带来难以判断的问题。

如果处理器只需要回复当前聊天,也可以直接调用 message.answer_invoice(),避免处理 Bot 注入。

修正后的示例

import logging

from aiogram import Bot, F, Router
from aiogram.filters import Command
from aiogram.types import (
    LabeledPrice,
    Message,
    PreCheckoutQuery,
)

router = Router()
logger = logging.getLogger(__name__)

INVOICE_PAYLOAD = "test-invoice-payload"


@router.message(Command("payment"))
async def pay(message: Message, bot: Bot):
    await message.answer("Your order payment")

    try:
        await bot.send_invoice(
            chat_id=message.chat.id,
            title="Subscription",
            description="One month subscription",
            payload=INVOICE_PAYLOAD,
            provider_token="REAL_PROVIDER_TOKEN_FROM_BOTFATHER",
            currency="USD",
            prices=[
                LabeledPrice(
                    label="Sub month",
                    amount=299,  # $2.99
                )
            ],
            max_tip_amount=1000,              # $10.00
            suggested_tip_amounts=[100, 300], # $1.00、$3.00
            start_parameter="testpaymentbot",
            need_email=True,
            need_phone_number=True,
            need_name=True,
            need_shipping_address=False,
            is_flexible=False,
            protect_content=True,
        )
    except Exception:
        logger.exception("Failed to send invoice")
        await message.answer(
            "创建付款账单失败,请查看服务端日志中的 Telegram API 错误。"
        )


@router.pre_checkout_query()
async def process_pre_checkout_query(
    pre_checkout_query: PreCheckoutQuery,
    bot: Bot,
):
    if pre_checkout_query.invoice_payload != INVOICE_PAYLOAD:
        await bot.answer_pre_checkout_query(
            pre_checkout_query.id,
            ok=False,
            error_message="Invalid invoice payload",
        )
        return

    await bot.answer_pre_checkout_query(
        pre_checkout_query.id,
        ok=True,
    )


@router.message(F.successful_payment)
async def successful_payment(message: Message):
    payment = message.successful_payment

    await message.answer(
        f"Thank you for your payment: "
        f"{payment.total_amount / 100:.2f} "
        f"{payment.currency}"
    )

也可以直接通过 Message 发送账单:

@router.message(Command("payment"))
async def pay(message: Message):
    await message.answer_invoice(
        title="Subscription",
        description="One month subscription",
        payload=INVOICE_PAYLOAD,
        provider_token="REAL_PROVIDER_TOKEN_FROM_BOTFATHER",
        currency="USD",
        prices=[
            LabeledPrice(label="Sub month", amount=299)
        ],
    )

provider_token 应该填什么

provider_token 不是自行定义的字符串,也不是支付平台的普通 API key。需要先通过 BotFather 为当前 Telegram bot 连接支付提供商,再使用由 BotFather 生成的 token。

通常需要完成以下配置:

  1. 在 BotFather 中选择目标 bot。
  2. 进入 Payments 配置。
  3. 选择 Telegram 支持的支付提供商。
  4. 完成测试环境或正式环境的配置。
  5. 将生成的 provider token 保存到环境变量。
  6. 由程序读取环境变量,并传给 provider_token。

例如:

import os

provider_token = os.environ["TELEGRAM_PROVIDER_TOKEN"]

不要把正式 token 直接写进源码或提交到 Git 仓库。测试 token 也必须是 BotFather 实际生成的测试凭据,不能直接填写字面量 TEST_TOKEN。

小费参数为什么会导致发送失败

Telegram 对小费参数有以下约束:

  • suggested_tip_amounts 必须按升序排列;
  • 每个建议金额都不能超过 max_tip_amount;
  • 所有金额同样使用货币的最小单位。

原代码是:

max_tip_amount=100 * 100
suggested_tip_amounts=[100 * 100, 300 * 100]

计算结果为:

max_tip_amount = 10000
suggested_tip_amounts = [10000, 30000]

第二个建议金额 30000 超过了最大值 10000,因此 Telegram 会拒绝整个 sendInvoice 请求。

排查时可以先删掉所有小费参数:

await bot.send_invoice(
    ...
    prices=[LabeledPrice(label="Sub month", amount=299)],
)

确认基础账单能够正常发送后,再加入符合约束的小费配置。

金额不应无条件再乘以 100

LabeledPrice.amount 接收的是货币最小单位,不是带小数点的金额。

对于 USD:

amount=299

表示 $2.99。

amount=299 * 100

表示 $299.00。

只有当业务变量保存的是美元金额时,才需要将它转换为美分。例如:

from decimal import Decimal

price_usd = Decimal("2.99")
amount = int(price_usd * 100)

不同货币的小数位数不一定与 USD 相同。生产环境应按照 Telegram 支持的货币配置及对应指数进行换算,不能对所有货币一律乘以 100。

payload 必须前后一致

发送账单时使用的是:

payload="test-invoice-payload"

预结账处理器检查的却是:

if pre_checkout_query.invoice_payload != "test_payload":

两个字符串并不相等。用户点击付款后,预结账请求一定会进入拒绝分支。

最好把它定义成一个常量:

INVOICE_PAYLOAD = "test-invoice-payload"

发送账单和检查预结账请求时都使用该常量。实际项目中的 payload 通常还应包含可验证的订单标识,但不能直接信任客户端返回的数据。服务端需要查询订单,并核对用户、金额、币种和订单状态。

如何看到实际异常

先启用基础日志:

import asyncio
import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

然后在处理器中使用:

try:
    await bot.send_invoice(...)
except Exception:
    logger.exception("Failed to send invoice")

logger.exception() 会自动记录 traceback。相比之下,下面的写法通常只会输出异常文本,缺少具体的调用位置:

logger.critical(f"Can't start bot! Error: {_ex}")

如果需要在 except 中记录异常,可以写成:

except Exception:
    logger.exception("Bot polling failed")

还可以为 Aiogram 注册错误处理器:

from aiogram.types import ErrorEvent


@router.error()
async def error_handler(event: ErrorEvent):
    logger.exception(
        "Unhandled update exception",
        exc_info=event.exception,
    )

不要只在 start_polling() 外层捕获异常。支付请求发生在 update handler 内部,具体异常应通过处理器日志或 Aiogram 的错误处理器记录。

关于“订阅支付”

普通的 send_invoice() 发送的是付款账单,并不等于自动续费订阅。能否实现周期扣款,取决于使用的 Telegram 支付类型、支付提供商的能力以及当前 Bot API 的支持范围。

如果目标是每月自动续费,需要先确认所选支付方案是否支持 recurring payment,并按要求保存和使用相应的付款凭据。仅仅把标题写成 Subscription 或设置 start_parameter,不会让普通账单自动按月扣款。

建议先跑通最小的一次性付款流程:

  1. 使用 BotFather 生成的有效 provider_token。
  2. 删除小费和其他非必要参数。
  3. 使用正确的最小货币单位。
  4. 确保 payload 完全一致。
  5. 在规定时间内响应 pre_checkout_query。
  6. 收到 successful_payment 后,再为用户开通对应权益。

基础流程验证成功后,再根据实际支付渠道处理续费、到期和订单幂等。

备注:内容仅供参考。