feat: stripe支付

This commit is contained in:
ggyy
2026-04-25 18:32:25 +08:00
parent aec57f4f7e
commit 23eccc6eb5
10 changed files with 262 additions and 5 deletions

View File

@@ -119,4 +119,59 @@ BEpusdt 是一款个人加密货币收款网关,支持 USDT、USDC、TRX 等
官方文档:
- PC 支付https://opendocs.alipay.com/open/270/105898
- H5 支付https://opendocs.alipay.com/open/203/107090
- 密钥工具https://opendocs.alipay.com/common/02kipl
- 密钥工具https://opendocs.alipay.com/common/02kipl
## Stripe 接口说明
Stripe 是国际主流信用卡收款网关,本项目使用 **Checkout Session** 模式,用户跳转到 Stripe 托管收银台完成支付。
### 工作模式
创建 Checkout Session 后跳转到 Stripe 收银台,支付完成后通过 Webhook 异步通知。
| 接口 | 说明 |
|------|------|
| `POST /v1/checkout/sessions` | 创建支付会话,返回 `url` 跳转地址 |
| Webhook `checkout.session.completed` | 支付成功异步回调 |
### 配置项
| 字段 | 说明 |
|------|------|
| `stripeSecretKey` | Stripe Dashboard → Developers → API keys → Secret key`sk_live_...` |
| `stripeWebhookSecret` | Stripe Dashboard → Developers → Webhooks → 端点签名密钥(`whsec_...` |
| `stripeCurrency` | ISO 4217 货币代码,如 `cny``usd``hkd`,默认 `cny` |
> **注意**Stripe 要求订单金额折算成 USD 至少 $0.50部分货币更高。CNY 最低约 ¥4低于此金额会报错。
### Webhook 配置
在 Stripe Dashboard → Developers → Webhooks 创建端点:
- **URL**`https://你的域名/api/payments/stripe/notify`
- **监听事件**`checkout.session.completed`
### 签名验证HMAC-SHA256
1. 读取请求原始 body不可解析后重新序列化
2.`Stripe-Signature` 请求头解析 `t`(时间戳)和 `v1`(签名)
3. 拼接签名字符串:`{t}.{rawBody}`
4.`webhookSecret` 对拼接字符串做 HMAC-SHA256结果转十六进制
5.`v1` 比对,一致则验签通过
### 回调说明
回调为 JSON 格式,路由读取原始 body 后以 `__raw_body``__stripe_signature` 传入适配器。
| 字段 | 说明 |
|------|------|
| `type` | 事件类型,仅处理 `checkout.session.completed` |
| `data.object.id` | Stripe Session ID作为 `paymentOrderNo` |
| `data.object.metadata.orderNo` | 商户订单号 |
| `data.object.amount_total` | 实际支付金额(单位:分) |
回调响应返回 `success` 字符串即可。
官方文档:
- Checkouthttps://stripe.com/docs/payments/checkout
- Webhookhttps://stripe.com/docs/webhooks
- API 密钥https://dashboard.stripe.com/apikeys

View File

@@ -12,6 +12,7 @@ import type { PaymentConfigValue } from "./types";
import { createBepusdtAdapter } from "./bepusdt";
import { createEpayAdapter } from "./epay";
import { createAlipayAdapter, queryAlipayTrade } from "./alipay";
import { createStripeAdapter } from "./stripe";
import { deliverOrder } from "../delivery/service";
import { findOrderRecord, updateOrderPayment } from "../order/repository";
import { notifyOrderPaid as _notifyOrderPaid } from "../email/service";
@@ -48,6 +49,17 @@ const defaultPaymentConfigs: Record<PaymentProvider, PaymentConfigValue> = {
notifyUrl: "/api/payments/alipay/notify",
returnUrl: "/order/{orderNo}?token={token}",
},
STRIPE: {
provider: "STRIPE",
name: "Stripe",
isEnabled: false,
baseUrl: "https://api.stripe.com",
stripeSecretKey: "",
stripeWebhookSecret: "",
stripeCurrency: "cny",
notifyUrl: "/api/payments/stripe/notify",
returnUrl: "/order/{orderNo}?token={token}",
},
};
function getPaymentContext() {
@@ -121,6 +133,9 @@ export async function savePaymentConfig(input: PaymentConfigValue) {
alipayAppId: input.alipayAppId?.trim() || "",
alipayPrivateKey: input.alipayPrivateKey?.trim() || "",
alipayPublicKey: input.alipayPublicKey?.trim() || "",
stripeSecretKey: input.stripeSecretKey?.trim() || "",
stripeWebhookSecret: input.stripeWebhookSecret?.trim() || "",
stripeCurrency: input.stripeCurrency?.trim() || "cny",
};
const record = await upsertPaymentConfigRecord(prisma, input.provider, {
@@ -152,6 +167,9 @@ function createProviderAdapter(config: PaymentConfigValue) {
if (config.provider === "ALIPAY") {
return createAlipayAdapter(config);
}
if (config.provider === "STRIPE") {
return createStripeAdapter(config);
}
return createEpayAdapter(config);
}

104
modules/payment/stripe.ts Normal file
View File

@@ -0,0 +1,104 @@
import { externalServiceError } from "../../lib/app-error";
import type { PaymentConfigValue } from "./types";
import type { PaymentProviderAdapter, CreatePaymentInput, CreatePaymentResult, VerifyNotifyResult } from "./provider";
export function createStripeAdapter(config: PaymentConfigValue): PaymentProviderAdapter {
const secretKey = config.stripeSecretKey ?? "";
const webhookSecret = config.stripeWebhookSecret ?? "";
const currency = (config.stripeCurrency?.trim() || "cny").toLowerCase();
async function stripeRequest(path: string, body: Record<string, string>) {
const encoded = new URLSearchParams(body).toString();
const res = await fetch(`https://api.stripe.com${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${secretKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: encoded,
});
if (!res.ok) {
const err = await res.json() as { error?: { message?: string } };
throw externalServiceError(err.error?.message ?? `Stripe API error ${res.status}`, "STRIPE_API_ERROR");
}
return res.json();
}
return {
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentResult> {
const amountCents = Math.round(input.amount);
const session = await stripeRequest("/v1/checkout/sessions", {
"payment_method_types[]": "card",
"line_items[0][price_data][currency]": currency,
"line_items[0][price_data][product_data][name]": input.productName,
"line_items[0][price_data][unit_amount]": String(amountCents),
"line_items[0][quantity]": "1",
mode: "payment",
success_url: input.returnUrl,
cancel_url: input.returnUrl,
"metadata[orderNo]": input.orderNo,"payment_intent_data[metadata][orderNo]": input.orderNo,
}) as { url: string; id: string };
return { payUrl: session.url, paymentOrderNo: session.id };
},
async verifyNotify(payload: Record<string, string>): Promise<VerifyNotifyResult> {
const raw = payload.__raw_body ?? "";
const signature = payload.__stripe_signature ?? "";
// Parse Stripe-Signature header: t=...,v1=...
const parts = Object.fromEntries(
signature.split(",").map((p) => p.split("=") as [string, string])
);
const timestamp = parts["t"];
const v1 = parts["v1"];
if (!timestamp || !v1) {
return { isValid: false, raw: payload, message: "Missing signature components" };
}
// Verify HMAC-SHA256
const signedPayload = `${timestamp}.${raw}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(webhookSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signedPayload));
const expected = Array.from(new Uint8Array(mac))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (expected !== v1) {
return { isValid: false, raw: payload, message: "Signature mismatch" };
}
// Parse event from raw body
let event: { type: string; data: { object: Record<string, any> } };
try {
event = JSON.parse(raw);
} catch {
return { isValid: false, raw: payload, message: "Invalid JSON body" };
}
if (event.type !== "checkout.session.completed") {
return { isValid: true, raw: payload, status: "PENDING", message: `Ignored event: ${event.type}` };
}
const session = event.data.object;
const orderNo = session.metadata?.orderNo as string | undefined;
const amountTotal = typeof session.amount_total === "number" ? session.amount_total / 100 : undefined;
return {
isValid: true,
orderNo,
paymentOrderNo: session.id as string,
amount: amountTotal,
status: "PAID",
raw: payload,
};
},
};
}

View File

@@ -1,4 +1,4 @@
export type PaymentProvider = "BEPUSDT" | "EPAY" | "ALIPAY";
export type PaymentProvider = "BEPUSDT" | "EPAY" | "ALIPAY" | "STRIPE";
export interface PaymentMethodItem {
provider: PaymentProvider;
@@ -21,4 +21,7 @@ export interface PaymentConfigValue {
alipayAppId?: string;
alipayPrivateKey?: string;
alipayPublicKey?: string;
stripeSecretKey?: string;
stripeWebhookSecret?: string;
stripeCurrency?: string;
}

View File

@@ -16,10 +16,15 @@
支付宝
<span v-if="localConfigs.ALIPAY?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
</a>
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'STRIPE' }" @click="activeTab = 'STRIPE'">
Stripe
<span v-if="localConfigs.STRIPE?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
</a>
</div>
<PaymentConfigCard v-if="activeTab === 'BEPUSDT'" provider="BEPUSDT" title="Usdt/Usdc(数字货币)" :initial-value="localConfigs.BEPUSDT" @saved="localConfigs.BEPUSDT = $event" />
<PaymentConfigCard v-if="activeTab === 'EPAY'" provider="EPAY" title="易支付(聚合支付)" :initial-value="localConfigs.EPAY" @saved="localConfigs.EPAY = $event" />
<PaymentConfigCard v-if="activeTab === 'ALIPAY'" provider="ALIPAY" title="支付宝(官方)" :initial-value="localConfigs.ALIPAY" @saved="localConfigs.ALIPAY = $event" />
<PaymentConfigCard v-if="activeTab === 'STRIPE'" provider="STRIPE" title="Stripe(信用卡)" :initial-value="localConfigs.STRIPE" @saved="localConfigs.STRIPE = $event" />
</section>
</template>
@@ -31,5 +36,5 @@ import type { Data } from "./+data";
const { configs } = useData<Data>();
const activeTab = ref("BEPUSDT");
const localConfigs = reactive({ ...configs });
const localConfigs = reactive<Record<string, any>>({ ...configs });
</script>

View File

@@ -54,9 +54,10 @@ import { onSavePaymentConfig } from "./savePaymentConfig.telefunc";
import BEpusdtForm from "./forms/BEpusdtForm.vue";
import EpayForm from "./forms/EpayForm.vue";
import AlipayForm from "./forms/AlipayForm.vue";
import StripeForm from "./forms/StripeForm.vue";
import type { PaymentProvider, PaymentConfigValue } from "../../../modules/payment/types";
const formMap = { BEPUSDT: BEpusdtForm, EPAY: EpayForm, ALIPAY: AlipayForm };
const formMap = { BEPUSDT: BEpusdtForm, EPAY: EpayForm, ALIPAY: AlipayForm, STRIPE: StripeForm };
const emit = defineEmits<{ saved: [value: typeof props.initialValue] }>();
@@ -79,7 +80,9 @@ const extraFields = reactive(
? { appId: props.initialValue?.appId ?? '', appSecret: props.initialValue?.appSecret ?? '' }
: props.provider === 'ALIPAY'
? { alipayAppId: props.initialValue?.alipayAppId ?? '', alipayPrivateKey: props.initialValue?.alipayPrivateKey ?? '', alipayPublicKey: props.initialValue?.alipayPublicKey ?? '' }
: { pid: props.initialValue?.pid ?? '', key: props.initialValue?.key ?? '' }
: props.provider === 'STRIPE'
? { stripeSecretKey: props.initialValue?.stripeSecretKey ?? '', stripeWebhookSecret: props.initialValue?.stripeWebhookSecret ?? '', stripeCurrency: props.initialValue?.stripeCurrency ?? 'cny' }
: { pid: props.initialValue?.pid ?? '', key: props.initialValue?.key ?? '' }
);
const saving = ref(false);
@@ -100,6 +103,10 @@ async function handleSave() {
if (props.provider === 'BEPUSDT') {
(extraFields as any).appId = result.appId ?? '';
(extraFields as any).appSecret = result.appSecret ?? '';
} else if (props.provider === 'STRIPE') {
(extraFields as any).stripeSecretKey = (result as any).stripeSecretKey ?? '';
(extraFields as any).stripeWebhookSecret = (result as any).stripeWebhookSecret ?? '';
(extraFields as any).stripeCurrency = (result as any).stripeCurrency ?? 'cny';
} else if (props.provider === 'ALIPAY') {
(extraFields as any).alipayAppId = (result as any).alipayAppId ?? '';
(extraFields as any).alipayPrivateKey = (result as any).alipayPrivateKey ?? '';

View File

@@ -0,0 +1,25 @@
<template>
<div class="space-y-4">
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Secret Key</span>
<SecretInput v-model="modelValue.stripeSecretKey" placeholder="sk_live_..." />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Webhook Secret</span>
<SecretInput v-model="modelValue.stripeWebhookSecret" placeholder="whsec_..." />
<p class="text-xs text-base-content/60">
<a href="https://dashboard.stripe.com/apikeys" target="_blank" class="link">Stripe Dashboard</a> 获取 API 密钥Webhook Secret <a href="https://dashboard.stripe.com/webhooks" target="_blank" class="link">Webhooks</a> 页面创建端点后获取回调事件请选择 <code>checkout.session.completed</code>
</p>
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">货币代码</span>
<input v-model="modelValue.stripeCurrency" class="input input-bordered w-full" placeholder="cny" />
<p class="text-xs text-base-content/60">ISO 4217 货币代码 cnyusdhkd默认 cny</p>
</label>
</div>
</template>
<script setup lang="ts">
import SecretInput from "../../../../components/SecretInput.vue";
defineProps<{ modelValue: Record<string, any> }>();
</script>

View File

@@ -16,6 +16,9 @@ export async function onSavePaymentConfig(input: {
alipayAppId?: string;
alipayPrivateKey?: string;
alipayPublicKey?: string;
stripeSecretKey?: string;
stripeWebhookSecret?: string;
stripeCurrency?: string;
}) {
assertAdminAccess();
return savePaymentConfig(input);

View File

@@ -3,6 +3,7 @@ import { registerHealthRoutes } from "./health";
import { registerBepusdtRoutes } from "./payment-bepusdt";
import { registerEpayRoutes } from "./payment-epay";
import { registerAlipayRoutes } from "./payment-alipay";
import { registerStripeRoutes } from "./payment-stripe";
// 集中注册所有 `/api/*` 路由,避免入口文件散落多个 register 调用。
export function registerApiRoutes(app: Hono) {
@@ -10,5 +11,6 @@ export function registerApiRoutes(app: Hono) {
registerBepusdtRoutes(app);
registerEpayRoutes(app);
registerAlipayRoutes(app);
registerStripeRoutes(app);
}

View File

@@ -0,0 +1,35 @@
import type { Hono } from "hono";
import { handlePaymentNotify } from "../../modules/payment/service";
import { logger } from "../../lib/logger";
export function registerStripeRoutes(app: Hono) {
app.post("/api/payments/stripe/notify", async (c) => {
let payload: Record<string, string> = {};
try {
const rawBody = await c.req.text();
const signature = c.req.header("stripe-signature") ?? "";
payload = {
__raw_body: rawBody,
__stripe_signature: signature,
};
const universalContext = (c as any).get("universalContext") as { prisma: import("../../generated/prisma/client").PrismaClient };
if (!universalContext?.prisma) {
logger.error("Missing prisma for stripe notify", {
event: "payment.notify.context_missing",
provider: "STRIPE",
source: "notify",
});
return c.text("fail", 500);
}
const result = await handlePaymentNotify("STRIPE", payload, universalContext.prisma, "notify");
return c.text(result.ok ? "success" : "fail");
} catch (error) {
logger.error(error instanceof Error ? error : new Error(String(error)), {
event: "payment.notify.route_exception",
provider: "STRIPE",
source: "notify",
});
return c.text("fail", 400);
}
});
}