Files
webman-buildadmin/app/common/library/finance/DDPayGateway.php
zhenhui 1b8d947f97 0.使用模拟数据进行充值和提现
1.优化提现接口/api/finance/withdrawCreate
2.优化充值接口/api/finance/depositCreate
2026-05-20 15:57:19 +08:00

373 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\library\finance;
use InvalidArgumentException;
use RuntimeException;
use Webman\Http\Request;
/**
* DDPay 支付网关接入(基于文档 v1.1.3/1.1.x
* - MD5 签名key-value 按 ASCII 升序拼接 + 追加 &key=API_SECRET
* - 发送 HTTPS POSTapplication/json
* - 支持入金Deposit与回调通知Webhook
*
* 注意:生产环境需在 config/app.php 或环境变量中配置 DDPAY_*。
*/
final class DDPayGateway
{
private const SIGNATURE_FIELD = 'signature';
private const SECRET_KEY_PARAM = 'key';
/**
* 入金/出金回调 URL 使用的公网根地址(无尾部斜杠)。
* 优先 `DDPAY_PUBLIC_BASE_URL`(见 config/app.php未配置时按请求头推导生产务必配置 HTTPS 公网地址,与 DDPay 文档一致)。
*/
public static function publicBaseUrlForCallbacks(?Request $request = null): string
{
$cfg = config('app.ddpay_public_base_url', '');
if (is_string($cfg) && trim($cfg) !== '') {
return rtrim(trim($cfg), '/');
}
if ($request === null) {
return '';
}
$proto = strtolower((string) $request->header('x-forwarded-proto', ''));
$https = $proto === 'https' || strtolower((string) $request->header('x-forwarded-ssl', '')) === 'on';
$scheme = $https ? 'https' : 'http';
$host = trim((string) $request->header('host', ''));
if ($host === '') {
$host = trim((string) ($request->header('x-forwarded-host', '')));
}
if ($host === '') {
$host = '127.0.0.1:8787';
}
return $scheme . '://' . $host;
}
/**
* 是否已配置 DDPay 入金所需项(未配置时不应调用三方接口)
*/
public static function isConfigured(): bool
{
$envMap = [
'ddpay_client_id' => 'DDPAY_CLIENT_ID',
'ddpay_identifier' => 'DDPAY_IDENTIFIER',
'ddpay_api_secret' => 'DDPAY_API_SECRET',
'ddpay_deposit_init_url' => 'DDPAY_DEPOSIT_INIT_URL',
];
foreach ($envMap as $cfgKey => $envKey) {
$v = getenv($envKey);
if (!is_string($v) || trim($v) === '') {
$cfg = config('app.' . $cfgKey, '');
if (!is_string($cfg) || trim($cfg) === '') {
return false;
}
}
}
return true;
}
/**
* @return array<string, mixed>
*/
public static function depositInitiation(array $params): array
{
$endpoint = self::requireConfig('ddpay_deposit_init_url');
$apiSecret = self::requireConfig('ddpay_api_secret');
$req = $params;
if (isset($req[self::SIGNATURE_FIELD])) {
unset($req[self::SIGNATURE_FIELD]);
}
$req[self::SIGNATURE_FIELD] = self::computeSignature($req, $apiSecret);
$resp = self::postJson($endpoint, $req);
// 按文档:响应签名需使用同方法校验(字段名 signature
if (array_key_exists(self::SIGNATURE_FIELD, $resp)) {
$sig = is_string($resp[self::SIGNATURE_FIELD]) ? $resp[self::SIGNATURE_FIELD] : '';
if ($sig !== '') {
$respForSign = $resp;
unset($respForSign[self::SIGNATURE_FIELD]);
$expected = self::computeSignature($respForSign, $apiSecret);
if (!hash_equals($expected, $sig)) {
throw new RuntimeException('DDPay response signature mismatch');
}
}
}
$statusCode = self::intValue($resp['status_code'] ?? 0);
if ($statusCode !== 0) {
$msg = is_string($resp['status_message'] ?? null) ? $resp['status_message'] : 'DDPay deposit initiation failed';
throw new RuntimeException($msg);
}
return $resp;
}
/**
* 出金发起Payout Initiation
*
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
public static function payoutInitiation(array $params): array
{
$endpoint = self::requireConfig('ddpay_payout_init_url');
$apiSecret = self::requireConfig('ddpay_api_secret');
$req = $params;
if (isset($req[self::SIGNATURE_FIELD])) {
unset($req[self::SIGNATURE_FIELD]);
}
$req[self::SIGNATURE_FIELD] = self::computeSignature($req, $apiSecret);
$resp = self::postJson($endpoint, $req);
// 按文档:响应签名需使用同方法校验(字段名 signature
if (array_key_exists(self::SIGNATURE_FIELD, $resp)) {
$sig = is_string($resp[self::SIGNATURE_FIELD]) ? $resp[self::SIGNATURE_FIELD] : '';
if ($sig !== '') {
$respForSign = $resp;
unset($respForSign[self::SIGNATURE_FIELD]);
$expected = self::computeSignature($respForSign, $apiSecret);
if (!hash_equals($expected, $sig)) {
throw new RuntimeException('DDPay response signature mismatch');
}
}
}
$statusCode = self::intValue($resp['status_code'] ?? 0);
if ($statusCode !== 0) {
$msg = is_string($resp['status_message'] ?? null) ? $resp['status_message'] : 'DDPay payout initiation failed';
throw new RuntimeException($msg);
}
return $resp;
}
/**
* 出金状态查询Payout Status Inquiry
*
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
public static function payoutStatusInquiry(array $params): array
{
$endpoint = self::requireConfig('ddpay_payout_status_url');
$apiSecret = self::requireConfig('ddpay_api_secret');
$req = $params;
if (isset($req[self::SIGNATURE_FIELD])) {
unset($req[self::SIGNATURE_FIELD]);
}
$req[self::SIGNATURE_FIELD] = self::computeSignature($req, $apiSecret);
$resp = self::postJson($endpoint, $req);
if (array_key_exists(self::SIGNATURE_FIELD, $resp)) {
$sig = is_string($resp[self::SIGNATURE_FIELD]) ? $resp[self::SIGNATURE_FIELD] : '';
if ($sig !== '') {
$respForSign = $resp;
unset($respForSign[self::SIGNATURE_FIELD]);
$expected = self::computeSignature($respForSign, $apiSecret);
if (!hash_equals($expected, $sig)) {
throw new RuntimeException('DDPay response signature mismatch');
}
}
}
$statusCode = self::intValue($resp['status_code'] ?? 0);
if ($statusCode !== 0) {
$msg = is_string($resp['status_message'] ?? null) ? $resp['status_message'] : 'DDPay payout status inquiry failed';
throw new RuntimeException($msg);
}
return $resp;
}
/**
* 校验 DDPay Webhook 通知签名。
*
* @param array<string, mixed> $payload
*/
public static function verifyWebhookSignature(array $payload): bool
{
$apiSecret = self::requireConfig('ddpay_api_secret');
$sigRaw = $payload[self::SIGNATURE_FIELD] ?? '';
$sig = is_string($sigRaw) ? trim($sigRaw) : '';
if ($sig === '') {
return false;
}
$payloadForSign = $payload;
unset($payloadForSign[self::SIGNATURE_FIELD]);
$expected = self::computeSignature($payloadForSign, $apiSecret);
return hash_equals($expected, $sig);
}
/**
* @param array<string, mixed> $params
*/
private static function computeSignature(array $params, string $apiSecret): string
{
// 1) 排除 signature & 空值/null
$filtered = [];
foreach ($params as $k => $v) {
if (!is_string($k) || $k === '') {
continue;
}
if ($k === self::SIGNATURE_FIELD) {
continue;
}
if ($v === null) {
continue;
}
if (is_string($v) && trim($v) === '') {
continue;
}
if (is_bool($v)) {
$filtered[$k] = $v ? 'true' : 'false';
continue;
}
if (is_int($v) || is_float($v) || is_numeric($v)) {
$filtered[$k] = strval($v);
continue;
}
if (is_string($v)) {
$filtered[$k] = trim($v);
continue;
}
// 数组/对象等不应参与签名;忽略它们
if (is_array($v) || is_object($v)) {
continue;
}
$filtered[$k] = strval($v);
}
// 2) 按 ASCII 升序排序 key
ksort($filtered, SORT_STRING);
// 3) 拼接 key=value用 & 连接
$pairs = [];
foreach ($filtered as $k => $v) {
$pairs[] = $k . '=' . $v;
}
// 4) 追加 &key=API_SECRET
$base = implode('&', $pairs);
$signStr = $base . '&' . self::SECRET_KEY_PARAM . '=' . $apiSecret;
// 5) MD5 小写
$hash = md5($signStr);
return is_string($hash) ? strtolower($hash) : '';
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private static function postJson(string $url, array $payload): array
{
if (!function_exists('curl_init')) {
throw new RuntimeException('curl extension is required for DDPayGateway');
}
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($body) || $body === '') {
throw new RuntimeException('DDPay request body encode failed');
}
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('DDPay curl_init failed');
}
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json; charset=utf-8',
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$respBody = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($respBody === false) {
throw new RuntimeException('DDPay request failed: ' . ($errno > 0 ? 'curl_' . strval($errno) : 'unknown'));
}
if (!is_numeric($httpCode)) {
throw new RuntimeException('DDPay request http code invalid');
}
$httpCodeInt = intval(strval($httpCode));
if ($httpCodeInt < 200 || $httpCodeInt >= 300) {
$snippet = '';
if (is_string($respBody)) {
$snippet = trim($respBody);
if (mb_strlen($snippet) > 400) {
$snippet = mb_substr($snippet, 0, 400) . '...';
}
}
$suffix = $snippet !== '' ? (' body=' . $snippet) : '';
throw new RuntimeException('DDPay request http failed, http_code=' . strval($httpCodeInt) . $suffix);
}
$decoded = json_decode(is_string($respBody) ? $respBody : '', true);
if (!is_array($decoded)) {
throw new RuntimeException('DDPay response decode failed');
}
return $decoded;
}
/**
* @return mixed
*/
private static function requireConfig(string $key): mixed
{
$v = config('app.' . $key, '');
if (!is_string($v)) {
return '';
}
$s = trim($v);
if ($s === '') {
throw new InvalidArgumentException('Missing config: app.' . $key);
}
return $s;
}
/**
* @param mixed $v
*/
private static function intValue(mixed $v): int
{
if (is_int($v)) {
return $v;
}
if (is_string($v) && $v !== '') {
$n = filter_var($v, FILTER_VALIDATE_INT);
return $n === false ? 0 : intval(strval($n));
}
if (is_numeric($v)) {
$n = filter_var($v, FILTER_VALIDATE_INT);
return $n === false ? 0 : intval(strval($n));
}
return 0;
}
}