1、环境:centOS stream 9、thinkphp8.1、php8.4;
2、前端html5\es6-es2024新特性的应用;
3、利用了stripe沙盒测试;
注意事项:该演示是沙盒测试,并非生成环境;
前提条件:
bash
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位数字
配置文件:
php
#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
];
前端代码:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-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';">
<title>Stripe 沙盒支付演示</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-type="success"] {
background: #ecfdf3;
color: #15803d;
}
.message[data-type="error"] {
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 class="page">
<span class="badge">SANDBOX · 沙盒测试</span>
<h1>在线支付演示</h1>
<p class="subtitle">
ThinkPHP 8.1 + Stripe Checkout<br>
使用测试卡完成付款,不会产生真实扣款。
</p>
<section class="card">
<div class="product">演示服务</div>
<div class="description">一次性支付 · Stripe 托管收银台</div>
<div class="price">
$10.00 <small>USD</small>
</div>
<button id="payButton" class="primary" type="button">
前往测试支付
</button>
<button id="checkButton" class="secondary" type="button">
查询最近订单
</button>
<div id="message" class="message" role="status" aria-live="polite">
点击按钮创建测试订单。
</div>
<div id="orderPanel" class="order" hidden>
<div class="row">
<span>订单编号</span>
<strong id="orderNo"></strong>
</div>
<div class="row">
<span>订单金额</span>
<strong id="orderAmount"></strong>
</div>
<div class="row">
<span>支付状态</span>
<strong id="orderStatus"></strong>
</div>
<div class="row">
<span>支付时间</span>
<strong id="paidAt"></strong>
</div>
</div>
</section>
<div class="tips">
测试卡号:4242 4242 4242 4242<br>
有效期:任意未来日期;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(`接口没有返回JSON(HTTP ${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;
php
composer require stripe/stripe-php
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?return=1',
'cancel_url' => $baseUrl . '/index/stripe?cancel=1',
// 不固定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
<?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
如果在调试过程中报错:
php think clear
清除缓存;
数据库mySQL 9.7; 表结构:
sql
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;