黑白梦黑白梦

  • 文章
  • 专栏
  • 文章
  • 专栏
全部文章

Creem 支付结算全流程实战笔记

发布于 2026-10-04约 50 分钟

Creem 是一款面向数字商品与 SaaS 软件的商户记录服务商兼支付结算平台,提供全球税收代缴、托管收银台、数字商品交付等服务。本文记录使用 Test Mode 走通商户凭证配置、商品生命周期管理、托管收银台对接、Webhook 验签履约与售后退款的端到端流程实战。

官方文档与参考链接:Creem 官方文档 与 Creem 控制台。

核心机制与架构模型

名义商户(MoR)模式解析

在传统支付网关模式下,商户自身作为交易法律主体,必须自行申报和代缴买家所在国或地区的增值税(VAT)、数字服务税(DST)和美国各州销售税(Sales Tax)。

Creem 作为名义商户(Merchant of Record)介入交易流程:

  • 交易法律主体:买家直接向 Creem 支付货款,Creem 作为法律认可的商品销售方开具发票与合规凭证。
  • 税务合规处理:Creem 负责自动识别买家 IP 与国家,在基准售价之外另行计算并代缴当地税费。
  • 结算与资金流:Creem 定期扣除渠道手续费与代缴税费后,将净收入结算至商户账户。

端到端交互架构与时序

为兼顾用户前台操作的即时响应与分布式网络抖动下的可靠履约,系统将支付流程解耦为“会话创建与收银台重定向”和“双通道支付确认与幂等履约”两个核心阶段:

结账会话创建与收银台重定向

客户端在发起付费流程时,不直接与外部支付接口通信,所有敏感调用与密钥交互均由服务端代理完成:

节点与流程说明:

  • 元数据绑定:服务端在创建结账会话时,将业务核心实体(如用户 userId 与产品 productId)注入 metadata,确保支付上下文全程透传且防范篡改。
  • 凭证安全隔离:商户 API Key 仅保留在服务端,客户端仅接收 Creem 生成的一次性收银台 URL,规避前端凭证泄露风险。

支付结果确认与双通道履约

在用户完成付款后,系统采用“前台同步回跳主动反查”与“后台 Webhook 异步兜底”双通道机制,确保数据交付的时效性与最终一致性:

节点与流程说明:

  • 前台同步回跳通道:用户在收银台付款完成后立即触发,服务端中继路由在校验签名后主动反查 Creem 会话状态,核验通过后直接执行本地履约,免除用户等待异步队列延迟,提升前台转化与交付体验。
  • 服务端 Webhook 兜底通道:防范用户在支付成功后立即关闭浏览器、网络偶发抖动或会话丢失等异常边界,充当最终数据一致性的核心兜底防线。
  • 幂等持久化协调机制:两条链路均以外部系统的唯一会话标识 checkout_id 作为核心幂等键。数据库购买表设置唯一索引约束,先到达的请求完成单据入库与权益激活,后到达的请求被唯一键约束拦截并直接返回成功,防止单据重复写入。

环境准备与平台配置

开发者凭证与模式切换

在 Creem 开发者平台控制台中切换环境并提取核心凭证:

  1. 登录控制台,开启底部 Test Mode 开关。
  2. 进入 Developers 页面,生成并复制测试用 API Key(以 creem_test_ 前缀开头)。
  3. 进入 Developers > Webhooks 页面,添加 Webhook 接收端点,并妥善保存签名密钥(Signing Secret)。

编辑环境变量配置文件 .env:

Bash
# Creem 服务端 API 认证密钥
CREEM_API_KEY="creem_test_xxxxxxxxxxxxxxxxxxxxxxxx"

# Creem Webhook 原始请求体签名校验密钥
CREEM_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"

# 运行模式: test 或 live
CREEM_MODE="test"

Webhook 本地联调穿透

由于 Creem 服务端回调要求公网可达的 HTTPS 地址,本地开发时需借助隧道工具(如 Cloudflare Tunnel 或 ngrok)建立反向代理:

启动本地端口映射:

Bash
ngrok http 3000

将穿透生成的公网域名配置在 Creem 控制台 Webhooks 列表中:

  • 监听 URL:https://<your-tunnel-subdomain>.ngrok-free.app/api/webhooks/creem
  • 监听事件:勾选 checkout.completed、refund.created、dispute.created

测试模式卡片表单填写规则

在 Creem 的测试模式(Test Mode)下,托管收银台页面中的信用卡表单各字段填写规范如下:

字段名称 填写要求 推荐填写示例
Card number(卡号) 使用 Creem 官方分配的测试卡号 4111 1111 1111 1111
MM/YY(有效期) 任意处于未来有效期的月份和年份 12/30
CVC/CVV(安全码) 任意 3 位数字 123
Cardholder Name(持卡人姓名) 任意英文字符或文本 Test User

常用测试卡号与场景模拟

Creem 支持通过不同的特定卡号模拟各种线上支付结果与异常边界,便于验证系统容错机制:

  • 支付成功(Successful payment):使用 4111 1111 1111 1111 或 4242 4242 4242 4242,用于验证正常结账、回跳以及 Webhook 幂等履约链路。
  • 银行拒付(Card declined):使用 4507 9900 0000 0028,模拟发卡行拒绝扣款时的业务异常反馈。
  • 余额不足(Insufficient funds):使用 4507 9900 0000 0010,模拟账户可用额度不足场景。
  • 安全码验证失败(Incorrect CVC):使用 4507 9900 0000 0044,模拟用户输入错误安全码时的收银台就地拦截提示。

在联调验证时,卡号填入对应场景卡号后,补全未来有效期(如 12/30)、安全码(如 123)及持卡人姓名(如 Test User)即可直接触发对应结算分支。


SDK 集成与客户端封装

安装依赖

官方提供了适配 Node.js / TypeScript 环境的 SDK:

Bash
npm install creem

抽象客户端接口

为保障系统各层解耦及单元测试可模拟(Mock),先定义抽象客户端接口与标准交互实体:

TypeScript
// app/lib/creem.server.ts
import { Creem } from "creem";
import crypto from "node:crypto";

export interface CreateProductParams {
  name: string;
  description: string;
  price: number;
}

export interface CreemProductResult {
  id: string;
}

export interface CreateCheckoutParams {
  productId: string;
  customerEmail?: string;
  successUrl: string;
  metadata: {
    userId: string;
    productId: string;
    [key: string]: any;
  };
}

export interface CreemCheckoutResult {
  id: string;
  checkoutUrl?: string;
  status?: string;
}

export interface CreemCheckoutEntity {
  id: string;
  status: "pending" | "processing" | "completed" | "expired" | string;
  checkoutUrl?: string;
  product?: string | { id: string };
  order?: { id: string } | null;
  customer?: string | { id: string } | null;
  metadata?: {
    userId?: string;
    productId?: string;
    [key: string]: any;
  };
  [key: string]: any;
}

export interface CreemClient {
  createProduct(params: CreateProductParams): Promise<CreemProductResult>;
  updateProductPrice(productId: string, price: number): Promise<void>;
  createCheckout(params: CreateCheckoutParams): Promise<CreemCheckoutResult>;
  getCheckout(checkoutId: string): Promise<CreemCheckoutEntity>;
  searchTransactions?(orderId: string): Promise<any[]>;
  refundPayment?(transactionId: string): Promise<{ status: string }>;
}

默认客户端实现与依赖注入

编写默认客户端实现类,提供单例实例导出与单元测试替换入口:

TypeScript
// app/lib/creem.server.ts (续)
class DefaultCreemClient implements CreemClient {
  private sdk: Creem;

  constructor(apiKey: string) {
    this.sdk = new Creem({
      apiKey,
      server: process.env.CREEM_MODE === "live" ? "production" : "test",
    });
  }

  async createProduct(params: CreateProductParams): Promise<CreemProductResult> {
    try {
      const product = await this.sdk.products.create({
        name: params.name,
        description: params.description,
        price: params.price,
        currency: "USD",
        billingType: "onetime",
        taxCategory: "digital-goods-service",
        taxMode: "exclusive",
      });
      return { id: product.id };
    } catch (err) {
      throw new Error(`Creem 创建商品失败:${err instanceof Error ? err.message : String(err)}`);
    }
  }

  async updateProductPrice(productId: string, price: number): Promise<void> {
    try {
      await this.sdk.products.update(productId, { price });
    } catch (err) {
      throw new Error(`Creem 更新价格失败:${err instanceof Error ? err.message : String(err)}`);
    }
  }

  async createCheckout(params: CreateCheckoutParams): Promise<CreemCheckoutResult> {
    try {
      const checkout = await this.sdk.checkouts.create({
        productId: params.productId,
        customer: params.customerEmail ? { email: params.customerEmail } : undefined,
        successUrl: params.successUrl,
        metadata: params.metadata,
      });
      return {
        id: checkout.id,
        checkoutUrl: checkout.checkoutUrl,
        status: checkout.status,
      };
    } catch (err) {
      throw new Error(`Creem 创建结账会话失败:${err instanceof Error ? err.message : String(err)}`);
    }
  }

  async getCheckout(checkoutId: string): Promise<CreemCheckoutEntity> {
    try {
      const checkout = await this.sdk.checkouts.retrieve(checkoutId);
      return checkout as CreemCheckoutEntity;
    } catch (err) {
      throw new Error(`Creem 查询结账信息失败:${err instanceof Error ? err.message : String(err)}`);
    }
  }

  async searchTransactions(orderId: string): Promise<any[]> {
    try {
      const response = await this.sdk.transactions.search(undefined, orderId);
      const items: any[] = [];
      for await (const page of response) {
        if (page.result?.items) {
          items.push(...page.result.items);
        }
      }
      return items;
    } catch (err) {
      throw new Error(`Creem 查询交易列表失败:${err instanceof Error ? err.message : String(err)}`);
    }
  }

  async refundPayment(transactionId: string): Promise<{ status: string }> {
    try {
      const res = await this.sdk.transactions.refund({ transactionId });
      return { status: res.status };
    } catch (err) {
      throw new Error(`Creem 发起退款失败:${err instanceof Error ? err.message : String(err)}`);
    }
  }
}

let activeClient: CreemClient | null = null;

export function setCreemClient(client: CreemClient | null) {
  activeClient = client;
}

export function getCreemClient(): CreemClient {
  if (activeClient) {
    return activeClient;
  }
  const apiKey = process.env.CREEM_API_KEY;
  if (!apiKey || !apiKey.trim()) {
    throw new Error("环境变量缺少 CREEM_API_KEY 配置");
  }
  return new DefaultCreemClient(apiKey.trim());
}

商品映射与价格生命周期管理

在 SaaS 与数字内容商品中,数据通常划分为免费与付费两类。推荐采用“每款产品对应一商品”(One-to-One Product Mapping)策略,在产品首次发布上架时同步到 Creem,结账时直接通过 productId 关联,避免在客户端滥用动态传价 custom_price 带来被篡改的风险。

可售商品范围

Creem 只承接能在平台内完成交付的数字商品。实物商品在禁止清单内,不能把 Creem 用作实物交易的收款渠道。

官方说明:Account Reviews 列出允许与禁止的商品;Introduction 将课程、模板与软件许可证列为可售数字商品。

  • 允许交付:软件与 SaaS、电子书、PDF、设计素材、图片、音频、视频,以及课程、模板、软件许可证。
  • 禁止交易:任何实物商品。同一店铺代售他人商品的市场、没有对应商品的捐赠,以及商户不持有授权或知识产权的内容,也在禁止范围内。
  • 本集成范围:课程按一次性数字商品创建,税目为 digital-goods-service,结账只提交已创建的 productId。

标价币种约束

创建商品时,currency 只接受 USD 与 EUR。人民币的 ISO 4217 代码是 CNY(日常也称 RMB),港币代码是 HKD,二者都不在 ProductCurrency 枚举中,不能作为商品标价。

官方说明:Create Your First Product 写明顾客按商品币种扣款,当前支持美元与欧元;Create Product 将 ProductCurrency 限定为 EUR、USD。

  • 可选标价:USD、EUR。创建商品时写入 currency,结账按该币种扣款。更新价格只提交 price,币种在创建时确定。
  • 金额单位:price 使用所选币种的最小单位。美元与欧元均为分(cents)。合法取值为 0(免费商品),或不小于 100(1 个完整货币单位)。1999 表示 19.99 美元。
  • 人民币与港币:CNY、HKD 不能写入商品 currency。业务目录若以人民币维护价格,同步前需换算为美元或欧元的最小单位,并在本系统内固定同一标价币种。
  • 结算币种:商品标价与商户到账币种相互独立。打款按已登记收款账户的币种执行;账户币种与扣款币种不一致时,由银行合作方兑换并收取转换费。中国与香港均在商户收款支持地区内。中国个人商户可通过支付宝接收人民币,单笔上限 50,000 CNY,年度可接收额度在 300,000 至 600,000 CNY。详见 Payouts 与 Supported Countries。

本集成创建商品时固定传入 currency: "USD",price 直接使用已换算的美分整数。

商品发布联动规则

实现发布逻辑:产品为免费规格时直接忽略外部同步;付费产品若尚无映射商品则调用 Creem 接口创建一次性商品,将生成的 id 持久化在数据库中:

TypeScript
// app/services/creemProductService.ts
import { getCreemClient } from "~/lib/creem.server";

export interface SyncProductParams {
  id: number;
  title: string;
  description: string;
  price: number;
  creemProductId: string | null;
}

export async function syncProductOnPublish(
  product: SyncProductParams
): Promise<string | null> {
  // 免费产品无需在 Creem 上架
  if (product.price <= 0) {
    return null;
  }

  // 若已存在 Creem 商品映射则保持复用
  if (product.creemProductId) {
    return product.creemProductId;
  }

  const client = getCreemClient();
  const created = await client.createProduct({
    name: product.title,
    description: product.description,
    price: product.price,
  });

  return created.id;
}

价格变更同步

产品在运营过程中需要调整标价时,处理分支如下:

  • 调整后价格为 0:按免费产品处理,不调用远程改价。
  • 调整后价格大于 0 且已绑定商品:调用 Creem API 同步更新商品价格。
  • 由免费调整为付费(已发布状态):远程创建新商品并记录编号。
TypeScript
// app/services/creemProductService.ts (续)
export async function syncProductOnPriceChange(
  product: SyncProductParams & { isPublished: boolean },
  newPrice: number
): Promise<{ creemProductId: string | null }> {
  if (newPrice <= 0) {
    return { creemProductId: product.creemProductId };
  }

  const client = getCreemClient();

  if (product.creemProductId) {
    await client.updateProductPrice(product.creemProductId, newPrice);
    return { creemProductId: product.creemProductId };
  }

  if (product.isPublished) {
    const created = await client.createProduct({
      name: product.title,
      description: product.description,
      price: newPrice,
    });
    return { creemProductId: created.id };
  }

  return { creemProductId: null };
}

托管结账流程实现

创建结账会话

结账逻辑运行在服务端路由动作(Action)中。系统首先校验用户是否已拥有权限,未拥有则拉取商品映射标识,调用 Creem 创建结账会话并回传跳转地址。

TypeScript
// app/routes/products.$id.purchase.ts
import { redirect } from "react-router";
import { getCreemClient } from "~/lib/creem.server";
import { findProductById } from "~/services/productService";
import { hasUserAccess } from "~/services/entitlementService";

export async function action({ request, params }: { request: Request; params: { id: string } }) {
  const user = await requireAuth(request);
  const product = await findProductById(Number(params.id));

  if (!product || !product.creemProductId) {
    throw new Response("产品未就绪或未设置付费商品", { status: 400 });
  }

  // 幂等防御:已拥有产品权限的用户直接回跳产品交付页
  const alreadyPurchased = await hasUserAccess(user.id, product.id);
  if (alreadyPurchased) {
    return redirect(`/products/${product.id}`);
  }

  const client = getCreemClient();
  const url = new URL(request.url);
  const successUrl = `${url.origin}/products/${product.id}/payment-callback`;

  // 创建收银台 Session,并挂载业务元数据
  const checkout = await client.createCheckout({
    productId: product.creemProductId,
    customerEmail: user.email,
    successUrl,
    metadata: {
      userId: String(user.id),
      productId: String(product.id),
    },
  });

  if (!checkout.checkoutUrl) {
    throw new Error("无法获取结账跳转链接");
  }

  return redirect(checkout.checkoutUrl);
}

关键参数与字段说明:

  • productId:Creem 侧预设的标准化商品 ID。
  • customerEmail:将当前登录用户的注册邮箱预填入收银台,锁定支付主体。
  • successUrl:支付成功后收银台自动重定向回目标系统的绝对 URL。
  • metadata:透传业务关键上下文(如 userId 和 productId),将在回跳参数与 Webhook 事件中原样带回,作为后续权限开通的核心凭证。

回跳验证与前端即时呈现

用户在收银台付款完成后,Creem 会向 successUrl 追加回调参数及签名。服务端加载器必须验证签名,并主动向 Creem 确认订单状态。

回跳签名算法实现

Creem 回跳签名的计算规则为:将除 signature 外的所有查询参数按传入顺序以 key=value 拼接,用竖线 | 分隔,并在末尾附加 salt={CREEM_API_KEY},最终进行标准 SHA-256 哈希计算。

TypeScript
// app/lib/creem.server.ts (续)
export function computeReturnSignature(
  params: URLSearchParams | string,
  apiKey: string
): string {
  const rawParams = typeof params === "string" ? new URLSearchParams(params) : params;
  const entries = Array.from(rawParams.entries());

  // 剔除名为 signature 的参数项
  const nonSigEntries = entries.filter(([k]) => k.toLowerCase() !== "signature");
  const parts = nonSigEntries.map(([k, v]) => `${k}=${v}`);
  parts.push(`salt=${apiKey}`);
  
  const signString = parts.join("|");
  return crypto.createHash("sha256").update(signString).digest("hex");
}

export function verifyReturnSignature(
  params: URLSearchParams | string,
  signature: string,
  apiKey?: string
): boolean {
  const key = apiKey ?? process.env.CREEM_API_KEY;
  if (!key || !signature) {
    return false;
  }
  const expected = computeReturnSignature(params, key);
  if (expected.length !== signature.length) {
    return false;
  }
  // 采用时间恒定比较防止时序侧信道反推
  try {
    return crypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(signature, "utf8"));
  } catch {
    return false;
  }
}

回调处理路由

在支付成功回跳页面中执行验签、主动查询与即时履约:

TypeScript
// app/routes/products.$id.payment-callback.ts
import { verifyReturnSignature, getCreemClient } from "~/lib/creem.server";
import { fulfillPersonalCheckout } from "~/services/purchaseService";

export async function loader({ request, params }: { request: Request; params: { id: string } }) {
  const url = new URL(request.url);
  const signature = url.searchParams.get("signature");
  const checkoutId = url.searchParams.get("checkout_id");

  if (!signature || !checkoutId) {
    return { status: "invalid", message: "缺少回跳校验参数" };
  }

  // 1. 验证签名完整性
  const isValid = verifyReturnSignature(url.searchParams, signature);
  if (!isValid) {
    return { status: "invalid", message: "回跳签名验证失败" };
  }

  // 2. 主动调用 API 查询结账状态
  const client = getCreemClient();
  const checkout = await client.getCheckout(checkoutId);

  if (checkout.status !== "completed") {
    return { status: "pending", message: "订单正在处理中,请稍候刷新" };
  }

  const userId = Number(checkout.metadata?.userId);
  const productId = Number(checkout.metadata?.productId);
  const orderId = typeof checkout.order === "string" ? checkout.order : checkout.order?.id;
  const customerId = typeof checkout.customer === "string" ? checkout.customer : checkout.customer?.id;

  if (!userId || !productId || !orderId) {
    return { status: "error", message: "元数据缺失,无法履约" };
  }

  // 3. 执行履约逻辑
  await fulfillPersonalCheckout({
    checkoutId: checkout.id,
    orderId,
    customerId: customerId ?? null,
    userId,
    productId,
  });

  return { status: "success", productId: params.id };
}

Webhook 异步通知与幂等履约

Webhook 签名验证机制

Webhook 请求通过 HTTP 标头 creem-signature 携带签名。商户需用配置的 CREEM_WEBHOOK_SECRET 对原始 HTTP 载荷文本(Raw Payload Text)进行 HMAC-SHA256 计算后比对。不可直接使用解析后的 JSON 对象比对,以防键排序或格式微小变动破坏哈希值。

TypeScript
// app/lib/creem.server.ts (续)
export function verifyWebhookSignature(payload: string, signature: string, secret?: string): boolean {
  const signingSecret = secret ?? process.env.CREEM_WEBHOOK_SECRET;
  if (!signingSecret || !signature) {
    return false;
  }
  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(payload)
    .digest("hex");

  if (expected.length !== signature.length) {
    return false;
  }
  try {
    return crypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(signature, "utf8"));
  } catch {
    return false;
  }
}

Webhook 路由与多事件分发

编写通用 Webhook 接收端点:

TypeScript
// app/routes/api.webhooks.creem.ts
import { verifyWebhookSignature } from "~/lib/creem.server";
import { fulfillPersonalCheckout, processRefundSettlement } from "~/services/purchaseService";

export async function action({ request }: { request: Request }) {
  if (request.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  // 必须提取原始请求体文本
  const rawPayload = await request.text();
  const signature = request.headers.get("creem-signature");

  if (!signature || !verifyWebhookSignature(rawPayload, signature)) {
    return new Response("Invalid signature", { status: 400 });
  }

  let event: any;
  try {
    event = JSON.parse(rawPayload);
  } catch {
    return new Response("Bad request", { status: 400 });
  }

  const eventType = event.event_type || event.eventType;

  switch (eventType) {
    case "checkout.completed": {
      const checkout = event.object || event.data?.object || event;
      if (checkout.status !== "completed") {
        return new Response("OK", { status: 200 });
      }

      const metaUserId = Number(checkout.metadata?.userId);
      const metaProductId = Number(checkout.metadata?.productId);
      const orderId = typeof checkout.order === "string" ? checkout.order : checkout.order?.id;
      const customerId = typeof checkout.customer === "string" ? checkout.customer : checkout.customer?.id;

      if (!metaUserId || !metaProductId || !orderId) {
        return new Response("OK", { status: 200 });
      }

      await fulfillPersonalCheckout({
        checkoutId: checkout.id,
        orderId,
        customerId: customerId ?? undefined,
        userId: metaUserId,
        productId: metaProductId,
      });
      break;
    }

    case "refund.created": {
      const refund = event.object || event.data?.object || event;
      const orderId = typeof refund.order === "string" ? refund.order : refund.order?.id;
      if (!orderId) {
        return new Response("OK", { status: 200 });
      }

      // 判定是否已达成全额退款
      const tx = refund.transaction;
      const refundedAmount = tx?.refunded_amount;
      const txAmount = tx?.amount;
      const isFull = typeof refundedAmount === "number" && typeof txAmount === "number"
        ? refundedAmount >= txAmount
        : refund.status === "succeeded";

      await processRefundSettlement({ orderId, fullyRefunded: isFull });
      break;
    }

    case "dispute.created": {
      const dispute = event.object || event.data?.object || event;
      const orderId = typeof dispute.order === "string" ? dispute.order : dispute.order?.id;
      if (orderId) {
        // 银行争议事件视同全额撤回处理
        await processRefundSettlement({ orderId, fullyRefunded: true });
      }
      break;
    }

    default:
      // 未定义事件返回 200 防止平台不断重试投递
      break;
  }

  return new Response("OK", { status: 200 });
}

数据库幂等履约服务

在业务服务层中,通过数据库唯一索引拦截并发重复写入:

TypeScript
// app/services/purchaseService.ts
import { eq } from "drizzle-orm";
import { db } from "~/db";
import { purchases, products } from "~/db/schema";
import { hasUserAccess, grantUserAccess } from "~/services/entitlementService";

export interface FulfillPersonalCheckoutParams {
  checkoutId: string;
  orderId: string;
  customerId?: string | null;
  userId: number;
  productId: number;
}

export async function fulfillPersonalCheckout(params: FulfillPersonalCheckoutParams) {
  const { checkoutId, orderId, customerId, userId, productId } = params;

  // 1. 查询数据库记录是否已存在
  const [existing] = await db
    .select()
    .from(purchases)
    .where(eq(purchases.creemCheckoutId, checkoutId))
    .limit(1);

  if (existing) {
    // 已经履约过的订单直接补充确保权限状态
    const hasAccess = await hasUserAccess(userId, productId);
    if (!hasAccess) {
      await grantUserAccess(userId, productId);
    }
    return existing;
  }

  // 2. 获取当时产品基准价格
  const [product] = await db
    .select()
    .from(products)
    .where(eq(products.id, productId))
    .limit(1);

  if (!product) {
    throw new Error("关联产品不存在");
  }

  // 3. 写入购买记录(creemCheckoutId 具有唯一约束)
  try {
    await db.insert(purchases).values({
      userId,
      productId,
      pricePaid: product.price,
      creemCheckoutId: checkoutId,
      creemOrderId: orderId,
      creemCustomerId: customerId ?? null,
    });
  } catch (err: any) {
    // 捕获并发冲突,若已被另一通道写入则视为成功
    const [secondCheck] = await db
      .select()
      .from(purchases)
      .where(eq(purchases.creemCheckoutId, checkoutId))
      .limit(1);

    if (!secondCheck) {
      throw err;
    }
  }

  // 4. 激活用户产品使用权限
  const hasAccess = await hasUserAccess(userId, productId);
  if (!hasAccess) {
    await grantUserAccess(userId, productId);
  }
}

退款处理与争议应对

退款状态流转与权益生命周期

退款往往涉及申请提交、在途处理、渠道审核与最终结算等多阶段流转。为防止用户在退款处理期间重复发起或继续消耗核心权益,系统采用状态机模型与临时冻结机制:

  • 正常激活态(Active):订单已完成履约,用户持有产品完整的使用权益。
  • 在途冻结态(RefundPending):退款申请已向支付网关提交,处于 pending 处理中。系统记录申请时间戳并冻结产品权益,同时拦截重复发起的退款操作。
  • 解冻恢复态(Restored / Active):若网关处理结果为 failed 或 canceled,系统回滚在途标记,解冻并恢复用户权益。
  • 注销终态(Revoked):退款确认到账(收到 Webhook refund.created 或同步返回 succeeded),系统写入退款完成时间戳 refundedAt,正式注销用户产品权益。

主动退款调用与在途冻结

Creem 的退款接口基于交易号(Transaction ID)发起,而非订单号。发起申请时,服务端先校验单据状态、标记在途时间戳并冻结权益,随后调用网关执行退款:

TypeScript
// app/services/purchaseService.ts (续)
export async function requestPersonalRefund(orderId: string): Promise<{ success: boolean; status: string }> {
  const client = getCreemClient();

  // 1. 获取购买单据并校验在途状态
  const [purchase] = await db
    .select()
    .from(purchases)
    .where(eq(purchases.creemOrderId, orderId))
    .limit(1);

  if (!purchase) {
    throw new Error("未找到关联购买单据");
  }

  if (purchase.refundedAt !== null) {
    throw new Error("该订单已完成退款,不可重复申请");
  }

  if (purchase.refundRequestedAt !== null) {
    throw new Error("退款申请正在处理中,请勿重复操作");
  }

  // 2. 标记退款在途并冻结权限,防范并发重复提交
  await db
    .update(purchases)
    .set({ refundRequestedAt: new Date() })
    .where(eq(purchases.id, purchase.id));

  // 临时冻结用户针对该产品的业务权益(置为暂停/只读状态)
  await freezeUserAccess(purchase.userId, purchase.productId);

  // 3. 根据 orderId 检索底层交易单据
  let transactions: any[];
  try {
    transactions = await client.searchTransactions(orderId);
  } catch (err: any) {
    // 接口异常时回滚申请标记并解冻权限
    await rollbackRefundRequest(purchase.id, purchase.userId, purchase.productId);
    throw new Error(`查询支付交易失败:${err.message || String(err)}`);
  }

  if (!transactions || transactions.length === 0) {
    await rollbackRefundRequest(purchase.id, purchase.userId, purchase.productId);
    throw new Error("未找到关联支付交易单据");
  }

  const transactionId = transactions[0].id;

  // 4. 调用 Creem 发起退款
  let result: { status: string };
  try {
    result = await client.refundPayment(transactionId);
  } catch (err: any) {
    await rollbackRefundRequest(purchase.id, purchase.userId, purchase.productId);
    throw new Error(`发起退款申请失败:${err.message || String(err)}`);
  }

  // 5. 根据网关响应状态进行流转分支
  if (result.status === "failed" || result.status === "canceled") {
    // 退款失败:回滚在途标记并解冻权益,恢复正常使用状态
    await rollbackRefundRequest(purchase.id, purchase.userId, purchase.productId);
    throw new Error("退款已被渠道拒绝,权益已恢复,可稍后重试");
  }

  if (result.status === "succeeded") {
    // 同步成功:直接执行最终结算与权益注销
    await processRefundSettlement({ orderId, fullyRefunded: true });
    return { success: true, status: "succeeded" };
  }

  // 保持 pending 在途冻结态,等待异步 Webhook 到达后执行最终结算
  return { success: true, status: result.status };
}

/**
 * 申请异常或失败时的回滚与解冻逻辑
 */
async function rollbackRefundRequest(purchaseId: number, userId: number, productId: number): Promise<void> {
  await db
    .update(purchases)
    .set({ refundRequestedAt: null })
    .where(eq(purchases.id, purchaseId));

  // 解除权益冻结,恢复正常访问
  await unfreezeUserAccess(userId, productId);
}

结算收回与权限撤销

当收到 Webhook 异步退款通知 refund.created、银行拒付争议 dispute.created 或前台退款成功响应时,执行权限注销与终态闭环:

TypeScript
// app/services/purchaseService.ts (续)
import { purchases, userEntitlements } from "~/db/schema";
import { and, ne, isNull } from "drizzle-orm";

export async function processRefundSettlement(params: {
  orderId: string;
  fullyRefunded: boolean;
}): Promise<void> {
  const { orderId, fullyRefunded } = params;

  const [purchase] = await db
    .select()
    .from(purchases)
    .where(eq(purchases.creemOrderId, orderId))
    .limit(1);

  if (!purchase || purchase.refundedAt !== null) {
    return;
  }

  // 1. 写入终态退款时间,清除在途申请标记
  await db
    .update(purchases)
    .set({
      refundedAt: new Date(),
      refundRequestedAt: null,
    })
    .where(eq(purchases.id, purchase.id));

  // 2. 仅在退款金额达到整笔实付(全额退款)时收回权限
  if (fullyRefunded) {
    // 检查该用户针对该产品是否存在其他仍有效的购买记录
    const otherPurchases = await db
      .select()
      .from(purchases)
      .where(
        and(
          eq(purchases.userId, purchase.userId),
          eq(purchases.productId, purchase.productId),
          ne(purchases.id, purchase.id),
          isNull(purchases.refundedAt)
        )
      );

    // 无其他有效订单时移除产品使用权限
    if (otherPurchases.length === 0) {
      await db
        .delete(userEntitlements)
        .where(
          and(
            eq(userEntitlements.userId, purchase.userId),
            eq(userEntitlements.productId, purchase.productId)
          )
        );
    }
  }
}

生产环境安全与边界处理规范

时钟偏差与重放防范

在生产部署中,处理 Webhook 时除校验 HMAC 签名外,建议同步比对时间戳防范中间人重放攻击:

  • 时间窗口校验:读取请求头中的时间戳(若提供),要求当前时间与请求时间的差值保持在 300 秒以内。
  • 恒定时间比对:比对签名哈希值时统一使用 crypto.timingSafeEqual,避免普通字符串操作符 === 导致的时序反推。

数据库索引与幂等键原则

支付集成涉及高频异步并发,需在底层数据库设计阶段落实约束:

  • creemCheckoutId 唯一索引:在购买表(purchases)中将 creemCheckoutId 设为 UNIQUE 索引,作为履约防重的底层约束保障。
  • creemOrderId 索引:为退款与争议查询提供单键索引,防止根据外部订单更新记录时触发全表扫描。
  • 状态分离设计:将购买记录(财务事实)与产品业务权益(业务状态)分为独立的数据表,退款时只操作权益状态与标记,保留不可篡改的历史交易单据。

上线审核材料

正式收款前,店铺需通过账户审核。审核材料与站点展示需与所售数字商品一致。详见 Account Reviews。

  • 法律页面:站点需提供可访问的隐私政策与服务条款。
  • 客服邮箱:网站与收据上展示可达的品牌客服邮箱,并在 3 个工作日内回复用户请求。逾期未回复时,Creem 可代为退款。
  • 价格与商品说明:标价与所售内容需在页面上可被直接理解。一个 Creem 店铺对应一个网站。
  • 订阅取消:若商品为订阅,用户需能在产品内或 Creem 客户门户取消。一次性商品不涉及该入口。

价格与税费隔离

在 MoR 结算场景下,需明确标价与实付款差异:

  • 基准价入账:商户数据库记录的产品售价应当是基准价格(Net Price),币种为 USD 或 EUR。
  • 附加税款:收银台页面显示的加价为买家当地税金,由 Creem 平台代收代缴,商户系统不应将含税总额作为产品基准价存入基础商品表。
  • 到账换汇:买家实付币种与商户收款账户币种可以不同。收款账户可以使用人民币或港币,商品 currency 保持创建时写入的 USD 或 EUR。
目录
核心机制与架构模型名义商户(MoR)模式解析端到端交互架构与时序结账会话创建与收银台重定向支付结果确认与双通道履约环境准备与平台配置开发者凭证与模式切换Webhook 本地联调穿透测试模式卡片表单填写规则常用测试卡号与场景模拟SDK 集成与客户端封装安装依赖抽象客户端接口默认客户端实现与依赖注入商品映射与价格生命周期管理可售商品范围标价币种约束商品发布联动规则价格变更同步托管结账流程实现创建结账会话回跳验证与前端即时呈现回跳签名算法实现回调处理路由Webhook 异步通知与幂等履约Webhook 签名验证机制Webhook 路由与多事件分发数据库幂等履约服务退款处理与争议应对退款状态流转与权益生命周期主动退款调用与在途冻结结算收回与权限撤销生产环境安全与边界处理规范时钟偏差与重放防范数据库索引与幂等键原则上线审核材料价格与税费隔离
上一篇基于 ngrok 的本地 Webhook 调试与内网穿透实践指南

©2015-2026 黑白梦 粤ICP备15018165号

联系: heibaimeng@foxmail.com