适用于 php+mysql 网站的积分支付网关推荐
结论
支付网关通常负责收款、退款、风控和支付结果通知,不会管理网站内部的积分。
较稳妥的方案是:
- 使用 Stripe、PayPal、Adyen 或当地合规的支付服务商收取法币。
- 在 PHP + MySQL 系统中管理积分账户、积分流水、消费规则和退款。
- 收到支付网关的服务端 Webhook,并确认付款成功后,再向用户发放积分。
部分游戏支付或虚拟商品平台也提供钱包、虚拟货币等完整方案,但往往对支持地区、业务类型、费率、结算方式和平台规则有更多限制。对于普通 PHP 网站,把支付和积分账本分开管理会更容易控制。
支付网关怎么选
选择支付网关时,除了 PHP 支持情况,还要确认:
- 是否支持网站的注册地和用户所在地区。
- 是否允许游戏、虚拟商品或数字内容业务。
- 是否提供服务端 API、官方 PHP SDK 和 Webhook。
- 是否支持退款、拒付处理和支付事件查询。
- 是否要求用户跳转到托管收银台。
- 用户购买的积分能否兑换现金、转让或提现。
常见的选择有:
- Stripe:适合通过 API 集成,可以使用 Checkout 或 Payment Intents,并通过 Webhook 接收支付结果。
- PayPal:用户覆盖面较广,可以通过 Orders API 创建和捕获订单。
- Adyen:更适合规模较大、需要覆盖多个地区和支付方式的业务。
- 本地支付服务商:如果用户主要来自某个国家或地区,本地网关通常能提供更合适的付款方式和结算能力。
支付服务商可能调整支持地区、费率和行业限制。正式接入前,应查看当前的官方协议,并以控制台的实际审核结果为准。
推荐的系统结构
以“购买 50 积分”为例,完整流程可以这样设计:
- 用户选择积分套餐,例如支付 5.00 元购买 50 积分。
- PHP 后端读取数据库中的套餐价格并创建支付订单。
- 后端调用支付网关 API,取得支付页面地址或支付会话 ID。
- 用户完成付款。
- 支付网关向服务器发送 Webhook。
- 服务器验证 Webhook 签名,并核对金额、币种和订单状态。
- 在同一个 MySQL 事务中将订单记为支付成功,写入积分流水并增加余额。
- 用户访问游戏或受限内容时,后端扣除相应积分。
不要因为浏览器跳转到了“支付成功”页面就直接发放积分。这个页面可能被刷新、伪造或重复访问。发放积分的依据应是经过签名验证的服务端通知。
数据表设计
积分系统可以同时保存余额和不可变流水。余额便于快速查询,流水则用于审计、退款和问题排查。
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 衔接。
备注:内容仅供参考。
