Payment system:如何用 PHP 在用户与管理员间分账
结论
PHP 可以负责业务编排,但不能只凭系统中保存的信用卡或借记卡信息,直接把广告费分别“转入”用户卡和管理员卡。此类业务应接入支持平台收款和分账的支付服务商,例如 Stripe Connect、Adyen for Platforms,或所在地区具备相应资质的支付机构。
支付流程可以拆成三部分:
- 广告主授权平台扣款。
- 平台在支付服务商内部完成分账,或先计入内部账本,再定期结算。
- 支付服务商把用户收入提现到已验证的银行账户或受支持的借记卡。
PHP 负责调用支付 API、维护内部账本和处理 Webhook。银行卡数据、身份验证、资金托管和实际清算应由合规的支付服务商处理。
推荐的资金流程
如果一次有效广告点击对应 $10,可以按下面的方式分配:
广告主支付 $10
├── 用户收入:$5
└── 平台收入:$5
这里的 $5 + $5 通常只是业务上的毛额分配。支付手续费、退款和争议款由哪一方承担,需要另外约定。扣除手续费后,双方实际收到的金额未必都是 $5。
完整流程如下:
- 广告主注册并添加支付方式。
- 前端通过支付服务商提供的组件采集卡信息。
- 后端只保存
customer_id、payment_method_id等令牌,不保存卡号或 CVV。 - 收款用户完成实名认证,并创建支付平台的 connected account 或 sub-merchant account。
- 用户点击广告后,服务端验证点击是否有效。
- 系统为该点击创建一条不可重复的计费事件。
- 从广告主授权的支付方式扣除 $10。
- 将 $5 记为用户收入,另外 $5 记为平台收入。
- 收到 Webhook 并确认支付成功后,再把账本状态改为可结算。
- 用户收入达到结算条件后,由支付服务商提现到其银行账户或受支持的卡。
不能根据浏览器发来的“点击成功”请求直接付款。点击事件可能被重复提交、伪造,也可能由机器人批量触发,因此必须先经过服务端去重和反作弊检查。
更适合广告计费的结算方式
技术上,每次点击都可以创建一笔 $10 的支付。但在实际系统中,更常见的方式是通过内部账本累计金额,再批量扣款和结算:
有效点击
↓
写入计费账本
↓
累计广告主消费和用户收入
↓
达到阈值或结算周期
↓
统一扣款、分账和提现
这种方式可以减少支付 API 调用、支付手续费、失败交易以及退款处理成本。
例如,每次确认有效点击后只记录:
advertiser_debit = 1000 cents
user_credit = 500 cents
platform_credit = 500 cents
等到每日结算,或广告主余额不足时,再统一执行资金操作。账本金额应使用最小货币单位的整数。例如,$10.00 应保存为 1000 cents,不能使用浮点数计算金额。
PHP 分账示例
下面使用 Stripe Connect 的 destination charge 模式说明基本流程。示例中的账户 ID、支付方式和异常处理仍需根据实际系统补充。
使用该流程需要满足以下条件:
- 广告主已经以 Customer 形式保存了可用的支付方式;
- 用户已经完成 Connected Account 的开通和验证;
- 广告主已经同意相应的自动扣款条款;
- 当前国家、币种和账户类型支持这种资金流。
<?php
require __DIR__ . '/vendor/autoload.php';
$stripe = new \Stripe\StripeClient($_ENV['STRIPE_SECRET_KEY']);
$advertiserCustomerId = 'cus_xxx';
$paymentMethodId = 'pm_xxx';
$userConnectedAccountId = 'acct_xxx';
$clickId = 'click_123456';
try {
$paymentIntent = $stripe->paymentIntents->create(
[
'amount' => 1000,
'currency' => 'usd',
'customer' => $advertiserCustomerId,
'payment_method' => $paymentMethodId,
'confirm' => true,
'off_session' => true,
// 平台保留 $5
'application_fee_amount' => 500,
// 剩余金额转入用户的 Connected Account
'transfer_data' => [
'destination' => $userConnectedAccountId,
],
'metadata' => [
'click_id' => $clickId,
'billing_type' => 'ad_click',
],
],
[
// 防止同一点击因重试而被重复扣款
'idempotency_key' => 'ad-click-payment-' . $clickId,
]
);
echo $paymentIntent->id;
} catch (\Stripe\Exception\CardException $e) {
// 卡被拒绝、需要额外验证等支付错误
error_log($e->getMessage());
} catch (\Stripe\Exception\ApiErrorException $e) {
// 网络错误、参数错误或支付服务异常
error_log($e->getMessage());
}
这段代码表示:广告主支付 1000 cents,平台通过 application_fee_amount 保留 500 cents,其余资金进入用户的 Connected Account。
它不能保证双方最终净到账都恰好是 $5。处理费、跨境费、货币转换费、退款和拒付损失如何分配,取决于支付服务商的规则、账户配置及平台协议。
必须通过 Webhook 确认结果
不能只根据 PHP API 的调用返回值永久确认收入。支付可能异步处理,也可能失败、退款或发生争议。系统需要验证支付服务商的签名,并处理 Webhook:
<?php
require __DIR__ . '/vendor/autoload.php';
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$webhookSecret = $_ENV['STRIPE_WEBHOOK_SECRET'];
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$signature,
$webhookSecret
);
} catch (\UnexpectedValueException $e) {
http_response_code(400);
exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit;
}
switch ($event->type) {
case 'payment_intent.succeeded':
$paymentIntent = $event->data->object;
// 根据 paymentIntent.id 或 metadata.click_id 查询本地记录。
// 在数据库事务中将支付标记为成功。
// 必须设置唯一约束,确保重复 Webhook 不会重复入账。
break;
case 'payment_intent.payment_failed':
$paymentIntent = $event->data->object;
// 标记扣款失败,不产生可提现收入。
break;
case 'charge.refunded':
case 'charge.dispute.created':
// 冲销收入或冻结相关结算金额。
break;
}
http_response_code(200);
Webhook 处理程序必须具备幂等性。支付服务商可能针对同一事件发送多次通知,系统不能因此重复增加用户余额。
数据库至少需要记录什么
点击事件、支付记录和账本分录最好分开保存,不要只修改一个余额字段:
ad_clicks
- id
- advertiser_id
- user_id
- ad_id
- occurred_at
- validation_status
- billing_status
payments
- id
- click_id
- provider_payment_id
- amount
- currency
- status
- idempotency_key
ledger_entries
- id
- payment_id
- account_id
- entry_type
- amount
- currency
- status
同一个 click_id 只能生成一条计费记录,provider_payment_id 和 idempotency_key 也应设置唯一约束。仅靠前端禁用按钮或 PHP 内存变量,无法阻止并发请求造成重复扣款。
不应保存原始银行卡信息
对“系统已保存银行卡信息”这一点需要格外谨慎。
系统不应自行保存:
- 完整卡号;
- CVV/CVC;
- 磁道数据;
- 未加密的银行账户信息。
更安全的方案是使用支付服务商的托管页面或前端组件采集卡信息。系统只保存令牌和必要的展示信息,例如:
customer_id: cus_xxx
payment_method_id: pm_xxx
brand: visa
last4: 4242
CVV 通常禁止在授权后保存。即使数据经过加密,只要系统自行接触完整卡数据,就会显著扩大 PCI DSS 的合规范围。
信用卡通常用于付款,不是任意收款人的通用入账账户。用户提现应通过支付服务商支持的银行账户、借记卡或其他 payout destination 完成。
自动扣款的限制
保存支付方式并不意味着平台可以随时扣款。平台通常还需要:
- 获得广告主对扣款金额或计费规则的明确授权;
- 保存用户协议、授权时间和授权范围;
- 正确设置后续的 off-session 使用场景;
- 在适用地区处理 3-D Secure、SCA 等额外认证;
- 如果扣款需要重新认证,应提供返回前端完成确认的流程;
- 提前说明扣款周期、退款政策和账单描述。
因此,off_session => true 不能保证支付一定成功。支付机构或发卡行仍可能要求广告主重新验证。
支付与合规问题
PHP 本身没有特殊的支付限制。风险来自资金业务的设计,而不是使用哪种编程语言。上线前至少要确认以下事项:
- 支付服务商是否允许广告点击奖励、广告分成或类似业务;
- 平台是否属于 marketplace、支付促进方或其他受监管模式;
- 用户是否必须完成 KYC/KYB、税务信息和制裁名单检查;
- 平台是否可以代收、暂存或延迟结算第三方资金;
- 用户获得点击收入后是否需要申报税务;
- 未成年人、跨境支付和多币种结算是否受到限制;
- 退款、拒付和欺诈损失由广告主、平台还是用户承担;
- 隐私政策和用户协议是否涵盖支付数据及反欺诈处理。
具体要求会随国家、地区、支付服务商和业务模式变化,无法仅靠一份通用代码判断。选定服务商后,应由其合规团队以及当地的法律、税务专业人员进一步确认。
实施时最容易忽略的问题
-
点击欺诈
系统需要过滤机器人、自点广告、重复点击、代理流量和异常设备,不能由客户端决定一次点击是否产生收入。
-
手续费分配
如果广告主只支付 $10,处理费就必须由其中一方承担。如果要求用户和管理员净收各 $5,广告主实际支付的金额通常需要高于 $10,具体金额取决于费率。
-
退款和拒付
已经分给用户的资金可能需要追回。用户余额应区分
pending、available和paid,并设置结算等待期和负余额处理规则。 -
金额精度
金额应使用整数形式的最小货币单位,并配套明确的币种字段,不能使用
float:$totalAmount = 1000; $userShare = intdiv($totalAmount, 2); $platformShare = $totalAmount - $userShare; -
密钥管理
API Secret Key 和 Webhook Secret 应保存在环境变量或密钥管理系统中,不能提交到代码仓库,也不能发送到浏览器。
-
服务端可信性
金额、收款账户和分成比例必须由服务端根据数据库配置生成,不能直接接受前端传入的金额或目标账户。
真实业务更适合采用“支付服务商的平台分账账户 + 内部双重记账账本 + Webhook 确认 + 延迟批量结算”的方案。PHP 可以负责整个流程的自动化,敏感卡数据、身份验证和资金清算则交给具备相应能力的支付机构。
备注:内容仅供参考。
