mcpField
P
pinme-uniwebpay

pinme-uniwebpay

github.com/glitternetwork开发3.8k91 次安装15 周前更新
代码仓库

关于 pinme-uniwebpay

这份指南介绍如何在 PinMe Worker 中通过 `@uniwebpay/sdk` 直接接入 UniwebPay,涵盖支付流程、服务端凭据和 Webhook 处理,适合使用 TypeScript 构建支付服务的开发者。

主要功能

  1. 01创建支付链接和结账会话
  2. 02管理产品、价格和订阅
  3. 03查询支付并发起退款
  4. 04验证 Webhook 签名
  5. 05使用自动注入的服务端凭据

适用场景

  1. 01收取订单的一次性付款
  2. 02设置周期订阅结账
  3. 03在 Worker Webhook 中处理支付事件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/glitternetwork/pinme --skill pinme-uniwebpay

安装后在 Claude Code 中输入/pinme-uniwebpay确认已加载。

详细介绍

PinMe UniwebPay 支付集成

本指南介绍如何在 PinMe Worker(Cloudflare Worker TypeScript)中编写支付服务,并通过 @uniwebpay/sdk 直接调用 UniwebPay。

核心模型:PinMe 按PinMe 用户(而非项目)开通 UniwebPay 钱包并配置密钥,并在 Worker 部署时注入 UNIWEB_* 环境绑定;Worker 代码通过 SDK 直接调用 UniwebPay——不经过 PinMe 支付代理路由,也不得调用旧版 VibeCash API。

环境绑定契约

export interface Env {
  UNIWEB_SECRET: string;           // PinMe-provisioned sk_server_ key (server-side only)
  UNIWEB_WEBHOOK_SECRET?: string;  // wallet-level whsec_, used to verify webhook signatures
  UNIWEB_API_URL?: string;         // UniwebPay API endpoint override (default https://apiskill.uniwebpay.com)
  UNIWEB_PAY_URL?: string;         // UniwebPay checkout host override (default https://skill.uniwebpay.com)
  UNIWEB_WALLET_ID?: string;       // user-level wallet id (wal_), diagnostics/reconciliation only
  WORKER_URL?: string;             // this project's public URL: https://{projectName}.{platform api domain}
  PROJECT_NAME?: string;           // PinMe project name
  DB?: D1Database;                 // project D1 (if enabled)
}

注入规则(元数据由 PinMe 在部署时于服务端重新生成;忽略客户端提供的绑定):

  • 仅在用户的 UniwebPay 凭据开通后,才会注入 UNIWEB_* 绑定。新建项目会自动开通并立即获得这些绑定;启用 UniwebPay 或轮换密钥后,必须重新部署现有项目,才能获取新的绑定。
  • WORKER_URL、PROJECT_NAME、API_KEY、DB 和其他基础绑定在每次部署时都会注入,与 UniwebPay 无关。
  • 同一 PinMe 用户拥有的所有项目共用一个钱包、一个 sk_server_ 和一个 whsec_。
  • PinMe 绝不会向 Worker 提供完整的钱包密钥(sk_live_)。不要向用户索要该密钥,也不要将其放入代码、wrangler.toml、.dev.vars、响应、日志、D1 或前端包中。
  • 如果运行时缺少 UNIWEB_SECRET,说明用户尚未启用 UniwebPay,或尚未重新部署——告知用户启用后重新部署;切勿伪造该值。

SDK 客户端

始终在服务端(Worker)实例化;SDK 在浏览器中运行时会抛出错误:

import Uniweb from "@uniwebpay/sdk";

function uniwebClient(env: Env): Uniweb {
  return new Uniweb(env.UNIWEB_SECRET, {
    baseUrl: env.UNIWEB_API_URL,
    payUrl: env.UNIWEB_PAY_URL,
  });
}
  • 构造函数的第一个位置参数是密钥(必须带有 sk_server_ 或 sk_live_ 前缀);第二个参数是可选配置:{ baseUrl?, payUrl?, timeout? (default 30s), maxRetries? (default 2) }。
  • SDK 只会对 429/5xx 的 GET/DELETE 请求自动重试;POST/PATCH 请求绝不会重试(以避免重复扣款)。
  • 仅当 Worker 代码导入 @uniwebpay/sdk 时才安装该包;根据项目现有的锁文件选择包管理器。

选择集成方式

场景方式返回值
固定金额的一次性收款uniweb.links.create(...)永久、可重复使用的 /p/ 链接(仅限一次性付款)
稳定的商品目录创建一次 products.create + prices.create,并存储 priceId价格包含永久有效的 paymentUrl(/buy/ 链接)
动态购物车/订单重用或创建价格,然后调用 uniweb.checkout.create(...)session.url——仅限一次性使用,有效期为 24 小时
订阅周期性价格 + checkout.create({ mode: "subscription" }) 或 subscriptions.create同上
服务端支付状态查询payments.get / list仅限服务端路由

金额始终使用整数最小货币单位(分)。默认货币为 SGD,除非应用已有更明确的惯例。不要在每次页面浏览时创建新商品/价格——只需创建一次稳定的目录项并持久化 priceId。

支付方式和货币规则

方式支持的货币
cardSGD、USD、EUR、GBP、JPY、CNY、HKD、AUD、MYR、THB(最低 10 个最小货币单位)
wechat仅限 SGD
alipay仅限 SGD
paynow仅限 SGD
  • QR 码支付方式(wechat/alipay/paynow)均仅支持 SGD——切勿编写“通过 WeChat/Alipay 使用 CNY”的代码。
  • 订阅(周期性付款 / mode: "subscription")仅使用 card。
  • 省略 paymentMethodTypes 时,服务器会根据货币选择合适的默认值;显式传入时,请先根据上表验证用户输入。

SDK 接口速查

以下接口已对照源码验证。所有参数字段均采用 camelCase(priceId、webhookUrl、startingAfter 等);SDK 会自行处理线上格式转换。list() 返回 { data: T[], hasMore: boolean };产品、价格、支付、客户、订阅和链接提供异步生成器 listAll()(结账和退款不提供)。

产品(webhookUrl 是每个产品的回调覆盖设置):

await uniweb.products.create({ name, description?, webhookUrl?, metadata? });
await uniweb.products.list({ limit?, startingAfter? });
await uniweb.products.get(productId);
await uniweb.products.update(productId, { name?, description?, webhookUrl?, active?, metadata? });
await uniweb.products.del(productId);
for await (const product of uniweb.products.listAll()) {}

价格(返回的价格包含永久 paymentUrl;deactivate 会将其下架):

await uniweb.prices.create({
  productId,
  amount,        // integer minor units
  currency,      // e.g. "SGD"
  type,          // "one_time" | "recurring"
  interval?,     // "day" | "week" | "month" | "year"; recurring only
  intervalCount?,
  trialPeriodDays?,
  metadata?,
});
await uniweb.prices.list({ productId?, limit?, startingAfter? });
await uniweb.prices.get(priceId);
await uniweb.prices.update(priceId, { active });
await uniweb.prices.activate(priceId);
await uniweb.prices.deactivate(priceId);
for await (const price of uniweb.prices.listAll({ productId? })) {}

结账会话(不接受 webhookUrl——事件会沿着价格 → 产品 → 钱包链路路由;该 URL 仅供一次性使用,24 小时后过期):

await uniweb.checkout.create({
  mode,           // "payment" | "subscription"
  lineItems: [{ priceId, quantity }],
  successUrl?,
  cancelUrl?,
  customerEmail?,
  customerId?,
  trialPeriodDays?,
  paymentMethodTypes?, // ["card", "wechat", "alipay", "paynow"]
  metadata?,
});
await uniweb.checkout.list({ limit?, startingAfter? });
await uniweb.checkout.get(checkoutSessionId);

支付(用于服务端状态检查;只有金额、币种、元数据和订单状态都符合预期时,才将本地订单标记为已支付):

await uniweb.payments.create({ amount, currency, customerId?, metadata? });
await uniweb.payments.list({ status?, customerId?, limit?, startingAfter? });
await uniweb.payments.get(paymentId, { gateway? });
await uniweb.payments.listRefunds(paymentId);
await uniweb.payments.sync(paymentId);
await uniweb.payments.void(paymentId);
for await (const payment of uniweb.payments.listAll({ status?, customerId? })) {}

退款(不提供 list/listAll——请使用 payments.listRefunds):

await uniweb.refunds.create({ paymentId, amount?, reason?, offlineRefundFlag? });
await uniweb.refunds.get(refundId, { gateway? });

客户:

await uniweb.customers.create({ email, name?, metadata? });
await uniweb.customers.list({ email?, limit?, startingAfter? });
await uniweb.customers.get(customerId);
await uniweb.customers.update(customerId, { email?, name?, metadata? });
await uniweb.customers.del(customerId);
for await (const customer of uniweb.customers.listAll({ email? })) {}

订阅(状态包括 trialing / active / past_due / unpaid / canceled;只能通过已验证的 Webhook 或可信的服务端对账任务更新访问权限):

await uniweb.subscriptions.create({ customerId, priceId, paymentMethodId?, trialPeriodDays?, metadata? });
await uniweb.subscriptions.list({ customerId?, status?, limit?, startingAfter? });
await uniweb.subscriptions.get(subscriptionId);
await uniweb.subscriptions.update(subscriptionId, { cancelAtPeriodEnd? });
await uniweb.subscriptions.cancel(subscriptionId); // cancel immediately
await uniweb.subscriptions.resume(subscriptionId); // undo cancelAtPeriodEnd
for await (const subscription of uniweb.subscriptions.listAll({ customerId?, status? })) {}

支付链接(永久可复用的 /p/ 链接,仅用于一次性收款;webhookUrl 是每个链接的回调覆盖设置):

await uniweb.links.create({
  amount,
  currency,
  name?,
  description?,
  successUrl?,
  cancelUrl?,
  webhookUrl?,
  paymentMethodTypes?,
  metadata?,
});
await uniweb.links.list({ limit?, startingAfter? });
await uniweb.links.get(paymentLinkId);
await uniweb.links.update(paymentLinkId, { name?, description?, successUrl?, cancelUrl?, webhookUrl?, active? });
await uniweb.links.deactivate(paymentLinkId);
for await (const link of uniweb.links.listAll()) {}

钱包和钱包级 Webhook 配置(危险操作区:会影响该用户所有项目共享的钱包):

await uniweb.wallet.current();
await uniweb.wallet.update({ merchantName?, merchantCity?, merchantCountry?, webhookUrl? });
await uniweb.webhooks.set(url);       // overwrites the wallet-level callback URL
await uniweb.webhooks.info();
await uniweb.webhooks.remove();
await uniweb.webhooks.rollSecret();   // rotates the shared whsec_

普通项目路由不得调用 webhooks.set / remove / rollSecret 或 wallet.update——这些操作会修改该用户所有项目共享的钱包回调备用地址和签名密钥。只有在用户明确要求进行钱包管理,且路由具有项目/管理员级授权时,才生成这些操作。同样,退款、订阅变更、打款、KYC 和银行账户 API 也仅在用户明确请求相应业务流程,且代码具备验证、持久化和授权机制时生成。

Webhook 集成

回调 URL:在链接/产品上设置,并指向此 Worker

事件投递优先级:每个链接的 webhookUrl > 每个产品的 webhookUrl > 钱包级备用地址。签名密钥始终是钱包级的 whsec_(即 env.UNIWEB_WEBHOOK_SECRET)。

PinMe 会在钱包上设置一个托管的备用回调 URL,但它仅用于获取并保留签名密钥——PinMe 服务器会丢弃发送到该地址的事件(204);绝不会将事件转发给 Worker。因此,业务事件必须在创建支付的资源上明确设置此项目的 webhookUrl:

  • 支付链接:在 links.create 中传入 webhookUrl。
  • 结账会话:checkout.create 没有 webhookUrl 字段;事件会通过价格所属的产品路由——在 products.create 中设置 webhookUrl(或在复用的产品上设置)。

构建 webhookUrl 的规则:

  • 将回调路径存放在一个常量中(例如 const WEBHOOK_PATH = "/api/pay/webhook"),并在路由器和 webhookUrl 构造中共用,以免路径不匹配导致回调返回 404、订单一直处于待处理状态。
  • 使用 env.WORKER_URL 作为基址:new URL(WEBHOOK_PATH, env.WORKER_URL).toString()。它是运行时唯一可用的公共地址(平台子域名);用户的自定义域名不在 env 中。优先使用它,而不是 request.url(当前请求的主机名不一定是已部署的地址);非 HTTP 上下文(cron/queue)则完全没有请求——如果缺少此值,应明确报错,而不是生成无效 URL。
  • 本地开发环境没有 WORKER_URL;回退到 request.url 会解析为 localhost,UniwebPay 无法访问该地址。若要在本地测试 Webhook,请通过隧道(cloudflared / ngrok)公开 Worker。
  • webhookUrl 必须使用 HTTPS。不要在 URL 查询参数中放入密钥或可作为信任依据的数据——请将 orderId 等关联信息放入 metadata,并结合签名和 metadata 验证身份(查询字符串可以伪造)。
验证与处理
import { verifyWebhook } from "@uniwebpay/sdk";

const rawBody = await request.text();              // read the raw body exactly once
const event = await verifyWebhook(                  // note: async
  rawBody,
  request.headers.get("uniweb-Signature") || "",   // format: t=<unix>,v1=<hex>
  env.UNIWEB_WEBHOOK_SECRET,
);
  • verifyWebhook(rawBody, signature, secret) 返回 Promise<WebhookEvent>;签名时间戳容差为 ±5 分钟;失败时会抛出异常。
  • 事件结构:{ id: "evt_...", type, created, data: { object, productId?, priceId?, productName? } }。业务对象位于 event.data.object;orderId 等关联信息位于 event.data.object.metadata。
  • 已验证确认实际送达的事件类型:payment.succeeded / payment.failed / payment.refunded / payment.partially_refunded、refund.succeeded / refund.failed / refund.abandoned、checkout.session.completed / checkout.session.expired、subscription.created / subscription.renewed / subscription.past_due / subscription.unpaid / subscription.trial_ending / subscription.canceled。

处理规则:

  • Webhook 路由必须绕过项目自身的身份验证。 UniwebPay 回调仅携带 uniweb-Signature(以及 uniweb-Event-Id / uniweb-Timestamp),绝不会携带项目的 API_KEY。如果全局身份验证守卫覆盖了所有路由,请将 WEBHOOK_PATH 排除在外——信任完全来自签名验证。否则回调会收到 401/403,订单也永远无法完成履约。
  • 签名无效时返回 400(让 UniwebPay 停止无意义的重试);临时处理失败时返回 500(让它重试)。
  • 使用 event.id(结合支付 ID / 结账会话 ID / 本地订单 ID)确保幂等性。
  • 履约前验证金额、币种、元数据和当前订单状态。
  • 在 10 秒内响应;投递不会跟随重定向,因此不要将 webhook 路由置于重定向之后。首次投递会立即进行;若失败,会分别在 5 分钟、30 分钟、2 小时和 12 小时后重试,最多尝试 6 次,之后事件会被标记为失败。
  • 在 TypeScript 中将 UNIWEB_WEBHOOK_SECRET 保持为可选——项目可能在配置完成前或重新部署前就已存在;如果运行时缺少该值,返回 501 并附上提示。

安全规则

  • 任何 Uniweb 密钥(UNIWEB_SECRET、UNIWEB_WEBHOOK_SECRET)都不得出现在响应、日志、元数据、测试快照、D1、源代码、已提交的 wrangler.toml / .dev.vars 或浏览器代码中。本地开发时,仅使用未提交的 .dev.vars。
  • 不要在浏览器端代码中导入 SDK;不要在 Cloudflare Workers 中使用 process.env——请使用 env 参数。
  • 不要调用旧版 VibeCash API 或 PinMe 支付代理路由;不要使用 X-API-Key 调用 PinMe 支付 API 来收取 UniwebPay 款项——Worker 会通过 SDK 直接调用 UniwebPay。
  • successUrl / cancelUrl 仅用于用户体验重定向。绝不要根据浏览器重定向完成履约;只有经过验证的 webhook 处理(或其他明确的服务端验证)才能授予访问权限。
  • 调用 SDK 前验证用户输入:金额(整数最小货币单位)、币种(ISO 4217 格式且符合支付方式限制)、数量、产品 / 价格 ID、支付方式类型、订单所有权以及元数据结构。

持久化指南(D1)

仅当项目已使用 D1 或用户要求持久化时,才添加表 / 迁移。对于订单流程,至少存储:本地订单 ID、对应的 Uniweb 链接 / 会话 / 支付 / 订阅 ID、金额和币种、状态、时间戳,以及已处理的 webhook 事件 ID(用于确保幂等性)。绝不要存储密钥。

CREATE TABLE IF NOT EXISTS orders (
  order_id TEXT PRIMARY KEY,
  checkout_session_id TEXT UNIQUE,
  status TEXT NOT NULL DEFAULT 'pending',
  amount_cents INTEGER NOT NULL,
  currency TEXT NOT NULL,
  paid_at INTEGER,
  created_at INTEGER NOT NULL,
  updated_at INTEGER
);

CREATE TABLE IF NOT EXISTS payment_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  order_id TEXT,
  raw_payload TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

Worker 参考实现

import Uniweb, { verifyWebhook } from "@uniwebpay/sdk";

export interface Env {
  UNIWEB_SECRET: string;
  UNIWEB_WEBHOOK_SECRET?: string;
  UNIWEB_API_URL?: string;
  UNIWEB_PAY_URL?: string;
  UNIWEB_WALLET_ID?: string;
  WORKER_URL?: string;
  PROJECT_NAME?: string;
  DB?: D1Database;
}

type PaymentMethod = "card" | "wechat" | "alipay" | "paynow";

const VALID_PAYMENT_METHODS = new Set<PaymentMethod>(["card", "wechat", "alipay", "paynow"]);
const CARD_CURRENCIES = new Set(["SGD", "USD", "EUR", "GBP", "JPY", "CNY", "HKD", "AUD", "MYR", "THB"]);

// Single source of truth for the webhook path: shared by the router and the
// webhookUrl construction so the address sent to UniwebPay always matches the
// route the Worker actually serves.
const WEBHOOK_PATH = "/api/pay/webhook";

function uniwebClient(env: Env): Uniweb {
  return new Uniweb(env.UNIWEB_SECRET, {
    baseUrl: env.UNIWEB_API_URL,
    payUrl: env.UNIWEB_PAY_URL,
  });
}

function json(data: unknown, init: ResponseInit = {}): Response {
  return Response.json(data, init);
}

function assertAmountCents(value: unknown): number {
  const amount = Number(value);
  if (!Number.isInteger(amount) || amount < 10) {
    throw new Error("amountCents must be an integer minor-unit amount >= 10");
  }
  return amount;
}

function normalizeCurrency(value: unknown): string {
  const currency = String(value || "SGD").toUpperCase();
  if (!/^[A-Z]{3}$/.test(currency)) throw new Error("currency must be an ISO 4217 code");
  return currency;
}

// QR methods (wechat/alipay/paynow) support SGD only; card supports 10 currencies.
function defaultPaymentMethods(currency: string): PaymentMethod[] {
  if (currency === "SGD") return ["card", "wechat", "alipay", "paynow"];
  if (CARD_CURRENCIES.has(currency)) return ["card"];
  throw new Error(`unsupported currency: ${currency}`);
}

function normalizePaymentMethods(value: unknown, currency: string): PaymentMethod[] {
  const requested = Array.isArray(value) && value.length > 0 ? value : defaultPaymentMethods(currency);
  const methods = requested.map((m) => String(m).toLowerCase() as PaymentMethod);
  for (const method of methods) {
    if (!VALID_PAYMENT_METHODS.has(method)) {
      throw new Error("paymentMethodTypes contains an unsupported method");
    }
    if (method !== "card" && currency !== "SGD") {
      throw new Error(`${method} only supports SGD payments`);
    }
    if (method === "card" && !CARD_CURRENCIES.has(currency)) {
      throw new Error(`card does not support ${currency}`);
    }
  }
  return Array.from(new Set(methods));
}

// Prefer the PinMe-injected WORKER_URL (the project's platform subdomain, the
// only public address available at runtime). request.url is only a fallback for
// older deploys; cron/queue contexts have no request, so WORKER_URL is required.
function projectWebhookUrl(env: Env, request?: Request): string {
  const base = env.WORKER_URL || request?.url;
  if (!base) throw new Error("WORKER_URL binding is missing");
  return new URL(WEBHOOK_PATH, base).toString();
}

// ---- One-time collection: payment link ----

async function createPaymentLink(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });

  let input: any;
  try {
    input = await request.json();
  } catch {
    return json({ error: "invalid JSON body" }, { status: 400 });
  }

  let amount: number, currency: string, paymentMethodTypes: PaymentMethod[];
  try {
    amount = assertAmountCents(input.amountCents);
    currency = normalizeCurrency(input.currency);
    paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);
  } catch (err) {
    return json({ error: (err as Error).message }, { status: 400 });
  }

  const orderId = input.orderId || crypto.randomUUID();
  const uniweb = uniwebClient(env);

  try {
    const link = await uniweb.links.create({
      amount,
      currency,
      name: input.name || "Payment",
      description: input.description,
      successUrl: input.successUrl,
      cancelUrl: input.cancelUrl,
      webhookUrl: projectWebhookUrl(env, request),
      paymentMethodTypes,
      metadata: { orderId, projectName: env.PROJECT_NAME },
    });
    return json({ orderId, linkId: link.id, url: link.url });
  } catch {
    return json({ error: "failed to create payment link" }, { status: 502 });
  }
}

// ---- Dynamic orders: checkout session ----
// Note: checkout.create has no webhookUrl; the callback is set on the product.
// Stable catalog items should create the product/price once and persist the
// priceId — do not create new ones on every request.

async function createCheckoutSession(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });

  let input: any;
  try {
    input = await request.json();
  } catch {
    return json({ error: "invalid JSON body" }, { status: 400 });
  }

  let amount: number, currency: string, paymentMethodTypes: PaymentMethod[];
  try {
    amount = assertAmountCents(input.amountCents);
    currency = normalizeCurrency(input.currency);
    paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);
  } catch (err) {
    return json({ error: (err as Error).message }, { status: 400 });
  }

  const orderId = input.orderId || crypto.randomUUID();
  const quantity = Math.max(1, Math.floor(Number(input.quantity || 1)));
  const uniweb = uniwebClient(env);

  try {
    const product = await uniweb.products.create({
      name: input.productName,
      webhookUrl: projectWebhookUrl(env, request),
      metadata: { orderId, projectName: env.PROJECT_NAME },
    });
    const price = await uniweb.prices.create({
      productId: product.id,
      amount,
      currency,
      type: "one_time",
      metadata: { orderId, projectName: env.PROJECT_NAME },
    });
    const session = await uniweb.checkout.create({
      mode: "payment",
      lineItems: [{ priceId: price.id, quantity }],
      successUrl: input.successUrl,
      cancelUrl: input.cancelUrl,
      customerEmail: input.customerEmail,
      paymentMethodTypes,
      metadata: { orderId, projectName: env.PROJECT_NAME },
    });

    if (env.DB) {
      await env.DB.prepare(
        `INSERT INTO orders(order_id, checkout_session_id, status, amount_cents, currency, created_at)
         VALUES (?, ?, 'pending', ?, ?, ?)
         ON CONFLICT(order_id) DO UPDATE SET checkout_session_id = excluded.checkout_session_id`,
      )
        .bind(orderId, session.id, amount * quantity, currency, Math.floor(Date.now() / 1000))
        .run();
    }

    return json({ orderId, checkoutSessionId: session.id, url: session.url });
  } catch {
    return json({ error: "failed to create checkout session" }, { status: 502 });
  }
}

// ---- Webhook handling ----

async function handleUniwebWebhook(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });
  if (!env.UNIWEB_WEBHOOK_SECRET) {
    return json({ error: "webhook verification is not configured" }, { status: 501 });
  }

  const rawBody = await request.text();
  let event: Awaited<ReturnType<typeof verifyWebhook>>;
  try {
    event = await verifyWebhook(
      rawBody,
      request.headers.get("uniweb-Signature") || "",
      env.UNIWEB_WEBHOOK_SECRET,
    );
  } catch {
    return json({ error: "invalid signature" }, { status: 400 });
  }

  try {
    if (env.DB) {
      const object = event.data.object as { metadata?: Record<string, unknown> };
      const orderId = String(object?.metadata?.orderId || "");
      const now = Math.floor(Date.now() / 1000);

      // event.id idempotency: duplicate deliveries become no-ops
      await env.DB.prepare(
        `INSERT INTO payment_events(event_id, event_type, order_id, raw_payload, created_at)
         VALUES (?, ?, ?, ?, ?)
         ON CONFLICT(event_id) DO NOTHING`,
      )
        .bind(event.id, event.type, orderId, rawBody, now)
        .run();

      if (event.type === "payment.succeeded" && orderId) {
        // Production code should verify amount/currency/order state here before fulfilling
        await env.DB.prepare(
          `UPDATE orders SET status = 'paid', paid_at = ?, updated_at = ? WHERE order_id = ? AND status != 'paid'`,
        )
          .bind(now, now, orderId)
          .run();
      }
    }
    return json({ ok: true });
  } catch {
    // 500 makes UniwebPay retry on the 5m/30m/2h/12h schedule
    return json({ error: "processing error" }, { status: 500 });
  }
}

// ---- Router ----

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // The webhook must come before any project auth guard: callbacks carry only
    // uniweb-Signature, never the project API_KEY.
    if (url.pathname === WEBHOOK_PATH) return handleUniwebWebhook(request, env);

    // The project's own auth guard goes after this point, on the business routes.
    if (url.pathname === "/api/pay/link") return createPaymentLink(request, env);
    if (url.pathname === "/api/pay/checkout") return createCheckoutSession(request, env);

    return json({ error: "not found" }, { status: 404 });
  },
};

常见错误

错误后果 / 修复方法
项目的 API_KEY / bearer 身份验证保护规则拦截了 Webhook 路由回调会收到 401/403,订单会一直处于待处理状态。将 WEBHOOK_PATH 加入豁免名单;信任仅来自签名验证
忘记在链接 / 产品上设置 webhookUrl事件会落入 PinMe 托管的备用处理流程并被丢弃;Worker 永远收不到。业务事件必须在每个链接 / 产品上指向此 Worker
将 webhookUrl 传给 checkout.create此字段不存在。请在关联的产品上设置
根据 successUrl 重定向完成履约可伪造。只能根据已验证的 Webhook 或服务端验证来履约
将回调主机硬编码,或只依赖 request.url使用 env.WORKER_URL 构建;request.url 仅作备用
将 CNY 与 wechat/alipay 搭配来源限制 QR 方式仅适用于 SGD;CNY 只能使用 card
每次请求都创建新的产品 / 价格只创建一次稳定的目录项,并持久化、复用 priceId
在常规业务流程中调用 webhooks.set/remove/rollSecret 或 wallet.update会修改 / 轮换该钱包的回调以及所有用户项目共用的 whsec_。仅针对明确的钱包管理请求且经过管理员授权时生成
使用浮点数表示主单位金额始终使用整数次级单位
在 Worker 中使用 process.env,或在浏览器中导入 SDK使用 env 参数;SDK 会拒绝浏览器环境
将 UNIWEB_SECRET / whsec_ 放入 wrangler.toml、源代码、日志或 D1PinMe 会在部署时注入;本地仅使用未提交的 .dev.vars
验证前多次读取请求正文,或将其作为 JSON 读取使用 request.text() 恰好读取一次,并将原始字符串传给 verifyWebhook

完成检查清单

  • 仅当 Worker 代码导入 @uniwebpay/sdk 时才安装;包管理器遵循锁文件。
  • Env 包含代码使用的 PinMe 注入绑定;UNIWEB_WEBHOOK_SECRET / WORKER_URL 保持可选,并妥善处理其缺失情况。
  • 客户端通过 new Uniweb(env.UNIWEB_SECRET, { baseUrl: env.UNIWEB_API_URL, payUrl: env.UNIWEB_PAY_URL }) 实例化。
  • 未使用 VibeCash 或 PinMe 支付代理路由;源代码、响应、日志、D1、测试或文档中均未出现密钥。
  • 金额经过验证,且为整数次级单位;支付方式和货币遵循约束表(QR 方式仅适用于 SGD)。
  • 每个需要事件的支付创建操作都带有根据 env.WORKER_URL 构建的、针对每个链接 / 产品的 webhookUrl。
  • Webhook:原始请求正文恰好读取一次,使用 verifyWebhook 验证,正确处理 400/500 语义,使用 event.id 实现幂等性,路由绕过项目身份验证,并在 10 秒内响应。
  • 履约不依赖浏览器重定向;授予访问权限前会验证金额、货币、元数据和订单状态。

评论

支持 Markdown