Sistine Starter

支付集成

Creem 支付平台集成完整指南

支付集成

Sistine Starter 使用 Creem 作为支付解决方案,支持订阅计划和一次性积分包购买。本文档详细介绍 Creem 的配置步骤、定价计划、Checkout 流程、Webhook 处理和安全机制。

Creem 简介

Creem 是一个现代化的支付平台,专为 SaaS 应用设计,具有以下特点:

  • 简洁 API: 清晰的 RESTful API 设计
  • 订阅管理: 内置订阅周期管理和自动续费
  • Webhook 通知: 实时支付状态更新
  • 安全可靠: HMAC-SHA256 签名验证
  • 开发友好: 支持测试模式

配置步骤

1. 获取 Creem API Key

  1. 访问 Creem Dashboard
  2. 创建账号并登录
  3. 导航到 Settings > API Keys
  4. 复制 API Key (用于创建 Checkout Session)

2. 配置环境变量

将以下环境变量添加到 .env.local:

# Creem API Key (必需)
CREEM_API_KEY="ck_live_xxxxxxxxxxxxxxxxxx"

# Webhook 签名密钥 (必需,在配置 Webhook 后获取)
CREEM_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxx"

# API 基础 URL (可选,默认为 https://api.creem.io)
CREEM_API_BASE="https://api.creem.io"

# 测试模式 (可选,开发环境使用)
CREEM_SIMULATE="true"  # 跳过实际支付,直接成功

开发环境测试模式:

设置 CREEM_SIMULATE="true" 可以在本地开发时跳过实际支付流程,直接模拟支付成功:

# .env.local (开发环境)
CREEM_SIMULATE="true"

生产环境:

# .env (生产环境)
CREEM_SIMULATE="false"  # 或删除此行

3. 创建产品和价格

在 Creem Dashboard 中创建产品:

订阅计划产品

产品名称类型价格周期
Starter MonthlySubscription$29.00Monthly
Starter YearlySubscription$290.00Yearly
Pro MonthlySubscription$99.00Monthly
Pro YearlySubscription$990.00Yearly

一次性积分包

产品名称类型价格
200 Credits PackOne-time$5.00

创建产品后,复制每个产品的 Product ID (prod_xxxx),并更新到 /Users/mac/Documents/product/sistine-starter-vibe-to-production/constants/billing.ts

4. 配置 Webhook

在 Creem Dashboard 中设置 Webhook:

  1. 导航到 Settings > Webhooks
  2. 点击 Add Endpoint
  3. 填写以下信息:
    • URL: https://your-domain.com/api/payments/creem/webhook
    • Events: 选择以下事件:
      • checkout.completed
      • subscription.paid
      • subscription.active
  4. 保存后复制 Webhook Secret (whsec_xxx)
  5. 更新环境变量 CREEM_WEBHOOK_SECRET

本地开发 Webhook 测试:

使用 ngrokCloudflare Tunnel 将本地服务暴露到公网:

# 使用 ngrok
ngrok http 3000

# Webhook URL 示例
https://abc123.ngrok.io/api/payments/creem/webhook

定价计划配置

所有定价计划配置在 /Users/mac/Documents/product/sistine-starter-vibe-to-production/constants/billing.ts:

月付订阅计划

月付计划会在支付成功后立即发放全部积分

export const subscriptionPlans: Record<PlanKey, SubscriptionPlan> = {
  starter_monthly: {
    key: "starter_monthly",
    kind: "subscription",
    priceCents: 2900,              // $29.00
    currency: "usd",
    creditsPerCycle: 1000,         // 每月 1000 积分
    cycle: "month",
    creemPriceId: "prod_6oSIwPL8m6scklr3fwdkC9", // Creem 产品 ID
    grantSchedule: { mode: "per_cycle" },        // 立即发放
  },
  pro_monthly: {
    key: "pro_monthly",
    kind: "subscription",
    priceCents: 9900,              // $99.00
    currency: "usd",
    creditsPerCycle: 10000,        // 每月 10000 积分
    cycle: "month",
    creemPriceId: "prod_5Xzh9qV5TWeTQtRxjZPEHM",
    grantSchedule: { mode: "per_cycle" },
  },
};
计划价格/月积分/月发放方式
Starter Monthly$291,000立即发放全部
Pro Monthly$9910,000立即发放全部

年付订阅计划 (分期发放)

年付计划采用分期发放机制:首次支付后立即发放第一个月的积分,后续每月自动发放剩余积分。

export const subscriptionPlans: Record<PlanKey, SubscriptionPlan> = {
  starter_yearly: {
    key: "starter_yearly",
    kind: "subscription",
    priceCents: 29000,             // $290.00 (年付)
    currency: "usd",
    creditsPerCycle: 12000,        // 全年总共 12000 积分
    cycle: "year",
    creemPriceId: "prod_2V1LbGt2bLmZpKgmASTiCN",
    grantSchedule: {
      mode: "installments",        // 分期发放模式
      grantsPerCycle: 12,          // 分 12 次发放
      intervalMonths: 1,           // 每月发放一次
      creditsPerGrant: 1000,       // 每次发放 1000 积分
      initialGrants: 1,            // 首次立即发放 1 次
    },
  },
  pro_yearly: {
    key: "pro_yearly",
    kind: "subscription",
    priceCents: 99000,             // $990.00 (年付)
    currency: "usd",
    creditsPerCycle: 120000,       // 全年总共 120000 积分
    cycle: "year",
    creemPriceId: "prod_2xyljTJW1IlT8FUDrucU3X",
    grantSchedule: {
      mode: "installments",
      grantsPerCycle: 12,
      intervalMonths: 1,
      creditsPerGrant: 10000,      // 每次发放 10000 积分
      initialGrants: 1,
    },
  },
};
计划价格/年总积分每月发放发放次数
Starter Yearly$29012,0001,00012 次
Pro Yearly$990120,00010,00012 次

年付分期发放流程:

用户购买年付计划

立即发放 1000/10000 积分 (第 1 个月)

记录到 subscriptionCreditSchedule 表

Cron 定时任务每小时检查

每月自动发放剩余积分 (共 11 次)

一次性积分包

一次性积分包支付成功后立即发放全部积分

export const oneTimePacks: Record<PackKey, OneTimePack> = {
  pack_200: {
    key: "pack_200",
    kind: "one_time",
    priceCents: 500,               // $5.00
    currency: "usd",
    credits: 200,                  // 200 积分
    creemPriceId: "prod_3SiroZeMbMQidMVFDMUzKy",
  },
};
积分包价格积分数发放方式
200 Credits Pack$5200立即发放

修改定价计划

如需调整价格或积分数量:

  1. 编辑 /Users/mac/Documents/product/sistine-starter-vibe-to-production/constants/billing.ts
  2. 修改对应计划的 priceCentscreditsPerCycle/credits
  3. 在 Creem Dashboard 中创建对应的新产品
  4. 更新 creemPriceId 为新产品的 ID

示例: 创建新的 500 积分包:

// constants/billing.ts
export type PackKey = "pack_200" | "pack_500"; // 添加新 Key

export const oneTimePacks: Record<PackKey, OneTimePack> = {
  pack_200: { /* ... */ },
  pack_500: {
    key: "pack_500",
    kind: "one_time",
    priceCents: 2000,              // $20.00
    currency: "usd",
    credits: 500,
    creemPriceId: "prod_YOUR_NEW_PRODUCT_ID",
  },
};

Checkout 流程

创建 Checkout Session

用户点击购买按钮时,前端调用 /api/payments/creem/checkout 创建支付会话。

API 端点: POST /api/payments/creem/checkout

请求体:

{
  "key": "starter_monthly" | "pro_yearly" | "pack_200",
  "successUrl": "https://yourdomain.com/dashboard?payment=success",
  "cancelUrl": "https://yourdomain.com/pricing?payment=cancelled"
}

响应:

{
  "url": "https://checkout.creem.io/xxxxxxxxxxxxx"
}

前端集成示例:

// components/pricing-card.tsx
"use client";

import { useState } from "react";
import { useSession } from "@/lib/auth-client";

export function PricingCard({ planKey }: { planKey: string }) {
  const session = useSession();
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    if (!session.data) {
      // 未登录,跳转到登录页
      window.location.href = "/login";
      return;
    }

    setLoading(true);

    try {
      const res = await fetch("/api/payments/creem/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          key: planKey,
          successUrl: `${window.location.origin}/dashboard?payment=success`,
          cancelUrl: `${window.location.origin}/pricing?payment=cancelled`,
        }),
      });

      const data = await res.json();

      if (data.url) {
        // 重定向到 Creem Checkout 页面
        window.location.href = data.url;
      } else {
        alert(data.error || "Failed to create checkout session");
      }
    } catch (error) {
      console.error("Checkout error:", error);
      alert("Something went wrong");
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleCheckout} disabled={loading}>
      {loading ? "Processing..." : "Purchase"}
    </button>
  );
}

Checkout 实现逻辑

核心实现位于 /Users/mac/Documents/product/sistine-starter-vibe-to-production/lib/payments/creem.ts:

export async function createCheckoutSession(params: CreateCheckoutParams): Promise<CreateCheckoutResult> {
  const apiKey = getEnv("CREEM_API_KEY");
  const simulate = process.env.CREEM_SIMULATE === "true";

  if (simulate) {
    // 测试模式:跳过实际支付
    return { url: "/api/payments/creem/redirect-placeholder?success=1" };
  }

  // 创建 Checkout Payload
  const payload = {
    product_id: params.creemPriceId, // Creem 产品 ID
    success_url: params.successUrl,
    metadata: {
      userId: params.userId,         // 用户 ID
      key: params.key,               // 计划标识
      kind: params.kind,             // "subscription" 或 "one_time"
    },
  };

  const base = process.env.CREEM_API_BASE || "https://api.creem.io";
  const endpointUrl = `${base}/v1/checkouts`;

  const res = await fetch(endpointUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": apiKey, // Creem 使用 x-api-key 头
    },
    body: JSON.stringify(payload),
  });

  if (!res.ok) {
    throw new Error(`Creem checkout create failed: ${res.status}`);
  }

  const data = await res.json();
  const redirectUrl = data.checkout_url || data.url;

  return { url: redirectUrl };
}

关键参数说明:

参数说明
product_idCreem 产品 ID (creemPriceId)
success_url支付成功后的重定向 URL
metadata.userId用户 ID,用于 Webhook 中识别用户
metadata.key计划标识 (starter_monthly, pack_200 等)
metadata.kind类型 (subscriptionone_time)

Webhook 配置和处理

Webhook 事件类型

Sistine Starter 监听以下 Creem Webhook 事件:

事件说明处理逻辑
checkout.completed支付成功记录支付、发放积分、创建订阅
subscription.paid订阅续费成功记录支付、发放积分
subscription.active订阅激活更新订阅状态为 active

Webhook 处理流程

Webhook 处理逻辑位于 /Users/mac/Documents/product/sistine-starter-vibe-to-production/app/api/payments/creem/webhook/route.ts:

export async function POST(req: NextRequest) {
  // 1. 读取原始请求体 (用于签名验证)
  const rawBody = await req.text();

  // 2. 验证 Webhook 签名
  const ok = verifyWebhookSignature(req.headers, rawBody);
  if (!ok) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // 3. 解析事件
  const event = JSON.parse(rawBody);
  const type = event.eventType; // "checkout.completed" 等
  const mainObject = event.object || {};
  const metadata = mainObject.metadata || {};

  // 4. 提取关键信息
  const userId = metadata.userId;
  const key = metadata.key;       // 计划标识
  const kind = metadata.kind;     // "subscription" 或 "one_time"
  const paymentId = mainObject.order?.id || event.id;
  const amountCents = mainObject.order?.amount || 0;

  // 5. 幂等性检查 (防止重复处理)
  const existing = await db
    .select()
    .from(paymentTable)
    .where(eq(paymentTable.providerPaymentId, paymentId));

  if (existing.length > 0) {
    return NextResponse.json({ received: true }); // 已处理,跳过
  }

  // 6. 计算积分发放数量
  let creditsToGrant = 0;
  if (kind === "one_time") {
    creditsToGrant = oneTimePacks[key].credits;
  } else if (kind === "subscription") {
    const plan = subscriptionPlans[key];
    const schedule = getGrantSchedule(key);

    if (schedule) {
      // 年付计划:仅发放首次积分
      const initialGrant = computeInitialGrant(schedule);
      creditsToGrant = initialGrant.creditsNow;
    } else {
      // 月付计划:发放全部积分
      creditsToGrant = plan.creditsPerCycle;
    }
  }

  // 7. 插入支付记录
  await db.insert(paymentTable).values({
    id: paymentId,
    provider: "creem",
    providerPaymentId: paymentId,
    userId,
    amountCents,
    currency: "usd",
    status: "succeeded",
    type: kind,
    planKey: kind === "subscription" ? key : undefined,
    creditsGranted: creditsToGrant,
  });

  // 8. 创建或更新订阅记录 (仅订阅类型)
  if (kind === "subscription") {
    await db.insert(subscriptionTable).values({
      id: subscriptionId,
      provider: "creem",
      providerSubId: subscriptionId,
      userId,
      planKey: key,
      status: "active",
      currentPeriodEnd: extractCurrentPeriodEnd(),
    });
  }

  // 9. 发放积分 (事务性操作)
  await db.transaction(async tx => {
    // 增加用户积分
    await tx
      .update(userTable)
      .set({ credits: sql`${userTable.credits} + ${creditsToGrant}` })
      .where(eq(userTable.id, userId));

    // 记录到积分账本
    await tx.insert(creditLedger).values({
      id: paymentId,
      userId,
      delta: creditsToGrant,
      reason: kind === "one_time" ? "one_time_pack" : "subscription_cycle",
      paymentId,
    });

    // 更新用户订阅计划
    if (kind === "subscription") {
      await tx
        .update(userTable)
        .set({ planKey: key })
        .where(eq(userTable.id, userId));
    }

    // 设置积分发放调度 (年付计划)
    if (scheduleResetContext) {
      await resetSubscriptionSchedule({
        subscriptionId,
        userId,
        derivedSchedule: schedule,
        grantsRemaining: initialGrant.grantsRemaining,
        totalCreditsRemaining: initialGrant.totalCreditsRemaining,
        nextGrantAt: initialGrant.nextGrantAt,
      });
    }
  });

  // 10. 发送购买确认邮件
  await sendPurchaseEmail(userEmail, {
    orderId: paymentId,
    plan: key,
    amount: `$${(amountCents / 100).toFixed(2)}`,
    credits: creditsToGrant,
    type: kind,
  });

  return NextResponse.json({ received: true });
}

关键处理逻辑

1. 幂等性保护

通过 providerPaymentId 确保同一支付不会被重复处理:

const existing = await db
  .select()
  .from(paymentTable)
  .where(eq(paymentTable.providerPaymentId, paymentId));

if (existing.length > 0) {
  return NextResponse.json({ received: true }); // 已处理
}

2. 积分发放策略

  • 月付订阅: 立即发放 creditsPerCycle 全部积分
  • 年付订阅: 仅发放 creditsPerGrant (首次),剩余通过定时任务发放
  • 一次性积分包: 立即发放 credits 全部积分

3. 订阅记录管理

仅为 subscription 类型创建 subscription 表记录:

if (kind === "subscription" && subscriptionId) {
  await db.insert(subscriptionTable).values({
    id: subscriptionId,
    provider: "creem",
    providerSubId: subscriptionId,
    userId,
    planKey: key,
    status: "active",
    currentPeriodEnd: extractedDate,
  });
}

年付分期发放机制详解

年付订阅采用分期发放机制,避免用户一次性获得全年积分后立即取消订阅。

数据库表结构

CREATE TABLE "subscription_credit_schedule" (
  "subscription_id" TEXT PRIMARY KEY,
  "user_id" TEXT NOT NULL,
  "credits_per_grant" INTEGER NOT NULL,    -- 每次发放的积分数
  "grants_remaining" INTEGER NOT NULL,     -- 剩余发放次数
  "total_credits_remaining" INTEGER NOT NULL, -- 剩余总积分
  "next_grant_at" TIMESTAMP NOT NULL,      -- 下次发放时间
  "created_at" TIMESTAMP DEFAULT NOW(),
  "updated_at" TIMESTAMP DEFAULT NOW()
);

发放流程

步骤 1: 首次支付

用户购买年付计划时:

// 计算首次发放
const initialGrant = computeInitialGrant(schedule);

// 立即发放首次积分 (1000 或 10000)
creditsToGrant = initialGrant.creditsNow; // 1000 (Starter) 或 10000 (Pro)

// 创建发放调度记录
await resetSubscriptionSchedule({
  subscriptionId,
  userId,
  derivedSchedule: schedule,
  grantsRemaining: 11,                     // 剩余 11 次
  totalCreditsRemaining: 11000,            // 剩余 11000 积分 (Starter)
  nextGrantAt: new Date("2025-11-15"),     // 30 天后
});

subscriptionCreditSchedule 表记录:

subscriptionId: "sub_xxx"
userId: "user_123"
creditsPerGrant: 1000
grantsRemaining: 11
totalCreditsRemaining: 11000
nextGrantAt: 2025-11-15T00:00:00Z

步骤 2: 定时任务自动发放

定时任务 /Users/mac/Documents/product/sistine-starter-vibe-to-production/app/api/cron/grant-subscription-credits/route.ts 每小时执行一次:

export async function GET(req: NextRequest) {
  // 1. 验证 Cron 认证
  const authHeader = req.headers.get("authorization");
  const cronSecret = process.env.CRON_SECRET;

  if (authHeader !== `Bearer ${cronSecret}`) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // 2. 查询到期的发放任务
  const now = new Date();
  const schedules = await db
    .select()
    .from(subscriptionCreditSchedule)
    .where(
      sql`${subscriptionCreditSchedule.nextGrantAt} <= ${now}
          AND ${subscriptionCreditSchedule.grantsRemaining} > 0`
    );

  // 3. 逐个处理
  for (const schedule of schedules) {
    await db.transaction(async tx => {
      // 发放积分
      await tx
        .update(userTable)
        .set({
          credits: sql`${userTable.credits} + ${schedule.creditsPerGrant}`
        })
        .where(eq(userTable.id, schedule.userId));

      // 记录到账本
      await tx.insert(creditLedger).values({
        id: randomUUID(),
        userId: schedule.userId,
        delta: schedule.creditsPerGrant,
        reason: "subscription_cycle",
        paymentId: schedule.subscriptionId,
      });

      // 更新调度记录
      const newGrantsRemaining = schedule.grantsRemaining - 1;
      const newTotalRemaining = schedule.totalCreditsRemaining - schedule.creditsPerGrant;

      if (newGrantsRemaining > 0) {
        // 还有剩余,计算下次发放时间
        const nextGrantAt = new Date(schedule.nextGrantAt);
        nextGrantAt.setMonth(nextGrantAt.getMonth() + 1); // 30 天后

        await tx
          .update(subscriptionCreditSchedule)
          .set({
            grantsRemaining: newGrantsRemaining,
            totalCreditsRemaining: newTotalRemaining,
            nextGrantAt,
            updatedAt: new Date(),
          })
          .where(eq(subscriptionCreditSchedule.subscriptionId, schedule.subscriptionId));
      } else {
        // 全部发放完毕,删除调度记录
        await tx
          .delete(subscriptionCreditSchedule)
          .where(eq(subscriptionCreditSchedule.subscriptionId, schedule.subscriptionId));
      }
    });
  }

  return NextResponse.json({ processed: schedules.length });
}

步骤 3: 部署 Cron Job

在 Vercel 或其他平台上配置定时任务:

Vercel Cron (推荐):

vercel.json 中添加:

{
  "crons": [
    {
      "path": "/api/cron/grant-subscription-credits",
      "schedule": "0 * * * *"
    }
  ]
}

外部 Cron 服务 (如 cron-job.org):

# 每小时执行一次
0 * * * * curl -H "Authorization: Bearer YOUR_CRON_SECRET" \
  https://yourdomain.com/api/cron/grant-subscription-credits

环境变量:

CRON_SECRET="your-random-cron-secret-key"

发放时间线示例

Starter Yearly ($290, 12000 积分) 为例:

2025-10-15  用户购买 → 立即发放 1000 积分
2025-11-15  定时任务 → 发放 1000 积分 (剩余 10 次)
2025-12-15  定时任务 → 发放 1000 积分 (剩余 9 次)
2026-01-15  定时任务 → 发放 1000 积分 (剩余 8 次)
...
2026-09-15  定时任务 → 发放 1000 积分 (最后一次,删除调度记录)

订阅续费处理

当年付订阅续费时,Webhook 会再次触发 subscription.paid 事件:

// Webhook 处理逻辑
if (scheduleResetContext) {
  // 重置发放调度 (重新开始 12 次发放)
  await resetSubscriptionSchedule({
    subscriptionId,
    userId,
    derivedSchedule: schedule,
    grantsRemaining: 11,
    totalCreditsRemaining: 11000,
    nextGrantAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });
}

这样可以确保每次续费后重新开始 12 个月的分期发放周期。

安全机制

1. Webhook 签名验证

Creem 使用 HMAC-SHA256 签名确保 Webhook 来源可信:

export function verifyWebhookSignature(headers: Headers, rawBody: string): boolean {
  // 获取签名头
  const signature = headers.get("creem-signature") || headers.get("x-creem-signature");
  if (!signature) {
    return false;
  }

  const webhookSecret = process.env.CREEM_WEBHOOK_SECRET;
  if (!webhookSecret) {
    return false;
  }

  // 计算预期签名
  const computedSignature = crypto
    .createHmac("sha256", webhookSecret)
    .update(rawBody)
    .digest("hex");

  // 时序安全比较 (防止时序攻击)
  const sigBuf = Buffer.from(signature);
  const compBuf = Buffer.from(computedSignature);

  if (sigBuf.length !== compBuf.length) {
    return false;
  }

  return crypto.timingSafeEqual(sigBuf, compBuf);
}

签名验证流程:

Creem 发送 Webhook

计算 HMAC-SHA256(rawBody, CREEM_WEBHOOK_SECRET)

在 creem-signature 头中附带签名

我们的服务器重新计算签名

比较两个签名 (timing-safe)

验证通过 → 处理事件
验证失败 → 返回 400 错误

2. 幂等性保护

通过 providerPaymentId 防止重复处理同一支付:

const existing = await db
  .select()
  .from(paymentTable)
  .where(eq(paymentTable.providerPaymentId, paymentId));

if (existing.length > 0) {
  // 已处理,直接返回成功
  return NextResponse.json({ received: true });
}

场景:

  • Creem 重试 Webhook (网络故障)
  • 同时收到 checkout.completedsubscription.paid (同一支付)

3. 事务性操作

积分发放、账本记录、订阅更新在同一数据库事务中完成:

await db.transaction(async tx => {
  // 1. 增加积分
  await tx.update(userTable).set({ credits: sql`...` });

  // 2. 记录账本
  await tx.insert(creditLedger).values({ ... });

  // 3. 更新订阅
  await tx.update(userTable).set({ planKey: key });

  // 4. 设置发放调度
  await resetSubscriptionSchedule(...);
});

确保以上操作要么全部成功,要么全部失败,避免数据不一致。

4. 元数据验证

验证 Webhook 中的 metadata 包含必需字段:

const userId = metadata?.userId;
const key = metadata?.key;
const kind = metadata?.kind;

if (!userId || !key || !kind) {
  return NextResponse.json({ error: "Missing metadata" }, { status: 400 });
}

5. Cron 端点保护

定时任务端点通过 CRON_SECRET 保护:

const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;

if (authHeader !== `Bearer ${cronSecret}`) {
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

测试环境设置

使用 CREEM_SIMULATE

在本地开发时,设置 CREEM_SIMULATE="true" 跳过实际支付:

# .env.local
CREEM_SIMULATE="true"

行为:

  • createCheckoutSession 返回本地模拟端点
  • 点击购买后立即重定向到成功页面
  • 不会调用 Creem API,不会产生实际费用

模拟 Webhook:

创建脚本手动触发 Webhook:

# test-webhook.sh
#!/bin/bash

WEBHOOK_URL="http://localhost:3000/api/payments/creem/webhook"
WEBHOOK_SECRET="whsec_test_secret"

PAYLOAD='{
  "id": "evt_test_123",
  "eventType": "checkout.completed",
  "object": {
    "id": "checkout_test_123",
    "metadata": {
      "userId": "user_test_123",
      "key": "starter_monthly",
      "kind": "subscription"
    },
    "order": {
      "id": "order_test_123",
      "amount": 2900,
      "currency": "USD",
      "subscription_id": "sub_test_123"
    }
  }
}'

SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')

curl -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -H "creem-signature: $SIGNATURE" \
  -d "$PAYLOAD"

常见问题

Q: Webhook 没有被触发怎么办?

排查步骤:

  1. 检查 Creem Dashboard 中的 Webhook 配置是否正确
  2. 查看 Webhook 日志,确认是否发送成功
  3. 检查 CREEM_WEBHOOK_SECRET 是否匹配
  4. 确认服务器可以从公网访问 (使用 ngrok 测试)
  5. 查看服务器日志,确认请求是否到达

Q: 签名验证失败?

常见原因:

  • CREEM_WEBHOOK_SECRET 配置错误
  • 请求体被中间件修改 (需要使用原始 rawBody)
  • 字符编码问题

解决方法:

// 确保读取原始请求体
const rawBody = await req.text();

// 不要使用 req.json(),会丢失原始字节

Q: 如何测试年付分期发放?

方法 1: 修改时间间隔

临时修改 constants/billing.ts:

grantSchedule: {
  mode: "installments",
  grantsPerCycle: 12,
  intervalMonths: 0.001, // 约 43 秒 (仅用于测试!)
  creditsPerGrant: 1000,
  initialGrants: 1,
}

方法 2: 手动触发 Cron

curl -H "Authorization: Bearer YOUR_CRON_SECRET" \
  http://localhost:3000/api/cron/grant-subscription-credits

Q: 如何查看支付历史?

import { db } from "@/lib/db";
import { payment } from "@/lib/db/schema";
import { eq, desc } from "drizzle-orm";

// 查询用户的所有支付记录
const payments = await db
  .select()
  .from(payment)
  .where(eq(payment.userId, userId))
  .orderBy(desc(payment.createdAt));

Q: 如何处理退款?

Creem 的退款需要在 Dashboard 中手动操作:

  1. 在 Creem Dashboard 中找到对应订单
  2. 点击 "Refund" 并确认
  3. Creem 会发送 refund.created Webhook (可选监听)
  4. 手动扣除用户积分 (或实现自动退款逻辑)

手动扣除积分:

import { deductCredits } from "@/lib/credits";

await deductCredits(userId, 1000, "refund", paymentId);

Q: 如何取消订阅?

用户可以在仪表板中取消订阅,或管理员在后台操作:

import { db } from "@/lib/db";
import { subscription } from "@/lib/db/schema";
import { eq } from "drizzle-orm";

await db
  .update(subscription)
  .set({ status: "cancelled" })
  .where(eq(subscription.id, subscriptionId));

Q: 积分发放失败怎么办?

查看 creditLedger 表确认是否记录:

const ledger = await db
  .select()
  .from(creditLedger)
  .where(eq(creditLedger.paymentId, paymentId));

if (ledger.length === 0) {
  // 未记录,需要手动补发
  await refundCredits(userId, amount, "subscription_cycle", paymentId);
}

最佳实践

1. 监控 Webhook 日志

定期检查 Webhook 处理日志,确保所有事件都被正确处理:

// 添加日志记录
console.log(`[Creem Webhook] Processed ${type} for user ${userId}`);

2. 设置告警

监控以下指标:

  • Webhook 签名验证失败次数
  • 支付记录重复次数
  • 积分发放失败次数

3. 定期审计

每月审计支付记录和积分账本,确保数据一致:

// 检查积分账本总和是否匹配用户余额
const ledgerSum = await db
  .select({ total: sql`SUM(delta)` })
  .from(creditLedger)
  .where(eq(creditLedger.userId, userId));

const userCredits = await getUserCredits(userId);

if (ledgerSum[0].total !== userCredits) {
  // 数据不一致,需要修复
}

4. 提供清晰的支付反馈

在成功页面显示支付详情:

// app/[locale]/dashboard/page.tsx
const searchParams = useSearchParams();
const paymentSuccess = searchParams.get("payment") === "success";

if (paymentSuccess) {
  return (
    <div className="bg-green-50 p-4 rounded">
      <h2>Payment Successful!</h2>
      <p>Your credits have been added to your account.</p>
    </div>
  );
}

5. 处理边界情况

  • 用户在支付过程中关闭页面
  • Webhook 延迟到达
  • 订阅在发放期间取消

相关文档

总结

Creem 支付集成为 Sistine Starter 提供了完整的订阅和一次性购买能力,通过:

  • 灵活定价: 支持月付、年付和积分包
  • 分期发放: 年付计划分期发放,降低滥用风险
  • 安全可靠: 签名验证、幂等性保护、事务性操作
  • 自动化: Webhook 自动处理支付,Cron 自动发放积分

遵循本文档的配置步骤和最佳实践,你可以快速为你的 SaaS 应用集成完整的支付功能。