1、环境centOS stream 9、thinkphp8.1、php8.4;2、前端html5\es6-es2024新特性的应用3、利用了stripe沙盒测试注意事项该演示是沙盒测试并非生产环境(product env)前提条件1、创建受限密钥获取服务端测试密钥 2、在 Sandbox 对应的 Stripe Workbench 中添加事件目标 checkout.session.completed checkout.session.async_payment_succeeded checkout.session.async_payment_failed checkout.session.expired 3、Workbench → Webhooks 创建回调目标获取 whsec_ 签名密钥 4、Sandbox 可使用以下测试卡 卡号4242 4242 4242 4242 有效期任意未来日期 CVC任意3位数字配置文件#tp8根目录下.env文件 [STRIPE] KEY rk_test_k7cuCC00DztQPudL WEBHOOK_SECRET whsec_Xfa8rsGoQlEsApyRkctnIz7HsWiQ7xFx BASE_URL https://pay.quweiie.cn # root\config\stripe.php ?php return [ key env(stripe.key, ), webhook_secret env(stripe.webhook_secret, ), base_url rtrim(env(stripe.base_url, ), /), live false, // 正式环境改为 true ];前端代码!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1, viewport-fitcover meta namereferrer contentno-referrer meta http-equivContent-Security-Policy contentdefault-src self; img-src self data:; script-src self unsafe-inline;style-src self unsafe-inline;connect-src self;base-uri self;form-action self; titleStripe 沙盒支付演示/title style * { box-sizing: border-box; } body { margin: 0; background: #f5f6fa; color: #202332; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Microsoft YaHei, sans-serif; } .page { width: min(100% - 32px, 460px); margin: 64px auto; } .badge { display: inline-block; padding: 6px 12px; border-radius: 20px; background: #ede9fe; color: #6d28d9; font-size: 12px; } h1 { margin: 18px 0 8px; font-size: 28px; } .subtitle { margin: 0 0 24px; color: #777d8e; font-size: 14px; line-height: 1.7; } .card { padding: 24px; border: 1px solid #eceef5; border-radius: 20px; background: #fff; box-shadow: 0 12px 40px rgba(30, 35, 70, .04); } .product { font-size: 17px; font-weight: 600; } .description { margin-top: 8px; color: #818797; font-size: 13px; } .price { margin: 26px 0; font-size: 40px; font-weight: 700; } .price small { font-size: 15px; color: #818797; } button { width: 100%; min-height: 48px; padding: 12px 16px; border: 0; border-radius: 12px; font: inherit; cursor: pointer; } button:disabled { opacity: .6; cursor: not-allowed; } .primary { background: #635bff; color: #fff; font-weight: 600; } .secondary { margin-top: 12px; background: #f1f2f7; color: #52586a; } .message { margin-top: 18px; padding: 12px 14px; border-radius: 10px; background: #f4f5f9; color: #596174; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; } .message[data-typesuccess] { background: #ecfdf3; color: #15803d; } .message[data-typeerror] { background: #fff1f2; color: #be123c; } .order { margin-top: 20px; padding-top: 16px; border-top: 1px solid #eef0f5; } .row { display: flex; justify-content: space-between; gap: 16px; margin: 10px 0; font-size: 13px; line-height: 1.6; } .row span { flex-shrink: 0; color: #818797; } .row strong { text-align: right; font-weight: 500; overflow-wrap: anywhere; } .tips { margin-top: 22px; color: #818797; font-size: 12px; line-height: 1.9; } [hidden] { display: none !important; } /style /head body main classpage span classbadgeSANDBOX · 沙盒测试/span h1在线支付演示/h1 p classsubtitle ThinkPHP 8.1 Stripe Checkoutbr 使用测试卡完成付款不会产生真实扣款。 /p section classcard div classproduct演示服务/div div classdescription一次性支付 · Stripe 托管收银台/div div classprice $10.00 smallUSD/small /div button idpayButton classprimary typebutton 前往测试支付 /button button idcheckButton classsecondary typebutton 查询最近订单 /button div idmessage classmessage rolestatus aria-livepolite 点击按钮创建测试订单。 /div div idorderPanel classorder hidden div classrow span订单编号/span strong idorderNo/strong /div div classrow span订单金额/span strong idorderAmount/strong /div div classrow span支付状态/span strong idorderStatus/strong /div div classrow span支付时间/span strong idpaidAt/strong /div /div /section div classtips 测试卡号4242 4242 4242 4242br 有效期任意未来日期CVC任意三位数字。br 付款结果以服务器收到的 Stripe 通知为准。 /div /main script (() { use strict; const API /index/stripe; const STORAGE_KEY stripe_demo_order_token; const payButton document.getElementById(payButton); const checkButton document.getElementById(checkButton); const message document.getElementById(message); const orderPanel document.getElementById(orderPanel); const statusLabels { pending: 等待付款或确认中, paid: 已支付, failed: 支付失败, expired: 支付会话已过期 }; let pollingTimer null; let pollingCount 0; let creating false; let checking false; function showMessage(text, type info) { message.textContent text; message.dataset.type type; } function stopPolling() { if (pollingTimer ! null) { clearTimeout(pollingTimer); pollingTimer null; } } function getToken() { try { return localStorage.getItem(STORAGE_KEY) || ; } catch { return ; } } function saveToken(token) { // 跳转前必须保存成功返回后才能查询订单。 localStorage.setItem(STORAGE_KEY, token); if (localStorage.getItem(STORAGE_KEY) ! token) { throw new Error(无法保存订单凭证请允许浏览器使用本地存储。); } } async function requestJson(url, options {}) { const controller new AbortController(); const timeout setTimeout(() controller.abort(), 30000); try { const response await fetch(url, { ...options, credentials: same-origin, cache: no-store, signal: controller.signal, headers: { Accept: application/json, ...(options.headers || {}) } }); const contentType response.headers.get(content-type) || ; if (!contentType.includes(application/json)) { throw new Error(接口没有返回JSONHTTP ${response.status} 请检查路由、登录拦截及服务器错误日志。); } const data await response.json(); if (!response.ok) { throw new Error(data.msg || 请求失败HTTP ${response.status}); } return data; } catch (error) { if (error.name AbortError) { throw new Error(请求超时请稍后查询订单并检查服务器日志。); } throw error; } finally { clearTimeout(timeout); } } function renderOrder(order) { document.getElementById(orderNo).textContent order.order_no; // 当前示例固定USD其最小货币单位为美分。 document.getElementById(orderAmount).textContent ${(Number(order.amount) / 100).toFixed(2)} String(order.currency).toUpperCase(); document.getElementById(orderStatus).textContent statusLabels[order.status] || order.status; document.getElementById(paidAt).textContent order.paid_at || —; orderPanel.hidden false; } async function checkOrder(autoPoll false) { if (checking) return; const token getToken(); if (!token) { showMessage(本浏览器暂无订单凭证请先创建测试订单。); return; } checking true; checkButton.disabled true; try { const order await requestJson(${API}/status, { headers: { X-Order-Token: token } }); renderOrder(order); if (order.status paid) { stopPolling(); showMessage(支付成功服务器已确认收款。, success); } else if (order.status failed) { stopPolling(); showMessage(该订单支付失败。, error); } else if (order.status expired) { stopPolling(); showMessage(支付会话已过期可以创建新的测试订单。); } else { showMessage( 订单尚未确认支付如已完成付款请等待回调通知。 ); if (autoPoll pollingCount 15) { pollingCount; pollingTimer setTimeout(() { pollingTimer null; checkOrder(true); }, 2000); } else if (autoPoll) { showMessage(暂未收到付款确认请稍后点击查询 并检查Stripe后台的Webhook投递记录。); } } } catch (error) { stopPolling(); showMessage(error.message, error); } finally { checking false; checkButton.disabled false; } } payButton.addEventListener(click, async () { if (creating) return; stopPolling(); creating true; payButton.disabled true; showMessage(正在创建支付会话请稍候…); try { const data await requestJson(${API}/create, {method: POST}); if (typeof data.url ! string || typeof data.token ! string || !/^[a-f0-9]{64}$/.test(data.token)) { throw new Error(服务器返回的支付数据不完整。); } const checkoutUrl new URL(data.url); if (checkoutUrl.protocol ! https: || checkoutUrl.hostname ! checkout.stripe.com) { throw new Error(服务器返回了非预期的收银台地址。); } saveToken(data.token); showMessage(支付会话已创建正在跳转Stripe收银台…); window.location.assign(checkoutUrl.href); } catch (error) { showMessage(error.message, error); creating false; payButton.disabled false; } }); checkButton.addEventListener(click, () { stopPolling(); checkOrder(false); }); const params new URLSearchParams(window.location.search); if (params.get(return) 1) { showMessage(已返回正在查询服务器确认的付款状态…); checkOrder(true); } else if (params.get(cancel) 1) { showMessage(已返回支付页面正在查询最近订单…); checkOrder(false); } window.addEventListener(pagehide, stopPolling); // 从Stripe页面通过浏览器后退返回时恢复按钮。 window.addEventListener(pageshow, (event) { if (event.persisted) { creating false; payButton.disabled false; showMessage(已返回可以查询最近订单。); } }); })(); /script /body /html后端代码安装官方php sdk;composer require stripe/stripe-php?php declare (strict_types 1); namespace app\index\controller; use Stripe\StripeClient; use Stripe\Webhook; use Stripe\Exception\SignatureVerificationException; use think\facade\Db; use think\facade\Log; use think\Request; use think\Response; use think\response\Json; use think\response\View; class Index { public function index(): View { return view(/index); } private function client(): StripeClient { $key (string) config(stripe.key); if ($key ) { throw new \RuntimeException(Stripe key missing); } return new StripeClient($key); } // POST /stripe/create public function create(): Json { // 演示商品价格由服务器决定不接收前端传入金额。 $orderNo ST . date(YmdHis) . bin2hex(random_bytes(8)); $token bin2hex(random_bytes(32)); $baseUrl (string) config(stripe.base_url); if ($baseUrl ) { return json([msg 尚未配置网站地址], 500); } Db::table(stripe_demo_order)-insert([ order_no $orderNo, access_token $token, amount 1000, currency usd, status pending, created_at date(Y-m-d H:i:s), ]); try { $session $this-client()-checkout-sessions-create([ mode payment, // 确保本次Checkout创建Customer。 // 已登录用户的正式项目应保存并复用已有Customer ID。 customer_creation always, client_reference_id $orderNo, metadata [ order_no $orderNo, ], integration_identifier tp81_h5_kqmvznra, line_items [[ price_data [ currency usd, unit_amount 1000, product_data [ name 演示服务, ], ], quantity 1, ]], success_url $baseUrl . /index/stripe?return1, cancel_url $baseUrl . /index/stripe?cancel1, // 不固定payment_method_types // 由Stripe按后台配置展示适用的支付方式。 ], [ idempotency_key checkout_ . $orderNo, ]); // 同时防止配置错用正式/测试密钥。 if ((bool) $session-livemode ! (bool) config(stripe.live)) { throw new \RuntimeException(Stripe mode mismatch); } Db::table(stripe_demo_order) -where(order_no, $orderNo) -update([ stripe_session_id $session-id, ]); return json([ url $session-url, token $token, ]); } catch (\Throwable $e) { // 不把Stripe原始错误、密钥或请求参数返回前端。 Log::error(Stripe checkout creation failed: . $orderNo); return json([ msg 支付会话创建失败请检查服务器及Stripe请求日志, ], 502); } } // GET /stripe/status public function status(Request $request) { $token (string) $request-header(X-Order-Token, ); if (!preg_match(/^[a-f0-9]{64}$/, $token)) { return json([msg 无效订单凭证], 400); } $order Db::table(stripe_demo_order) -where(access_token, $token) -field(order_no,amount,currency,status,paid_at) -find(); if (!$order) { return json([msg 订单不存在], 404); } return json($order)-header([ Cache-Control no-store, ]); } // POST /stripe/webhook public function webhook(Request $request): Response { $secret (string) config(stripe.webhook_secret); if ($secret ) { return response(Webhook not configured, 500); } try { // 必须使用原始请求体进行验签。 $event Webhook::constructEvent( $request-getContent(), (string) $request-header(Stripe-Signature, ), $secret ); } catch (SignatureVerificationException | \UnexpectedValueException $e) { return response(Invalid signature or payload, 400); } if ((bool) $event-livemode ! (bool) config(stripe.live)) { return response(Mode mismatch, 400); } $types [ checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired, ]; if (!in_array($event-type, $types, true)) { return response(ok, 200); } $session $event-data-object; $orderNo (string) ($session-metadata-order_no ?? ); // 忽略该账户中由其他系统创建的Checkout会话。 if ($orderNo ) { return response(ok, 200); } try { Db::transaction(function () use ($session, $orderNo, $event) { $order Db::table(stripe_demo_order) -where(order_no, $orderNo) -lock(true) -find(); if (!$order) { throw new \RuntimeException(Order missing); } if ( $session-mode ! payment || (string) $session-client_reference_id ! $orderNo || (int) $session-amount_total ! (int) $order[amount] || strtolower((string) $session-currency) ! $order[currency] || ( !empty($order[stripe_session_id]) $order[stripe_session_id] ! $session-id ) ) { throw new \RuntimeException(Order verification failed); } // 已支付状态不可被重复通知或乱序事件覆盖。 if ($order[status] paid) { return; } $changes [stripe_session_id $session-id]; if ( in_array($event-type, [ checkout.session.completed, checkout.session.async_payment_succeeded, ], true) $session-payment_status paid ) { $changes [ status paid, stripe_customer_id $session-customer, stripe_payment_intent_id $session-payment_intent, paid_at date(Y-m-d H:i:s), ]; // 数据库内的权益/库存变更可在此事务中处理。 // 发短信、发货等外部操作应通过事务发件箱投递 // 并以order_no保证幂等。 } elseif ($event-type checkout.session.async_payment_failed) { $changes[status] failed; } elseif ($event-type checkout.session.expired) { $changes[status] expired; } Db::table(stripe_demo_order)-where(id, $order[id])-update($changes); }); return response(ok, 200); } catch (\Throwable $e) { Log::error(Stripe webhook processing failed: . $event-id); // 返回非2xx使Stripe随后重试。 return response(Processing failed, 500); } } }路由文件app\index\route\app.php?php use think\facade\Route; Route::post(stripe/create, Index/create)-completeMatch(); Route::get(stripe/status, Index/status)-completeMatch(); Route::post(stripe/webhook, Index/webhook)-completeMatch(); // 页面路由放最后并完整匹配 Route::get(stripe, Index/index)-completeMatch();如果在调试过程中报错 php think clear 清除缓存数据库mySQL 9.7; 表结构CREATE TABLE stripe_demo_order ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, access_token CHAR(64) NOT NULL, amount INT UNSIGNED NOT NULL, currency VARCHAR(10) NOT NULL DEFAULT usd, status VARCHAR(20) NOT NULL DEFAULT pending, stripe_session_id VARCHAR(255) DEFAULT NULL, stripe_customer_id VARCHAR(255) DEFAULT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, paid_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_access_token (access_token), UNIQUE KEY uk_session (stripe_session_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;