跳转到主要内容

按次计费 SaaS 实战

本教程以一个文档生成工具为例,演示如何通过 Profy PER_USE 计费模型实现按次收费。

你将构建什么

一个文档生成 SaaS——用户每次执行付费操作时,Profy 自动从其账户扣除相应积分:
  • 生成报告(generate_report)→ 扣 10 积分
  • 导出 PDF(export_pdf)→ 扣 5 积分
  • 生成摘要(generate_summary)→ 扣 3 积分
整个扣费流程由你的应用调用 SDK 完成,Profy 保证幂等与原子性。

前置条件

条件说明
Profy 创作者账号已通过创作者认证
App 已创建在 Profy Studio 中创建 App 并获取 clientId / clientSecret
计费事件已配置在 Studio 的「计费事件」Tab 中添加了 Meter
OAuth 授权已完成持有用户的 accessToken / refreshToken

Step 1: 在 Studio 中配置计费事件

进入 Profy Studio → 你的 App → 计费事件,添加以下 Meter:
事件名称显示名单价(积分)说明
generate_report生成报告10生成一份完整分析报告
export_pdf导出 PDF5将文档导出为 PDF 格式
generate_summary生成摘要3AI 生成文档摘要
事件名称一旦创建后不可修改,请使用 snake_case 命名。单价可随时调整,调整后立即生效。

Step 2: 安装和初始化 SDK

npm install @profy-ai/sdk
pip install profy-sdk
import { ProfyApp } from "@profy-ai/sdk";

const profy = new ProfyApp({
  clientId: process.env.PROFY_APP_ID!,
  clientSecret: process.env.PROFY_APP_SECRET!,
  onTokenRefresh: (newToken) => {
    saveToDatabase(newToken);
  },
});
from profy_sdk import ProfyApp

profy = ProfyApp(
    client_id=os.environ["PROFY_APP_ID"],
    client_secret=os.environ["PROFY_APP_SECRET"],
    on_token_refresh=lambda token: save_to_database(token),
)

Step 3: 实现计费上报

用户触发付费操作时,调用 reportEvent 上报计费事件。
import { randomUUID } from "crypto";

async function generateReport(userId: string, token: string) {
  const idempotencyKey = `${userId}-generate_report-${randomUUID()}`;

  const result = await profy.reportEvent("generate_report", {
    token,
    idempotencyKey,
    metadata: { userId, action: "generate_report" },
  });

  console.log(`Charged: ${result.charged}, Balance: ${result.balance_remaining}`);
  return result;
}
import uuid

async def generate_report(user_id: str, token: str):
    idempotency_key = f"{user_id}-generate_report-{uuid.uuid4()}"

    result = await profy.report_event("generate_report",
        token=token,
        idempotency_key=idempotency_key,
        metadata={"user_id": user_id, "action": "generate_report"},
    )

    print(f"Charged: {result.charged}, Balance: {result.balance_remaining}")
    return result
idempotencyKey 是必填参数(最长 128 字符)。缺失时 API 将返回 400 错误。
响应结构:
字段类型说明
chargednumber本次实际扣除的积分
balance_remainingnumber扣除后用户剩余积分
idempotent_replaybooleantrue 表示此次为幂等重放,未实际扣费

Step 4: 预检余额

在执行耗时操作前,先检查用户余额是否充足,避免执行完成后才发现无法扣费:
const PRICES: Record<string, number> = {
  generate_report: 10,
  export_pdf: 5,
  generate_summary: 3,
};

async function canAfford(token: string, action: string): Promise<boolean> {
  const balance = await profy.getBalance(token);
  return balance.credits >= PRICES[action];
}

async function handleAction(userId: string, token: string, action: string) {
  if (!await canAfford(token, action)) {
    throw new Error("INSUFFICIENT_BALANCE");
  }

  const document = await performExpensiveOperation(action);

  const result = await profy.reportEvent(action, {
    token,
    idempotencyKey: `${userId}-${action}-${randomUUID()}`,
  });

  return { document, charged: result.charged };
}
PRICES = {
    "generate_report": 10,
    "export_pdf": 5,
    "generate_summary": 3,
}

async def can_afford(token: str, action: str) -> bool:
    balance = await profy.get_balance(token)
    return balance.credits >= PRICES[action]

async def handle_action(user_id: str, token: str, action: str):
    if not await can_afford(token, action):
        raise ValueError("INSUFFICIENT_BALANCE")

    document = await perform_expensive_operation(action)

    result = await profy.report_event(action,
        token=token,
        idempotency_key=f"{user_id}-{action}-{uuid.uuid4()}",
    )

    return {"document": document, "charged": result.charged}
预检只是用户体验优化——实际扣费仍可能因并发操作失败。始终处理 InsufficientBalance 错误。

Step 5: 处理扣费失败

import {
  AuthExpired,
  InsufficientBalance,
  InvalidEvent,
  ProfyApiError,
} from "@profy-ai/sdk";

async function safeCharge(userId: string, token: string, action: string) {
  try {
    return await profy.reportEvent(action, {
      token,
      idempotencyKey: `${userId}-${action}-${randomUUID()}`,
    });
  } catch (err) {
    if (err instanceof InsufficientBalance) {
      return { success: false, reason: "balance_insufficient" } as const;
    }
    if (err instanceof InvalidEvent) {
      return { success: false, reason: "event_not_configured" } as const;
    }
    if (err instanceof AuthExpired) {
      return { success: false, reason: "auth_expired" } as const;
    }
    if (err instanceof ProfyApiError && err.statusCode === 429) {
      return { success: false, reason: "rate_limited" } as const;
    }
    throw err;
  }
}
from profy_sdk.errors import (
    AuthExpired,
    InsufficientBalance,
    InvalidEvent,
    ProfyApiError,
)

async def safe_charge(user_id: str, token: str, action: str):
    try:
        return await profy.report_event(action,
            token=token,
            idempotency_key=f"{user_id}-{action}-{uuid.uuid4()}",
        )
    except InsufficientBalance:
        return {"success": False, "reason": "balance_insufficient"}
    except InvalidEvent:
        return {"success": False, "reason": "event_not_configured"}
    except AuthExpired:
        return {"success": False, "reason": "auth_expired"}
    except ProfyApiError as e:
        if e.status_code == 429:
            return {"success": False, "reason": "rate_limited"}
        raise
错误处理策略:
错误HTTP处理方式
InsufficientBalance402提示用户充值,中断操作
InvalidEvent400开发阶段错误,检查 Studio Meter 配置
AuthExpired401引导用户重新授权
Rate Limit429指数退避重试(保持相同 idempotencyKey

Step 6: 幂等性最佳实践

idempotencyKey 保证同一业务操作不会被重复扣费。当请求超时或网络抖动时,可以安全重试。

幂等键设计原则

场景推荐格式示例
用户手动触发{userId}-{action}-{requestId}u123-export_pdf-a1b2c3
定时任务{taskId}-{scheduledAt}task_daily_report-2026-06-02
队列消费使用消息的唯一 IDmsg-5f3a8b2c

安全重试模式

async function chargeWithRetry(
  userId: string,
  token: string,
  action: string,
  maxRetries = 3
) {
  const idempotencyKey = `${userId}-${action}-${randomUUID()}`;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const result = await profy.reportEvent(action, { token, idempotencyKey });

      if (result.idempotent_replay) {
        console.log("Idempotent replay — no additional charge");
      }
      return result;
    } catch (err) {
      if (err instanceof ProfyApiError && err.statusCode === 429) {
        await sleep(2 ** attempt * 1000);
        continue;
      }
      throw err;
    }
  }
  throw new Error("Max retries exceeded");
}
async def charge_with_retry(
    user_id: str, token: str, action: str, max_retries: int = 3
):
    idempotency_key = f"{user_id}-{action}-{uuid.uuid4()}"

    for attempt in range(max_retries + 1):
        try:
            result = await profy.report_event(action,
                token=token,
                idempotency_key=idempotency_key,
            )
            if result.idempotent_replay:
                print("Idempotent replay — no additional charge")
            return result
        except ProfyApiError as e:
            if e.status_code == 429:
                await asyncio.sleep(2 ** attempt)
                continue
            raise

    raise RuntimeError("Max retries exceeded")
重试时必须复用同一个 idempotencyKey。每次重试生成新 key 会导致重复扣费。

完整示例

一个文档生成服务的完整实现:
import { randomUUID } from "crypto";
import { ProfyApp, InsufficientBalance, AuthExpired, ProfyApiError } from "@profy-ai/sdk";

const profy = new ProfyApp({
  clientId: process.env.PROFY_APP_ID!,
  clientSecret: process.env.PROFY_APP_SECRET!,
  onTokenRefresh: (newToken) => saveToDatabase(newToken),
});

const PRICES: Record<string, number> = {
  generate_report: 10,
  export_pdf: 5,
  generate_summary: 3,
};

interface ChargeResult {
  success: true;
  charged: number;
  balance: number;
}

interface ChargeError {
  success: false;
  reason: "balance_insufficient" | "auth_expired" | "rate_limited" | "unknown";
}

async function chargeUser(
  userId: string,
  token: string,
  action: string
): Promise<ChargeResult | ChargeError> {
  const idempotencyKey = `${userId}-${action}-${randomUUID()}`;

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const result = await profy.reportEvent(action, {
        token,
        idempotencyKey,
        metadata: { userId, action, attempt },
      });

      return {
        success: true,
        charged: result.charged,
        balance: result.balance_remaining,
      };
    } catch (err) {
      if (err instanceof InsufficientBalance) {
        return { success: false, reason: "balance_insufficient" };
      }
      if (err instanceof AuthExpired) {
        return { success: false, reason: "auth_expired" };
      }
      if (err instanceof ProfyApiError && err.statusCode === 429) {
        await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
        continue;
      }
      return { success: false, reason: "unknown" };
    }
  }
  return { success: false, reason: "rate_limited" };
}

async function handleGenerateReport(userId: string, token: string) {
  const balance = await profy.getBalance(token);
  if (balance.credits < PRICES.generate_report) {
    return { error: "余额不足,请充值后再试" };
  }

  const report = await generateReportContent(userId);

  const charge = await chargeUser(userId, token, "generate_report");
  if (!charge.success) {
    return { error: `扣费失败: ${charge.reason}` };
  }

  return { report, charged: charge.charged, balance: charge.balance };
}
import os
import uuid
import asyncio
from profy_sdk import ProfyApp
from profy_sdk.errors import InsufficientBalance, AuthExpired, ProfyApiError

profy = ProfyApp(
    client_id=os.environ["PROFY_APP_ID"],
    client_secret=os.environ["PROFY_APP_SECRET"],
    on_token_refresh=lambda token: save_to_database(token),
)

PRICES = {
    "generate_report": 10,
    "export_pdf": 5,
    "generate_summary": 3,
}


async def charge_user(user_id: str, token: str, action: str) -> dict:
    idempotency_key = f"{user_id}-{action}-{uuid.uuid4()}"

    for attempt in range(3):
        try:
            result = await profy.report_event(action,
                token=token,
                idempotency_key=idempotency_key,
                metadata={"user_id": user_id, "action": action, "attempt": attempt},
            )
            return {
                "success": True,
                "charged": result.charged,
                "balance": result.balance_remaining,
            }
        except InsufficientBalance:
            return {"success": False, "reason": "balance_insufficient"}
        except AuthExpired:
            return {"success": False, "reason": "auth_expired"}
        except ProfyApiError as e:
            if e.status_code == 429:
                await asyncio.sleep(2 ** attempt)
                continue
            return {"success": False, "reason": "unknown"}

    return {"success": False, "reason": "rate_limited"}


async def handle_generate_report(user_id: str, token: str) -> dict:
    balance = await profy.get_balance(token)
    if balance.credits < PRICES["generate_report"]:
        return {"error": "余额不足,请充值后再试"}

    report = await generate_report_content(user_id)

    charge = await charge_user(user_id, token, "generate_report")
    if not charge["success"]:
        return {"error": f"扣费失败: {charge['reason']}"}

    return {"report": report, "charged": charge["charged"], "balance": charge["balance"]}

计费事件设计指南

单事件 vs 多事件

策略适用场景示例
一个操作一个事件操作粒度明确、价格固定export_pdfsend_email
一类操作一个事件同类操作统一计费ai_generation(涵盖摘要、翻译、改写)
分级事件同操作不同复杂度generate_basic(3)、generate_pro(10)

命名规范

  • 使用 snake_case
  • 以动词开头:generate_export_analyze_
  • 避免泛化命名(如 actionevent
  • 长度建议 ≤ 32 字符

粒度决策

一条经验法则:如果用户会把两个操作理解为”一件事”,它们应该合并为一个事件。如果用户会问”为什么这次扣了这么多”,说明粒度需要拆细。

常见问题

Q: 幂等键重复会怎样? API 返回 { idempotent_replay: true },不会重复扣费。charged 值为首次扣费金额。 Q: 预检余额后余额被其他操作扣光了怎么办? reportEvent 会返回 InsufficientBalance 错误。预检仅是用户体验优化,不能替代错误处理。 Q: 单价修改后已发出的请求按哪个价格? 按请求到达时的最新单价计算。修改立即生效,无过渡期。 Q: idempotencyKey 有过期时间吗? 有。相同 key 在 24 小时内有效,超过后视为新请求。 Q: 一次操作需要扣多个事件怎么办? 依次调用 reportEvent。每个事件使用独立的 idempotencyKey。如果中间失败,已扣费的事件不会自动退回——建议设计时尽量将一次用户操作映射为单个事件。

下一步

SDK 快速开始

OAuth 授权与基础事件上报

AI 按量计费

按 Token 用量计费的 AI 应用接入

调用平台 Expert

在你的 App 中嵌入 AI Expert 能力

API 参考

Events API 完整端点文档