PAYATHON 2026

适用于 php+mysql 网站的积分支付网关推荐

支付老李

结论

支付网关通常负责收款、退款、风控和支付结果通知,不会管理网站内部的积分。

较稳妥的方案是:

  1. 使用 Stripe、PayPal、Adyen 或当地合规的支付服务商收取法币。
  2. 在 PHP + MySQL 系统中管理积分账户、积分流水、消费规则和退款。
  3. 收到支付网关的服务端 Webhook,并确认付款成功后,再向用户发放积分。

部分游戏支付或虚拟商品平台也提供钱包、虚拟货币等完整方案,但往往对支持地区、业务类型、费率、结算方式和平台规则有更多限制。对于普通 PHP 网站,把支付和积分账本分开管理会更容易控制。

支付网关怎么选

选择支付网关时,除了 PHP 支持情况,还要确认:

  • 是否支持网站的注册地和用户所在地区。
  • 是否允许游戏、虚拟商品或数字内容业务。
  • 是否提供服务端 API、官方 PHP SDK 和 Webhook。
  • 是否支持退款、拒付处理和支付事件查询。
  • 是否要求用户跳转到托管收银台。
  • 用户购买的积分能否兑换现金、转让或提现。

常见的选择有:

  • Stripe:适合通过 API 集成,可以使用 Checkout 或 Payment Intents,并通过 Webhook 接收支付结果。
  • PayPal:用户覆盖面较广,可以通过 Orders API 创建和捕获订单。
  • Adyen:更适合规模较大、需要覆盖多个地区和支付方式的业务。
  • 本地支付服务商:如果用户主要来自某个国家或地区,本地网关通常能提供更合适的付款方式和结算能力。

支付服务商可能调整支持地区、费率和行业限制。正式接入前,应查看当前的官方协议,并以控制台的实际审核结果为准。

推荐的系统结构

以“购买 50 积分”为例,完整流程可以这样设计:

  1. 用户选择积分套餐,例如支付 5.00 元购买 50 积分。
  2. PHP 后端读取数据库中的套餐价格并创建支付订单。
  3. 后端调用支付网关 API,取得支付页面地址或支付会话 ID。
  4. 用户完成付款。
  5. 支付网关向服务器发送 Webhook。
  6. 服务器验证 Webhook 签名,并核对金额、币种和订单状态。
  7. 在同一个 MySQL 事务中将订单记为支付成功,写入积分流水并增加余额。
  8. 用户访问游戏或受限内容时,后端扣除相应积分。

不要因为浏览器跳转到了“支付成功”页面就直接发放积分。这个页面可能被刷新、伪造或重复访问。发放积分的依据应是经过签名验证的服务端通知。

数据表设计

积分系统可以同时保存余额和不可变流水。余额便于快速查询,流水则用于审计、退款和问题排查。

CREATE TABLE credit_accounts (
    user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
    balance BIGINT UNSIGNED NOT NULL DEFAULT 0,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

CREATE TABLE payment_orders (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    order_no VARCHAR(64) NOT NULL UNIQUE,
    user_id BIGINT UNSIGNED NOT NULL,
    provider VARCHAR(32) NOT NULL,
    provider_payment_id VARCHAR(128) DEFAULT NULL,
    amount_minor BIGINT UNSIGNED NOT NULL,
    currency CHAR(3) NOT NULL,
    credits BIGINT UNSIGNED NOT NULL,
    status ENUM('pending', 'paid', 'failed', 'refunded') NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    paid_at DATETIME DEFAULT NULL,
    UNIQUE KEY uq_provider_payment (provider, provider_payment_id)
) ENGINE=InnoDB;

CREATE TABLE credit_ledger (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    change_amount BIGINT NOT NULL,
    balance_after BIGINT UNSIGNED NOT NULL,
    reason VARCHAR(32) NOT NULL,
    reference_type VARCHAR(32) NOT NULL,
    reference_id VARCHAR(128) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uq_credit_reference (
        user_id,
        reason,
        reference_type,
        reference_id
    )
) ENGINE=InnoDB;

CREATE TABLE webhook_events (
    provider VARCHAR(32) NOT NULL,
    event_id VARCHAR(128) NOT NULL,
    processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (provider, event_id)
) ENGINE=InnoDB;

金额应使用最小货币单位保存。例如,用 500 表示 5.00 元,不要使用浮点数计算金额。

支付成功后发放积分

下面的 PHP 示例假设 Webhook 已经使用支付服务商的官方 SDK 验证签名,并转换为内部统一格式。verifyAndParseGatewayWebhook() 只是一个接口占位,不能直接用于生产环境。实际项目必须按照所选网关的签名算法和官方 SDK 完成具体实现。

<?php

declare(strict_types=1);

$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
    'app_user',
    'secret',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]
);

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';

$event = verifyAndParseGatewayWebhook($payload, $signature);

if ($event['type'] !== 'payment.succeeded') {
    http_response_code(200);
    exit;
}

$pdo->beginTransaction();

try {
    // Webhook 可能被重复发送,先登记事件。
    $stmt = $pdo->prepare(
        'INSERT IGNORE INTO webhook_events (provider, event_id)
         VALUES (:provider, :event_id)'
    );
    $stmt->execute([
        'provider' => $event['provider'],
        'event_id' => $event['event_id'],
    ]);

    if ($stmt->rowCount() === 0) {
        $pdo->commit();
        http_response_code(200);
        exit;
    }

    $stmt = $pdo->prepare(
        'SELECT *
           FROM payment_orders
          WHERE order_no = :order_no
          FOR UPDATE'
    );
    $stmt->execute(['order_no' => $event['order_no']]);
    $order = $stmt->fetch();

    if (!$order) {
        throw new RuntimeException('Unknown order');
    }

    if (
        (int) $order['amount_minor'] !== (int) $event['amount_minor'] ||
        $order['currency'] !== $event['currency']
    ) {
        throw new RuntimeException('Payment amount or currency mismatch');
    }

    if ($order['status'] !== 'paid') {
        $stmt = $pdo->prepare(
            "UPDATE payment_orders
                SET status = 'paid',
                    provider_payment_id = :payment_id,
                    paid_at = NOW()
              WHERE id = :id"
        );
        $stmt->execute([
            'payment_id' => $event['payment_id'],
            'id' => $order['id'],
        ]);

        $stmt = $pdo->prepare(
            'INSERT INTO credit_accounts (user_id, balance)
             VALUES (:user_id, :credits)
             ON DUPLICATE KEY UPDATE balance = balance + :credits'
        );
        $stmt->execute([
            'user_id' => $order['user_id'],
            'credits' => $order['credits'],
        ]);

        $stmt = $pdo->prepare(
            'SELECT balance
               FROM credit_accounts
              WHERE user_id = :user_id
              FOR UPDATE'
        );
        $stmt->execute(['user_id' => $order['user_id']]);
        $balance = (int) $stmt->fetchColumn();

        $stmt = $pdo->prepare(
            "INSERT INTO credit_ledger (
                user_id,
                change_amount,
                balance_after,
                reason,
                reference_type,
                reference_id
             ) VALUES (
                :user_id,
                :change_amount,
                :balance_after,
                'purchase',
                'payment_order',
                :reference_id
             )"
        );
        $stmt->execute([
            'user_id' => $order['user_id'],
            'change_amount' => $order['credits'],
            'balance_after' => $balance,
            'reference_id' => (string) $order['id'],
        ]);
    }

    $pdo->commit();
    http_response_code(200);
} catch (Throwable $e) {
    $pdo->rollBack();

    // 生产环境中记录内部错误,但不要向外返回数据库或密钥信息。
    http_response_code(500);
}

订单对应的用户、金额和积分数量都必须由服务器生成。不能相信浏览器提交的 user_id、价格或积分数量,否则用户可能篡改请求,用很低的金额购买大量积分。

扣除积分

扣除积分时也要使用数据库事务和行锁,以免用户同时发起多个请求,导致余额透支。

<?php

function spendCredits(
    PDO $pdo,
    int $userId,
    int $cost,
    string $contentId
): void {
    if ($cost <= 0) {
        throw new InvalidArgumentException('Invalid credit cost');
    }

    $pdo->beginTransaction();

    try {
        $stmt = $pdo->prepare(
            'SELECT balance
               FROM credit_accounts
              WHERE user_id = :user_id
              FOR UPDATE'
        );
        $stmt->execute(['user_id' => $userId]);

        $balance = $stmt->fetchColumn();

        if ($balance === false || (int) $balance < $cost) {
            throw new RuntimeException('Insufficient credits');
        }

        $newBalance = (int) $balance - $cost;

        $stmt = $pdo->prepare(
            'UPDATE credit_accounts
                SET balance = :balance
              WHERE user_id = :user_id'
        );
        $stmt->execute([
            'balance' => $newBalance,
            'user_id' => $userId,
        ]);

        $stmt = $pdo->prepare(
            "INSERT INTO credit_ledger (
                user_id,
                change_amount,
                balance_after,
                reason,
                reference_type,
                reference_id
             ) VALUES (
                :user_id,
                :change_amount,
                :balance_after,
                'content_access',
                'content',
                :reference_id
             )"
        );
        $stmt->execute([
            'user_id' => $userId,
            'change_amount' => -$cost,
            'balance_after' => $newBalance,
            'reference_id' => $contentId,
        ]);

        $pdo->commit();
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

如果同一项内容只应收费一次,还要建立单独的访问授权表,并为 user_id + content_id 设置唯一索引。只用前端按钮无法防止重复扣款。

退款和拒付不能忽略

积分到账后,交易仍可能发生变化。用户可以申请退款,发卡机构也可能发起拒付。

系统至少需要确定以下规则:

  • 退款后是否自动收回尚未使用的积分。
  • 积分已经消费时,账户是否可以出现负余额。
  • 发生拒付后,是否暂停账户或限制用户继续购买。
  • 部分退款应扣回多少积分。
  • 管理员人工调整积分时,如何保留审计记录。

退款和拒付也应通过 Webhook 处理,并写入新的负数流水。不要删除原来的购买流水,否则以后很难查清余额为何发生变化。

还需要注意的边界

站内积分最好设置为不可提现、不可转让,只能用于购买明确的数字内容或服务。如果积分能够兑换现金、在用户之间转账,或者形成二级交易市场,业务可能涉及储值、电子货币、汇款、博彩或其他监管要求。

服务条款还应明确积分有效期、退款规则、账号封禁后的处理方式和内容定价。法律、税务及支付合规要求会因经营地区和业务类型而异,需要向当地专业人士及支付服务商确认。

支付网关负责判断“钱是否收到”,PHP + MySQL 系统负责确定“收到钱后发多少积分、积分如何使用,以及怎样记录每次余额变化”。两部分之间应通过经过签名验证且具备幂等性的 Webhook 衔接。

备注:内容仅供参考。