你将构建什么
本教程将带你从零构建一个 Next.js 14+(App Router)全栈应用,采用双客户端架构:Profy(API Key) — 你的应用自己的 API Key,用于调用专家和 AI 模型ProfyApp(OAuth) — 用户的 OAuth Token,用于向袋袋上报计费事件
前置条件
袋袋开发者账号
已注册袋袋账号并在开发者后台创建 App,获取 Client ID、Client Secret 和 API Key
开发环境
Node.js 18+、npm/pnpm/bun、基础 Next.js 和 React 经验
Step 1: 创建项目并安装依赖
npx create-next-app@latest profy-demo --typescript --app --tailwind
cd profy-demo
npm install @profy-ai/sdk
pnpm create next-app profy-demo --typescript --app --tailwind
cd profy-demo
pnpm add @profy-ai/sdk
bun create next-app profy-demo --typescript --app --tailwind
cd profy-demo
bun add @profy-ai/sdk
Step 2: 配置环境变量
.env.local
# Profy 客户端(API Key)— 调用专家和 AI 模型
PROFY_API_KEY=sk-pro-your-api-key
# ProfyApp 客户端(OAuth)— 用户授权和计费
PROFY_APP_ID=your_app_client_id
PROFY_APP_SECRET=your_app_client_secret
PROFY_CALLBACK_URL=http://localhost:3000/api/callback
永远不要将
.env.local 提交到版本控制。确保 .gitignore 包含该文件。Step 3: 初始化双客户端
lib/profy.ts
import { Profy, ProfyApp } from "@profy-ai/sdk";
export const client = new Profy({
apiKey: process.env.PROFY_API_KEY!,
});
export const app = new ProfyApp({
clientId: process.env.PROFY_APP_ID!,
clientSecret: process.env.PROFY_APP_SECRET!,
});
两个客户端各司其职:
client(Profy)持有你的 API Key,用于 AI 调用;app(ProfyApp)持有 OAuth 凭证,用于用户授权和计费。Step 4: OAuth 登录页面
app/page.tsx
import { app } from "@/lib/profy";
export default function Home() {
const authUrl = app.authorizationUrl({
redirectUri: process.env.PROFY_CALLBACK_URL!,
scope: "events:write",
});
return (
<main className="flex min-h-screen items-center justify-center">
<a
href={authUrl}
className="rounded-lg bg-indigo-600 px-6 py-3 text-white font-medium hover:bg-indigo-700 transition-colors"
>
Login with Profy
</a>
</main>
);
}
Step 5: 处理 OAuth 回调
app/api/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { app } from "@/lib/profy";
import { saveToken } from "@/lib/token-store";
import { cookies } from "next/headers";
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code");
if (!code) {
return NextResponse.json({ error: "Missing code" }, { status: 400 });
}
const redirectUri = process.env.PROFY_CALLBACK_URL!;
const token = await app.exchangeCode(code, redirectUri);
const userId = crypto.randomUUID();
await saveToken(userId, token);
const cookieStore = await cookies();
cookieStore.set("profy_user_id", userId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 90,
});
return NextResponse.redirect(new URL("/dashboard", request.url));
}
exchangeCode 返回的 OAuthToken 包含 accessToken、refreshToken、expiresAt、scope。expiresAt 是 Unix 时间戳(毫秒),用于判断何时刷新。Step 6: Token 持久化
手动实现 Token 存储。生产环境使用数据库(Drizzle / Prisma / etc.),这里用 Map 演示核心模式:lib/token-store.ts
import { isTokenExpired, type OAuthToken } from "@profy-ai/sdk";
import { app } from "./profy";
const tokenStore = new Map<string, OAuthToken>();
export async function saveToken(userId: string, token: OAuthToken) {
tokenStore.set(userId, token);
}
export async function getToken(userId: string): Promise<OAuthToken | null> {
return tokenStore.get(userId) ?? null;
}
export async function deleteToken(userId: string) {
tokenStore.delete(userId);
}
export async function getValidToken(userId: string): Promise<OAuthToken> {
const stored = await getToken(userId);
if (!stored) throw new Error("User not authenticated");
if (!isTokenExpired(stored)) return stored;
const refreshed = await app.refresh(stored.refreshToken);
await saveToken(userId, refreshed);
return refreshed;
}
生产环境必须用持久化存储(数据库/Redis)。
Map 在服务重启后会丢失所有 Token。参考 Token 管理最佳实践。Step 7: 上报计费事件
用户触发付费动作时,通过ProfyApp 的 reportEvent 上报计费事件:
app/api/report-event/route.ts
import { NextRequest, NextResponse } from "next/server";
import { app } from "@/lib/profy";
import { getValidToken, saveToken } from "@/lib/token-store";
import { cookies } from "next/headers";
import { AuthenticationError, InsufficientBalanceError } from "@profy-ai/sdk";
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
const userId = cookieStore.get("profy_user_id")?.value;
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { eventName, idempotencyKey, metadata } = await request.json();
try {
const token = await getValidToken(userId);
const result = await app.reportEvent(eventName, {
token,
idempotencyKey: idempotencyKey ?? crypto.randomUUID(),
metadata,
onRefresh: async (rotated) => {
await saveToken(userId, rotated);
},
});
return NextResponse.json(result);
} catch (error) {
if (error instanceof AuthenticationError) {
return NextResponse.json(
{ error: "登录已过期,请重新授权" },
{ status: 401 },
);
}
if (error instanceof InsufficientBalanceError) {
return NextResponse.json(
{ error: "余额不足,请充值后重试" },
{ status: 402 },
);
}
return NextResponse.json({ error: "服务异常" }, { status: 500 });
}
}
onRefresh 回调在 Token 自动刷新时触发。因为 Profy 的 Refresh Token 是一次性的(rotation),必须在回调中持久化新 Token。Step 8: 调用 AI
使用Profy(API Key)客户端调用专家或 AI 模型——这与用户 OAuth Token 无关:
app/api/chat/route.ts
import { NextRequest, NextResponse } from "next/server";
import { client } from "@/lib/profy";
export async function POST(request: NextRequest) {
const { message, expertId } = await request.json();
try {
if (expertId) {
const result = await client.agents.run(expertId, message);
return NextResponse.json({
text: result.text,
usage: result.usage,
});
}
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: "你是一个专业的写作助手。" },
{ role: "user", content: message },
],
temperature: 0.7,
maxTokens: 2000,
});
return NextResponse.json({
text: response.text,
usage: response.usage,
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
app/api/chat-stream/route.ts
import { NextRequest } from "next/server";
import { client } from "@/lib/profy";
export async function POST(request: NextRequest) {
const { message } = await request.json();
const stream = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: "你是一个专业的写作助手。" },
{ role: "user", content: message },
],
stream: true,
temperature: 0.7,
maxTokens: 2000,
});
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (chunk.text) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ content: chunk.text })}\n\n`),
);
}
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
Step 9: 客户端页面
app/dashboard/page.tsx
"use client";
import { useState } from "react";
export default function Dashboard() {
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [balance, setBalance] = useState<number | null>(null);
async function handleSend() {
if (!input.trim() || loading) return;
setLoading(true);
setMessages((prev) => [...prev, { role: "user", content: input }]);
setInput("");
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: input }),
});
const data = await res.json();
setMessages((prev) => [...prev, { role: "assistant", content: data.text }]);
const billRes = await fetch("/api/report-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eventName: "ai_generation",
idempotencyKey: crypto.randomUUID(),
metadata: { tokens: String(data.usage?.totalTokens ?? 0) },
}),
});
if (billRes.ok) {
const billData = await billRes.json();
setBalance(billData.balance_remaining);
}
setLoading(false);
}
return (
<main className="mx-auto max-w-2xl p-8">
<div className="mb-4 space-y-2">
{messages.map((msg, i) => (
<div
key={i}
className={`rounded-lg p-3 ${msg.role === "user" ? "bg-blue-50 text-right" : "bg-gray-50"}`}
>
{msg.content}
</div>
))}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="输入消息..."
className="flex-1 rounded-lg border px-4 py-2"
disabled={loading}
/>
<button
onClick={handleSend}
disabled={loading}
className="rounded-lg bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
>
发送
</button>
</div>
{balance !== null && (
<p className="mt-2 text-sm text-gray-500">剩余余额: {balance}</p>
)}
</main>
);
}
完整项目结构
profy-demo/
├── app/
│ ├── api/
│ │ ├── callback/
│ │ │ └── route.ts # OAuth 回调
│ │ ├── chat/
│ │ │ └── route.ts # AI 调用(Profy 客户端)
│ │ ├── chat-stream/
│ │ │ └── route.ts # AI 流式调用
│ │ └── report-event/
│ │ └── route.ts # 计费上报(ProfyApp 客户端)
│ ├── dashboard/
│ │ └── page.tsx # 登录后页面
│ ├── layout.tsx
│ └── page.tsx # 登录页
├── lib/
│ ├── profy.ts # 双客户端初始化
│ └── token-store.ts # Token 存储与刷新
├── .env.local
├── next.config.ts
├── package.json
└── tsconfig.json
常见问题
下一步
按次计费实战
ProfyApp reportEvent 按次扣费
Token 管理最佳实践
多用户、并发刷新、安全存储
专家调用
使用 Profy SDK 调用平台专家
AI 按量计费
chat.completions 按 token 计费

