> ## 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.

# Next.js 全栈集成

> 使用 Profy App SDK 构建完整的 Next.js 14+ 全栈应用，涵盖 OAuth 登录、Token 持久化与计费事件上报

## 你将构建什么

本教程将带你从零构建一个 Next.js 14+（App Router）全栈应用，实现 Profy OAuth 登录、Token 安全存储、以及按量计费事件上报的完整流程。用户点击「Login with Profy」后完成授权，应用自动管理 Token 生命周期，并在用户触发付费动作时向 Profy 上报计费事件。

```mermaid theme={null}
sequenceDiagram
    participant User as 用户浏览器
    participant App as Next.js App
    participant Profy as Profy OAuth

    User->>App: 点击「Login with Profy」
    App->>Profy: 重定向到 /oauth/authorize
    Profy->>User: 展示授权页面
    User->>Profy: 确认授权
    Profy->>App: 回调 ?code=xxx
    App->>Profy: exchangeCode(code)
    Profy->>App: { accessToken, refreshToken }
    App->>App: 存储 Token 到 DB
    User->>App: 触发付费动作
    App->>Profy: reportEvent(eventName, token)
    Profy->>App: { charged, balance_remaining }
```

## 前置条件

<CardGroup cols={2}>
  <Card title="Profy 开发者账号" icon="user">
    已注册 Profy 账号并在开发者后台创建 App，获取 Client ID 和 Client Secret
  </Card>

  <Card title="开发环境" icon="code">
    Node.js 18+、npm/pnpm/bun、基础 Next.js 和 React 经验
  </Card>
</CardGroup>

## Step 1: 创建 Next.js 项目并安装依赖

<CodeGroup>
  ```bash npm theme={null}
  npx create-next-app@latest profy-demo --typescript --app --tailwind
  cd profy-demo
  npm install @profy-ai/sdk
  ```

  ```bash pnpm theme={null}
  pnpm create next-app profy-demo --typescript --app --tailwind
  cd profy-demo
  pnpm add @profy-ai/sdk
  ```

  ```bash bun theme={null}
  bun create next-app profy-demo --typescript --app --tailwind
  cd profy-demo
  bun add @profy-ai/sdk
  ```
</CodeGroup>

如果需要使用 Drizzle 持久化 Token，还需安装：

```bash theme={null}
npm install drizzle-orm postgres
npm install -D drizzle-kit
```

## Step 2: 配置环境变量

```env .env.local theme={null}
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
```

<Warning>
  永远不要将 `.env.local` 提交到版本控制。确保 `.gitignore` 包含该文件。
</Warning>

## Step 3: 初始化 SDK

<CodeGroup>
  ```typescript lib/profy.ts theme={null}
  import { ProfyApp } from "@profy-ai/sdk";

  export const profy = new ProfyApp({
    clientId: process.env.PROFY_APP_ID!,
    clientSecret: process.env.PROFY_APP_SECRET!,
  });
  ```

  ```python config.py theme={null}
  import os
  from profy_sdk import ProfyApp

  profy = ProfyApp(
      client_id=os.environ["PROFY_APP_ID"],
      client_secret=os.environ["PROFY_APP_SECRET"],
  )
  ```
</CodeGroup>

<Note>
  `ProfyApp` 实例应当作为单例在服务端使用。不要将 `clientSecret` 暴露给客户端。
</Note>

## Step 4: 创建 OAuth 登录页面

```tsx app/page.tsx theme={null}
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 回调

<CodeGroup>
  ```typescript app/api/callback/route.ts theme={null}
  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));
  }
  ```

  ```python main.py (FastAPI) theme={null}
  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
  ```
</CodeGroup>

<Tip>
  `exchangeCode` 返回的 `expiresAt` 是 access token 的过期时间戳，用于判断何时刷新。
</Tip>

## Step 6: Token 持久化

### 方案 A: 使用 DrizzleTokenStore（推荐）

SDK 提供了开箱即用的 Drizzle 集成：

<CodeGroup>
  ```typescript lib/token-store.ts theme={null}
  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);
  ```

  ```python token_store.py (SQLAlchemy) theme={null}
  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)
  ```
</CodeGroup>

在 schema 中导出 Token 表：

```typescript db/schema.ts theme={null}
export { profyOAuthToken } from "@profy-ai/sdk/contrib/drizzle";
```

运行 migration 创建表后，回调中的 `saveToken` 实现：

<CodeGroup>
  ```typescript lib/token-store.ts theme={null}
  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;
  }
  ```

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

### 方案 B: 加密 Cookie（轻量场景）

对于不需要数据库的简单场景，可以将 Token 加密后存入 HTTP-only cookie：

```typescript lib/token-cookie.ts theme={null}
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;
}
```

<Warning>
  Cookie 方案在 Token 刷新时需要同步更新 cookie，且无法服务端主动吊销。生产环境推荐方案 A。
</Warning>

## Step 7: 上报计费事件

<CodeGroup>
  ```typescript app/api/report-event/route.ts theme={null}
  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);
  }
  ```

  ```python main.py (FastAPI) theme={null}
  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
  ```
</CodeGroup>

客户端调用示例：

```tsx app/dashboard/page.tsx theme={null}
"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 抛出的错误均为类型化异常，可精确捕获：

<CodeGroup>
  ```typescript lib/report-with-error-handling.ts theme={null}
  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;
    }
  }
  ```

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

在 API Route 中统一处理：

```typescript app/api/report-event/route.ts theme={null}
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
```

## 常见问题

<details>
  <summary>回调收到 `invalid_redirect_uri` 错误？</summary>

  确保 `.env.local` 中的 `PROFY_CALLBACK_URL` 与 Profy 开发者后台配置的回调地址完全一致（包括协议、端口、路径）。
</details>

<details>
  <summary>Token 刷新后旧 Token 还能用吗？</summary>

  不能。`refreshToken` 调用后旧的 access token 和 refresh token 都会失效（rotation 机制）。务必在刷新后立即持久化新 Token。
</details>

<details>
  <summary>`reportEvent` 返回 `InsufficientBalance` 但用户刚充值了？</summary>

  余额更新有短暂缓存延迟（通常 \< 5s）。如果需要实时余额，可在充值回调后等待数秒再重试。
</details>

<details>
  <summary>如何在开发环境测试 OAuth 流程？</summary>

  在 Profy 开发者后台将 `http://localhost:3000/api/callback` 添加为合法回调地址。开发环境下 OAuth 流程与生产完全一致。
</details>

## 下一步

<CardGroup cols={2}>
  <Card title="按次计费 SaaS" icon="coins" href="/zh/sdk/cookbook/per-use-billing">
    PER\_USE 模式：固定价格按次扣费
  </Card>

  <Card title="AI 按量计费" icon="gauge-high" href="/zh/sdk/cookbook/ai-metered-billing">
    METERED 模式：调用 AI 模型按 token 计费
  </Card>

  <Card title="Webhook 事件监听" icon="bell" href="/zh/sdk/cookbook/webhook-events">
    接收 Profy 平台推送的用户事件通知
  </Card>

  <Card title="Token 管理最佳实践" icon="shield-check" href="/zh/sdk/cookbook/token-management">
    多用户、并发刷新、安全存储
  </Card>
</CardGroup>
