你将构建什么
一个文档生成 SaaS——用户每次执行付费操作时,袋袋自动从其账户扣除相应积分:- 生成报告(
generate_report)→ 扣 10 积分 - 导出 PDF(
export_pdf)→ 扣 5 积分 - 生成摘要(
generate_summary)→ 扣 3 积分
前置条件
| 条件 | 说明 |
|---|---|
| 袋袋创作者账号 | 已通过创作者认证 |
| App 已创建 | 在袋袋 Studio 中创建 App 并获取 clientId / clientSecret |
| 计费事件已配置 | 在 Studio 的「计费事件」Tab 中添加了 Meter |
| OAuth 授权已完成 | 持有用户的 accessToken / refreshToken |
Step 1: 在 Studio 中配置计费事件
进入 袋袋 Studio → 你的 App → 计费事件,添加以下 Meter:| 事件名称 | 显示名 | 单价(积分) | 说明 |
|---|---|---|---|
generate_report | 生成报告 | 10 | 生成一份完整分析报告 |
export_pdf | 导出 PDF | 5 | 将文档导出为 PDF 格式 |
generate_summary | 生成摘要 | 3 | AI 生成文档摘要 |
事件名称一旦创建后不可修改,请使用
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 错误。| 字段 | 类型 | 说明 |
|---|---|---|
charged | number | 本次实际扣除的积分 |
balance_remaining | number | 扣除后用户剩余积分 |
idempotent_replay | boolean | true 表示此次为幂等重放,未实际扣费 |
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 | 处理方式 |
|---|---|---|
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 |
安全重试模式
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_pdf、send_email |
| 一类操作一个事件 | 同类操作统一计费 | ai_generation(涵盖摘要、翻译、改写) |
| 分级事件 | 同操作不同复杂度 | generate_basic(3)、generate_pro(10) |
命名规范
- 使用
snake_case - 以动词开头:
generate_、export_、analyze_ - 避免泛化命名(如
action、event) - 长度建议 ≤ 32 字符
粒度决策
一条经验法则:如果用户会把两个操作理解为”一件事”,它们应该合并为一个事件。如果用户会问”为什么这次扣了这么多”,说明粒度需要拆细。
常见问题
Q: 幂等键重复会怎样? API 返回{ idempotent_replay: true },不会重复扣费。charged 值为首次扣费金额。
Q: 预检余额后余额被其他操作扣光了怎么办?
reportEvent 会返回 InsufficientBalance 错误。预检仅是用户体验优化,不能替代错误处理。
Q: 单价修改后已发出的请求按哪个价格?
按请求到达时的最新单价计算。修改立即生效,无过渡期。
Q: idempotencyKey 有过期时间吗?
有。相同 key 在 24 小时内有效,超过后视为新请求。
Q: 一次操作需要扣多个事件怎么办?
依次调用 reportEvent。每个事件使用独立的 idempotencyKey。如果中间失败,已扣费的事件不会自动退回——建议设计时尽量将一次用户操作映射为单个事件。
下一步
SDK 快速开始
OAuth 授权与基础事件上报
AI 按量计费
按 Token 用量计费的 AI 应用接入
调用平台专家
在你的 App 中嵌入 AI 专家能力
API 参考
Events API 完整端点文档

