stripe支付(外贸独立站)

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;
相关推荐
SEO_juper4 小时前
AI 爬虫 robots.txt 检查工具:你的站是不是把 AI 挡在门外了
开发语言·人工智能·爬虫·python·外贸独立站
Maynor9968 天前
两个人、一支话筒、一间橙色录音棚:Hotel Lobby AI(LobbyDuo)双人视频生成器分享
短视频·cloudflare·stripe·ai视频生成·seedance·双人视频
SEO_juper11 天前
用 Python 写一个 GEO 可见性检查脚本:你的网站现在能被 AI 引用吗
开发语言·人工智能·爬虫·python·seo·外贸独立站
SEO_juper1 个月前
2026年用Python批量审计XML Sitemap:揪出sitemap.xml中的“配置错误/死链/过期页“(附完整代码)
java·python·seo·外贸独立站·谷歌优化
SEO_juper1 个月前
2026年用Python做关键词聚类:把1000个关键词自动分成内容选题(附完整代码)
大数据·运维·人工智能·seo·外贸独立站
Linguwen1 个月前
AI外贸建站12|FAQ页怎么写:起订量打样费交期付款四问怎么答,为什么它是AI引用的黄金入口
geo·外贸独立站·企业官网·外贸建站·ai建站
Linguwen1 个月前
AI外贸建站08|给网站挂上正式门牌:买域名、接解析、HTTPS小绿锁与域名邮箱一次配齐
geo·外贸独立站·企业官网·外贸建站·ai建站
Linguwen1 个月前
AI外贸建站04|三个账号第一次联动:让Codex写一个网页,从生成到上线走完整条流水线
next.js·外贸独立站·企业官网·外贸建站·ai建站
Linguwen1 个月前
AI外贸建站03|建站三件套:GitHub、Next.js、Codex各干什么,为什么别再用模板建站
next.js·外贸独立站·企业官网·外贸建站·ai建站