Creem 是一款面向数字商品与 SaaS 软件的商户记录服务商兼支付结算平台,提供全球税收代缴、托管收银台、数字商品交付等服务。本文记录使用 Test Mode 走通商户凭证配置、商品生命周期管理、托管收银台对接、Webhook 验签履约与售后退款的端到端流程实战。
官方文档与参考链接:Creem 官方文档 与 Creem 控制台。
在传统支付网关模式下,商户自身作为交易法律主体,必须自行申报和代缴买家所在国或地区的增值税(VAT)、数字服务税(DST)和美国各州销售税(Sales Tax)。
Creem 作为名义商户(Merchant of Record)介入交易流程:
为兼顾用户前台操作的即时响应与分布式网络抖动下的可靠履约,系统将支付流程解耦为“会话创建与收银台重定向”和“双通道支付确认与幂等履约”两个核心阶段:
客户端在发起付费流程时,不直接与外部支付接口通信,所有敏感调用与密钥交互均由服务端代理完成:
节点与流程说明:
userId 与产品 productId)注入 metadata,确保支付上下文全程透传且防范篡改。在用户完成付款后,系统采用“前台同步回跳主动反查”与“后台 Webhook 异步兜底”双通道机制,确保数据交付的时效性与最终一致性:
节点与流程说明:
checkout_id 作为核心幂等键。数据库购买表设置唯一索引约束,先到达的请求完成单据入库与权益激活,后到达的请求被唯一键约束拦截并直接返回成功,防止单据重复写入。在 Creem 开发者平台控制台中切换环境并提取核心凭证:
Test Mode 开关。Developers 页面,生成并复制测试用 API Key(以 creem_test_ 前缀开头)。Developers > Webhooks 页面,添加 Webhook 接收端点,并妥善保存签名密钥(Signing Secret)。编辑环境变量配置文件 .env:
# Creem 服务端 API 认证密钥
CREEM_API_KEY="creem_test_xxxxxxxxxxxxxxxxxxxxxxxx"
# Creem Webhook 原始请求体签名校验密钥
CREEM_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"
# 运行模式: test 或 live
CREEM_MODE="test"由于 Creem 服务端回调要求公网可达的 HTTPS 地址,本地开发时需借助隧道工具(如 Cloudflare Tunnel 或 ngrok)建立反向代理:
启动本地端口映射:
ngrok http 3000将穿透生成的公网域名配置在 Creem 控制台 Webhooks 列表中:
https://<your-tunnel-subdomain>.ngrok-free.app/api/webhooks/creemcheckout.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 支持通过不同的特定卡号模拟各种线上支付结果与异常边界,便于验证系统容错机制:
4111 1111 1111 1111 或 4242 4242 4242 4242,用于验证正常结账、回跳以及 Webhook 幂等履约链路。4507 9900 0000 0028,模拟发卡行拒绝扣款时的业务异常反馈。4507 9900 0000 0010,模拟账户可用额度不足场景。4507 9900 0000 0044,模拟用户输入错误安全码时的收银台就地拦截提示。在联调验证时,卡号填入对应场景卡号后,补全未来有效期(如 12/30)、安全码(如 123)及持卡人姓名(如 Test User)即可直接触发对应结算分支。
官方提供了适配 Node.js / TypeScript 环境的 SDK:
npm install creem为保障系统各层解耦及单元测试可模拟(Mock),先定义抽象客户端接口与标准交互实体:
// 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 }>;
}编写默认客户端实现类,提供单例实例导出与单元测试替换入口:
// 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 将课程、模板与软件许可证列为可售数字商品。
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。业务目录若以人民币维护价格,同步前需换算为美元或欧元的最小单位,并在本系统内固定同一标价币种。本集成创建商品时固定传入 currency: "USD",price 直接使用已换算的美分整数。
实现发布逻辑:产品为免费规格时直接忽略外部同步;付费产品若尚无映射商品则调用 Creem 接口创建一次性商品,将生成的 id 持久化在数据库中:
// 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;
}产品在运营过程中需要调整标价时,处理分支如下:
// 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 创建结账会话并回传跳转地址。
// 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 哈希计算。
// 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;
}
}在支付成功回跳页面中执行验签、主动查询与即时履约:
// 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 请求通过 HTTP 标头 creem-signature 携带签名。商户需用配置的 CREEM_WEBHOOK_SECRET 对原始 HTTP 载荷文本(Raw Payload Text)进行 HMAC-SHA256 计算后比对。不可直接使用解析后的 JSON 对象比对,以防键排序或格式微小变动破坏哈希值。
// 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 接收端点:
// 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 });
}在业务服务层中,通过数据库唯一索引拦截并发重复写入:
// 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);
}
}退款往往涉及申请提交、在途处理、渠道审核与最终结算等多阶段流转。为防止用户在退款处理期间重复发起或继续消耗核心权益,系统采用状态机模型与临时冻结机制:
pending 处理中。系统记录申请时间戳并冻结产品权益,同时拦截重复发起的退款操作。failed 或 canceled,系统回滚在途标记,解冻并恢复用户权益。refund.created 或同步返回 succeeded),系统写入退款完成时间戳 refundedAt,正式注销用户产品权益。Creem 的退款接口基于交易号(Transaction ID)发起,而非订单号。发起申请时,服务端先校验单据状态、标记在途时间戳并冻结权益,随后调用网关执行退款:
// 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 或前台退款成功响应时,执行权限注销与终态闭环:
// 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 签名外,建议同步比对时间戳防范中间人重放攻击:
crypto.timingSafeEqual,避免普通字符串操作符 === 导致的时序反推。支付集成涉及高频异步并发,需在底层数据库设计阶段落实约束:
creemCheckoutId 唯一索引:在购买表(purchases)中将 creemCheckoutId 设为 UNIQUE 索引,作为履约防重的底层约束保障。creemOrderId 索引:为退款与争议查询提供单键索引,防止根据外部订单更新记录时触发全表扫描。正式收款前,店铺需通过账户审核。审核材料与站点展示需与所售数字商品一致。详见 Account Reviews。
在 MoR 结算场景下,需明确标价与实付款差异:
USD 或 EUR。currency 保持创建时写入的 USD 或 EUR。