> ## Documentation Index
> Fetch the complete documentation index at: https://docs.profy.cn/llms.txt
> Use this file to discover all available pages before exploring further.

# 按次计费 SaaS 实战

> 使用 Profy App SDK 构建按次付费的 SaaS 应用：计费事件配置、幂等上报、余额预检与错误处理。

# 按次计费 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`       | 导出 PDF | 5      | 将文档导出为 PDF 格式 |
| `generate_summary` | 生成摘要   | 3      | AI 生成文档摘要     |

<Note>
  事件名称一旦创建后不可修改，请使用 `snake_case` 命名。单价可随时调整，调整后立即生效。
</Note>

## Step 2: 安装和初始化 SDK

<CodeGroup>
  ```bash TypeScript theme={null}
  npm install @profy-ai/sdk
  ```

  ```bash Python theme={null}
  pip install profy-sdk
  ```
</CodeGroup>

<CodeGroup>
  ```typescript TypeScript theme={null}
  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);
    },
  });
  ```

  ```python Python theme={null}
  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),
  )
  ```
</CodeGroup>

## Step 3: 实现计费上报

用户触发付费操作时，调用 `reportEvent` 上报计费事件。

<CodeGroup>
  ```typescript TypeScript theme={null}
  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;
  }
  ```

  ```python Python theme={null}
  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
  ```
</CodeGroup>

<Warning>
  `idempotencyKey` 是必填参数（最长 128 字符）。缺失时 API 将返回 400 错误。
</Warning>

**响应结构：**

| 字段                  | 类型      | 说明                     |
| ------------------- | ------- | ---------------------- |
| `charged`           | number  | 本次实际扣除的积分              |
| `balance_remaining` | number  | 扣除后用户剩余积分              |
| `idempotent_replay` | boolean | `true` 表示此次为幂等重放，未实际扣费 |

## Step 4: 预检余额

在执行耗时操作前，先检查用户余额是否充足，避免执行完成后才发现无法扣费：

<CodeGroup>
  ```typescript TypeScript theme={null}
  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 };
  }
  ```

  ```python Python theme={null}
  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}
  ```
</CodeGroup>

<Tip>
  预检只是用户体验优化——实际扣费仍可能因并发操作失败。始终处理 `InsufficientBalance` 错误。
</Tip>

## Step 5: 处理扣费失败

<CodeGroup>
  ```typescript TypeScript theme={null}
  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;
    }
  }
  ```

  ```python Python theme={null}
  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
  ```
</CodeGroup>

**错误处理策略：**

| 错误                    | HTTP | 处理方式                          |
| --------------------- | ---- | ----------------------------- |
| `InsufficientBalance` | 402  | 提示用户充值，中断操作                   |
| `InvalidEvent`        | 400  | 开发阶段错误，检查 Studio Meter 配置     |
| `AuthExpired`         | 401  | 引导用户重新授权                      |
| Rate Limit            | 429  | 指数退避重试（保持相同 `idempotencyKey`） |

## Step 6: 幂等性最佳实践

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

### 幂等键设计原则

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

### 安全重试模式

<CodeGroup>
  ```typescript TypeScript theme={null}
  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");
  }
  ```

  ```python Python theme={null}
  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")
  ```
</CodeGroup>

<Warning>
  重试时必须复用同一个 `idempotencyKey`。每次重试生成新 key 会导致重复扣费。
</Warning>

## 完整示例

一个文档生成服务的完整实现：

<CodeGroup>
  ```typescript TypeScript theme={null}
  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 };
  }
  ```

  ```python Python theme={null}
  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"]}
  ```
</CodeGroup>

## 计费事件设计指南

### 单事件 vs 多事件

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

### 命名规范

* 使用 `snake_case`
* 以动词开头：`generate_`、`export_`、`analyze_`
* 避免泛化命名（如 `action`、`event`）
* 长度建议 ≤ 32 字符

### 粒度决策

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

## 常见问题

**Q: 幂等键重复会怎样？**

API 返回 `{ idempotent_replay: true }`，不会重复扣费。`charged` 值为首次扣费金额。

**Q: 预检余额后余额被其他操作扣光了怎么办？**

`reportEvent` 会返回 `InsufficientBalance` 错误。预检仅是用户体验优化，不能替代错误处理。

**Q: 单价修改后已发出的请求按哪个价格？**

按请求到达时的最新单价计算。修改立即生效，无过渡期。

**Q: idempotencyKey 有过期时间吗？**

有。相同 key 在 24 小时内有效，超过后视为新请求。

**Q: 一次操作需要扣多个事件怎么办？**

依次调用 `reportEvent`。每个事件使用独立的 `idempotencyKey`。如果中间失败，已扣费的事件不会自动退回——建议设计时尽量将一次用户操作映射为单个事件。

## 下一步

<CardGroup cols={2}>
  <Card title="SDK 快速开始" icon="bolt" href="/zh/sdk/quickstart">
    OAuth 授权与基础事件上报
  </Card>

  <Card title="AI 按量计费" icon="gauge-high" href="/zh/sdk/cookbook/ai-metered-billing">
    按 Token 用量计费的 AI 应用接入
  </Card>

  <Card title="调用平台 Expert" icon="robot" href="/zh/sdk/cookbook/expert-invoke">
    在你的 App 中嵌入 AI Expert 能力
  </Card>

  <Card title="API 参考" icon="code" href="/zh/api/post-event">
    Events API 完整端点文档
  </Card>
</CardGroup>
