From 23eccc6eb51d80206c2e2fd0576fbd39aa0e4854 Mon Sep 17 00:00:00 2001 From: ggyy <34892002@qq.com> Date: Sat, 25 Apr 2026 18:32:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20stripe=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/payment-gateway-guide.md | 57 +++++++++- modules/payment/service.ts | 18 +++ modules/payment/stripe.ts | 104 ++++++++++++++++++ modules/payment/types.ts | 5 +- pages/admin/payments/+Page.vue | 7 +- pages/admin/payments/PaymentConfigCard.vue | 11 +- pages/admin/payments/forms/StripeForm.vue | 25 +++++ .../payments/savePaymentConfig.telefunc.ts | 3 + server/routes/index.ts | 2 + server/routes/payment-stripe.ts | 35 ++++++ 10 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 modules/payment/stripe.ts create mode 100644 pages/admin/payments/forms/StripeForm.vue create mode 100644 server/routes/payment-stripe.ts diff --git a/docs/payment-gateway-guide.md b/docs/payment-gateway-guide.md index da2148d..3c4a16d 100644 --- a/docs/payment-gateway-guide.md +++ b/docs/payment-gateway-guide.md @@ -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 \ No newline at end of file +- 密钥工具: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 \ No newline at end of file diff --git a/modules/payment/service.ts b/modules/payment/service.ts index 861931e..e494bca 100644 --- a/modules/payment/service.ts +++ b/modules/payment/service.ts @@ -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 = { 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); } diff --git a/modules/payment/stripe.ts b/modules/payment/stripe.ts new file mode 100644 index 0000000..d81b8bb --- /dev/null +++ b/modules/payment/stripe.ts @@ -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) { + 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 { + 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): Promise { + 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 } }; + 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, + }; + }, + }; +} \ No newline at end of file diff --git a/modules/payment/types.ts b/modules/payment/types.ts index 34636df..e7c2267 100644 --- a/modules/payment/types.ts +++ b/modules/payment/types.ts @@ -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; } \ No newline at end of file diff --git a/pages/admin/payments/+Page.vue b/pages/admin/payments/+Page.vue index a658565..46f7100 100644 --- a/pages/admin/payments/+Page.vue +++ b/pages/admin/payments/+Page.vue @@ -16,10 +16,15 @@ 支付宝 + + Stripe + + + @@ -31,5 +36,5 @@ import type { Data } from "./+data"; const { configs } = useData(); const activeTab = ref("BEPUSDT"); -const localConfigs = reactive({ ...configs }); +const localConfigs = reactive>({ ...configs }); \ No newline at end of file diff --git a/pages/admin/payments/PaymentConfigCard.vue b/pages/admin/payments/PaymentConfigCard.vue index 984a7f8..e95ed5a 100644 --- a/pages/admin/payments/PaymentConfigCard.vue +++ b/pages/admin/payments/PaymentConfigCard.vue @@ -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 ?? ''; diff --git a/pages/admin/payments/forms/StripeForm.vue b/pages/admin/payments/forms/StripeForm.vue new file mode 100644 index 0000000..e3b2aaf --- /dev/null +++ b/pages/admin/payments/forms/StripeForm.vue @@ -0,0 +1,25 @@ + + + \ No newline at end of file diff --git a/pages/admin/payments/savePaymentConfig.telefunc.ts b/pages/admin/payments/savePaymentConfig.telefunc.ts index c310143..7d505cb 100644 --- a/pages/admin/payments/savePaymentConfig.telefunc.ts +++ b/pages/admin/payments/savePaymentConfig.telefunc.ts @@ -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); diff --git a/server/routes/index.ts b/server/routes/index.ts index 06f8e9d..c798739 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -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); } diff --git a/server/routes/payment-stripe.ts b/server/routes/payment-stripe.ts new file mode 100644 index 0000000..456d95c --- /dev/null +++ b/server/routes/payment-stripe.ts @@ -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 = {}; + 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); + } + }); +} \ No newline at end of file