你将构建什么
本教程将带你从零构建一个 Next.js 14+(App Router)全栈应用,实现 Profy OAuth 登录、Token 安全存储、以及按量计费事件上报的完整流程。用户点击「Login with Profy」后完成授权,应用自动管理 Token 生命周期,并在用户触发付费动作时向 Profy 上报计费事件。前置条件
Profy 开发者账号
已注册 Profy 账号并在开发者后台创建 App,获取 Client ID 和 Client Secret
开发环境
Node.js 18+、npm/pnpm/bun、基础 Next.js 和 React 经验
Step 1: 创建 Next.js 项目并安装依赖
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
npm install drizzle-orm postgres
npm install -D drizzle-kit
Step 2: 配置环境变量
.env.local
PROFY_APP_ID=your_app_client_id
PROFY_APP_SECRET=your_app_client_secret
PROFY_CALLBACK_URL=http://localhost:3000/api/callback
# Drizzle Token Store(可选)
DATABASE_URL=postgres://user:pass@localhost:5432/profy_demo
永远不要将
.env.local 提交到版本控制。确保 .gitignore 包含该文件。Step 3: 初始化 SDK
import { ProfyApp } from "@profy-ai/sdk";
export const profy = new ProfyApp({
clientId: process.env.PROFY_APP_ID!,
clientSecret: process.env.PROFY_APP_SECRET!,
});
import os
from profy_sdk import ProfyApp
profy = ProfyApp(
client_id=os.environ["PROFY_APP_ID"],
client_secret=os.environ["PROFY_APP_SECRET"],
)
ProfyApp 实例应当作为单例在服务端使用。不要将 clientSecret 暴露给客户端。Step 4: 创建 OAuth 登录页面
app/page.tsx
const PROFY_AUTHORIZE_URL = "https://app.profy.cn/oauth/authorize";
export default function Home() {
const clientId = process.env.PROFY_APP_ID!;
const redirectUri = process.env.PROFY_CALLBACK_URL!;
const authUrl = `${PROFY_AUTHORIZE_URL}?${new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: "events:write",
response_type: "code",
})}`;
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 回调
import { NextRequest, NextResponse } from "next/server";
import { profy } from "@/lib/profy";
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 tokenResult = await profy.exchangeCode(code, redirectUri);
const cookieStore = await cookies();
cookieStore.set("profy_user_id", tokenResult.userId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 90,
});
// Token 持久化见 Step 6
await saveToken(tokenResult);
return NextResponse.redirect(new URL("/dashboard", request.url));
}
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from config import profy
app = FastAPI()
@app.get("/api/callback")
async def oauth_callback(request: Request, code: str | None = None):
if not code:
return {"error": "Missing code"}, 400
redirect_uri = os.environ["PROFY_CALLBACK_URL"]
token_result = await profy.exchange_code(code, redirect_uri)
# Token 持久化见 Step 6
await save_token(token_result)
response = RedirectResponse(url="/dashboard")
response.set_cookie(
"profy_user_id",
token_result["user_id"],
httponly=True,
secure=os.environ.get("ENV") == "production",
samesite="lax",
max_age=60 * 60 * 24 * 90,
)
return response
exchangeCode 返回的 expiresAt 是 access token 的过期时间戳,用于判断何时刷新。Step 6: Token 持久化
方案 A: 使用 DrizzleTokenStore(推荐)
SDK 提供了开箱即用的 Drizzle 集成:import { DrizzleTokenStore, profyOAuthToken } from "@profy-ai/sdk/contrib/drizzle";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const client = postgres(process.env.DATABASE_URL!);
const db = drizzle(client);
export const tokenStore = new DrizzleTokenStore(db);
from profy_sdk.contrib.sqlalchemy import SQLAlchemyTokenStore
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine(os.environ["DATABASE_URL"])
async_session = async_sessionmaker(engine)
token_store = SQLAlchemyTokenStore(async_session)
db/schema.ts
export { profyOAuthToken } from "@profy-ai/sdk/contrib/drizzle";
saveToken 实现:
import { isTokenExpired } from "@profy-ai/sdk";
import { profy } from "./profy";
export async function saveToken(tokenResult: {
accessToken: string;
refreshToken: string;
userId: string;
expiresAt: number;
}) {
await tokenStore.save(tokenResult.userId, {
accessToken: tokenResult.accessToken,
refreshToken: tokenResult.refreshToken,
expiresAt: tokenResult.expiresAt,
});
}
export async function getValidToken(userId: string) {
const stored = await tokenStore.get(userId);
if (!stored) throw new Error("User not authenticated");
if (isTokenExpired(stored)) {
const refreshed = await profy.refreshToken(stored.refreshToken);
await tokenStore.save(userId, refreshed);
return refreshed.accessToken;
}
return stored.accessToken;
}
from profy_sdk import is_token_expired
from config import profy
async def save_token(token_result: dict):
await token_store.save(
token_result["user_id"],
{
"access_token": token_result["access_token"],
"refresh_token": token_result["refresh_token"],
"expires_at": token_result["expires_at"],
},
)
async def get_valid_token(user_id: str) -> str:
stored = await token_store.get(user_id)
if not stored:
raise Exception("User not authenticated")
if is_token_expired(stored):
refreshed = await profy.refresh_token(stored["refresh_token"])
await token_store.save(user_id, refreshed)
return refreshed["access_token"]
return stored["access_token"]
方案 B: 加密 Cookie(轻量场景)
对于不需要数据库的简单场景,可以将 Token 加密后存入 HTTP-only cookie:lib/token-cookie.ts
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.PROFY_APP_SECRET!);
export async function encryptToken(payload: Record<string, unknown>) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("90d")
.sign(secret);
}
export async function decryptToken(token: string) {
const { payload } = await jwtVerify(token, secret);
return payload;
}
Cookie 方案在 Token 刷新时需要同步更新 cookie,且无法服务端主动吊销。生产环境推荐方案 A。
Step 7: 上报计费事件
import { NextRequest, NextResponse } from "next/server";
import { profy } from "@/lib/profy";
import { getValidToken } from "@/lib/token-store";
import { cookies } from "next/headers";
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();
const token = await getValidToken(userId);
const result = await profy.reportEvent(eventName, {
token,
idempotencyKey,
metadata,
});
return NextResponse.json(result);
}
from fastapi import FastAPI, Request, HTTPException
from config import profy
from token_store import get_valid_token
@app.post("/api/report-event")
async def report_event(request: Request):
user_id = request.cookies.get("profy_user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Unauthorized")
body = await request.json()
token = await get_valid_token(user_id)
result = await profy.report_event(
body["eventName"],
token=token,
idempotency_key=body.get("idempotencyKey"),
metadata=body.get("metadata"),
)
return result
app/dashboard/page.tsx
"use client";
import { useState } from "react";
export default function Dashboard() {
const [balance, setBalance] = useState<number | null>(null);
async function handleAction() {
const res = await fetch("/api/report-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eventName: "ai_generation",
idempotencyKey: crypto.randomUUID(),
metadata: { type: "text" },
}),
});
if (!res.ok) {
const err = await res.json();
alert(err.error);
return;
}
const { balance_remaining } = await res.json();
setBalance(balance_remaining);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4">
<button
onClick={handleAction}
className="rounded-lg bg-green-600 px-6 py-3 text-white font-medium hover:bg-green-700 transition-colors"
>
触发 AI 生成(计费)
</button>
{balance !== null && (
<p className="text-sm text-gray-500">剩余余额: {balance}</p>
)}
</main>
);
}
Step 8: 错误处理
SDK 抛出的错误均为类型化异常,可精确捕获:import { AuthExpired, InsufficientBalance, InvalidEvent, ProfyApiError } from "@profy-ai/sdk";
import { profy } from "./profy";
import { getValidToken, tokenStore } from "./token-store";
export async function reportEventSafely(
userId: string,
eventName: string,
options?: { idempotencyKey?: string; metadata?: Record<string, unknown> }
) {
const token = await getValidToken(userId);
try {
return await profy.reportEvent(eventName, { token, ...options });
} catch (error) {
if (error instanceof AuthExpired) {
await tokenStore.delete(userId);
throw new Error("SESSION_EXPIRED");
}
if (error instanceof InsufficientBalance) {
throw new Error("INSUFFICIENT_BALANCE");
}
if (error instanceof InvalidEvent) {
throw new Error("INVALID_EVENT");
}
if (error instanceof ProfyApiError) {
throw new Error(`PROFY_ERROR: ${error.message}`);
}
throw error;
}
}
from profy_sdk import AuthExpired, InsufficientBalance, InvalidEvent, ProfyApiError
from config import profy
from token_store import get_valid_token, token_store
async def report_event_safely(
user_id: str,
event_name: str,
idempotency_key: str | None = None,
metadata: dict | None = None,
):
token = await get_valid_token(user_id)
try:
return await profy.report_event(
event_name,
token=token,
idempotency_key=idempotency_key,
metadata=metadata,
)
except AuthExpired:
await token_store.delete(user_id)
raise Exception("SESSION_EXPIRED")
except InsufficientBalance:
raise Exception("INSUFFICIENT_BALANCE")
except InvalidEvent:
raise Exception("INVALID_EVENT")
except ProfyApiError as e:
raise Exception(f"PROFY_ERROR: {e}")
app/api/report-event/route.ts
import { NextRequest, NextResponse } from "next/server";
import { reportEventSafely } from "@/lib/report-with-error-handling";
import { cookies } from "next/headers";
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 result = await reportEventSafely(userId, eventName, {
idempotencyKey,
metadata,
});
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "SESSION_EXPIRED") {
return NextResponse.json(
{ error: "登录已过期,请重新授权" },
{ status: 401 }
);
}
if (message === "INSUFFICIENT_BALANCE") {
return NextResponse.json(
{ error: "余额不足,请充值后重试" },
{ status: 402 }
);
}
if (message === "INVALID_EVENT") {
return NextResponse.json(
{ error: "事件名称无效,请检查 Meter 配置" },
{ status: 400 }
);
}
return NextResponse.json({ error: "服务异常" }, { status: 500 });
}
}
完整项目结构
profy-demo/
├── app/
│ ├── api/
│ │ ├── callback/
│ │ │ └── route.ts # OAuth 回调
│ │ └── report-event/
│ │ └── route.ts # 计费事件上报
│ ├── dashboard/
│ │ └── page.tsx # 登录后页面
│ ├── layout.tsx
│ └── page.tsx # 登录页
├── db/
│ └── schema.ts # Drizzle schema
├── lib/
│ ├── profy.ts # SDK 单例
│ ├── token-store.ts # Token 存储与刷新
│ └── report-with-error-handling.ts
├── .env.local
├── drizzle.config.ts
├── next.config.ts
├── package.json
└── tsconfig.json
常见问题
下一步
按次计费 SaaS
PER_USE 模式:固定价格按次扣费
AI 按量计费
METERED 模式:调用 AI 模型按 token 计费
Webhook 事件监听
接收 Profy 平台推送的用户事件通知
Token 管理最佳实践
多用户、并发刷新、安全存储

