跳转到主要内容

你将构建什么

本教程将带你从零构建一个 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
如果需要使用 Drizzle 持久化 Token,还需安装:
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)
在 schema 中导出 Token 表:
db/schema.ts
export { profyOAuthToken } from "@profy-ai/sdk/contrib/drizzle";
运行 migration 创建表后,回调中的 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}")
在 API Route 中统一处理:
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 管理最佳实践

多用户、并发刷新、安全存储