mirror of
https://github.com/34892002/edgeKey.git
synced 2026-05-06 15:22:43 +08:00
feat: stripe支付
This commit is contained in:
@@ -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` 字符串即可。
|
||||
|
||||
官方文档:
|
||||
- Checkout:https://stripe.com/docs/payments/checkout
|
||||
- Webhook:https://stripe.com/docs/webhooks
|
||||
- API 密钥:https://dashboard.stripe.com/apikeys
|
||||
@@ -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
104
modules/payment/stripe.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 ?? '';
|
||||
|
||||
25
pages/admin/payments/forms/StripeForm.vue
Normal file
25
pages/admin/payments/forms/StripeForm.vue
Normal 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 货币代码,如 cny、usd、hkd,默认 cny</p>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SecretInput from "../../../../components/SecretInput.vue";
|
||||
defineProps<{ modelValue: Record<string, any> }>();
|
||||
</script>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
35
server/routes/payment-stripe.ts
Normal file
35
server/routes/payment-stripe.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user