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 */ 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 $params * @return array */ 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 $params * @return array */ 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 $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 $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 $payload * @return array */ 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; } }