初始化仓库

This commit is contained in:
wangxiaowei
2025-04-22 14:09:52 +08:00
commit 8b100110bb
5155 changed files with 664201 additions and 0 deletions

View File

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay;
use EasyWeChat\Kernel\Contracts\Config as ConfigInterface;
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
use EasyWeChat\Kernel\Support\PrivateKey;
use EasyWeChat\Kernel\Support\PublicKey;
use EasyWeChat\Kernel\Traits\InteractWithConfig;
use EasyWeChat\Kernel\Traits\InteractWithHttpClient;
use EasyWeChat\Kernel\Traits\InteractWithServerRequest;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class Application implements \EasyWeChat\Pay\Contracts\Application
{
use InteractWithConfig;
use InteractWithHttpClient;
use InteractWithServerRequest;
protected ?ServerInterface $server = null;
protected ?HttpClientInterface $client = null;
protected ?Merchant $merchant = null;
/**
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
*/
public function getUtils(): Utils
{
return new Utils($this->getMerchant());
}
/**
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
*/
public function getMerchant(): Merchant
{
if (! $this->merchant) {
$this->merchant = new Merchant(
mchId: $this->config['mch_id'], /** @phpstan-ignore-line */
privateKey: new PrivateKey((string) $this->config['private_key']), /** @phpstan-ignore-line */
certificate: new PublicKey((string) $this->config['certificate']), /** @phpstan-ignore-line */
secretKey: (string) $this->config['secret_key'], /** @phpstan-ignore-line */
v2SecretKey: (string) $this->config['v2_secret_key'], /** @phpstan-ignore-line */
platformCerts: $this->config->has('platform_certs') ? (array) $this->config['platform_certs'] : [],/** @phpstan-ignore-line */
);
}
return $this->merchant;
}
/**
* @throws \ReflectionException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \Throwable
*/
public function getServer(): Server|ServerInterface
{
if (! $this->server) {
$this->server = new Server(
merchant: $this->getMerchant(),
request: $this->getRequest(),
);
}
return $this->server;
}
public function setServer(ServerInterface $server): static
{
$this->server = $server;
return $this;
}
public function setConfig(ConfigInterface $config): static
{
$this->config = $config;
return $this;
}
public function getConfig(): ConfigInterface
{
return $this->config;
}
/**
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
*/
public function getClient(): HttpClientInterface
{
return $this->client ?? $this->client = (new Client(
$this->getMerchant(),
$this->getHttpClient(),
(array) $this->config->get('http', [])
))->setPresets($this->config->all());
}
public function setClient(HttpClientInterface $client): static
{
$this->client = $client;
return $this;
}
}

View File

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
use EasyWeChat\Kernel\HttpClient\HttpClientMethods;
use EasyWeChat\Kernel\HttpClient\RequestUtil;
use EasyWeChat\Kernel\HttpClient\RequestWithPresets;
use EasyWeChat\Kernel\HttpClient\Response;
use EasyWeChat\Kernel\Support\PrivateKey;
use EasyWeChat\Kernel\Support\PublicKey;
use EasyWeChat\Kernel\Support\UserAgent;
use EasyWeChat\Kernel\Support\Xml;
use EasyWeChat\Kernel\Traits\MockableHttpClient;
use Exception;
use function is_array;
use function is_string;
use Mockery;
use Mockery\Mock;
use Nyholm\Psr7\Uri;
use function str_starts_with;
use Symfony\Component\HttpClient\DecoratorTrait;
use Symfony\Component\HttpClient\HttpClient as SymfonyHttpClient;
use Symfony\Component\HttpClient\HttpClientTrait;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @method ResponseInterface get(string $uri, array $options = [])
* @method ResponseInterface post(string $uri, array $options = [])
* @method ResponseInterface put(string $uri, array $options = [])
* @method ResponseInterface patch(string $uri, array $options = [])
* @method ResponseInterface delete(string $uri, array $options = [])
* @method HttpClientInterface withMchId(string $value = null)
* @method HttpClientInterface withMchIdAs(string $key)
*/
class Client implements HttpClientInterface
{
use DecoratorTrait {
DecoratorTrait::withOptions insteadof HttpClientTrait;
}
use HttpClientTrait;
use HttpClientMethods;
use MockableHttpClient;
use RequestWithPresets;
/**
* @var array<string, mixed>
*/
protected array $defaultOptions = [
'base_uri' => 'https://api.mch.weixin.qq.com/',
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
];
public const V3_URI_PREFIXES = [
'/v3/',
'/sandbox/v3/',
'/hk/v3/',
'/global/v3/',
];
protected bool $throw = true;
/**
* @param array<string, mixed> $defaultOptions
*/
public function __construct(
protected Merchant $merchant,
?HttpClientInterface $client = null,
array $defaultOptions = []
) {
$this->throw = (bool) ($defaultOptions['throw'] ?? true);
$this->defaultOptions = array_merge(self::OPTIONS_DEFAULTS, $this->defaultOptions);
if (! empty($defaultOptions)) {
$defaultOptions = RequestUtil::formatDefaultOptions($this->defaultOptions);
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
$this->client = ($client ?? SymfonyHttpClient::create())->withOptions($this->defaultOptions);
}
/**
* @param array<string, array|mixed> $options
*
* @throws TransportExceptionInterface
* @throws Exception
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
if (empty($options['headers'])) {
$options['headers'] = [];
}
/** @phpstan-ignore-next-line */
$options['headers']['User-Agent'] = UserAgent::create();
if ($this->isV3Request($url)) {
[, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true);
$options['headers']['Authorization'] = $this->createSignature($method, $url, $options);
} else {
// v2 全部为 xml 请求
if (! empty($options['xml'])) {
if (is_array($options['xml'])) {
$options['xml'] = Xml::build($this->attachLegacySignature($options['xml']));
}
if (! is_string($options['xml'])) {
throw new \InvalidArgumentException('The `xml` option must be a string or array.');
}
$options['body'] = $options['xml'];
unset($options['xml']);
}
if (! empty($options['body']) && is_array($options['body'])) {
$options['body'] = Xml::build($this->attachLegacySignature($options['body']));
}
/** @phpstan-ignore-next-line */
if (! isset($options['headers']['Content-Type']) && ! isset($options['headers']['content-type'])) {
$options['headers']['Content-Type'] = 'text/xml'; /** @phpstan-ignore-line */
}
}
// 合并通过 withHeader 和 withHeaders 设置的信息
if (! empty($this->prependHeaders)) {
$options['headers'] = array_merge($this->prependHeaders, $options['headers'] ?? []);
}
return new Response($this->client->request($method, $url, $options), throw: $this->throw);
}
protected function isV3Request(string $url): bool
{
$uri = new Uri($url);
foreach (self::V3_URI_PREFIXES as $prefix) {
if (str_starts_with('/'.ltrim($uri->getPath(), '/'), $prefix)) {
return true;
}
}
return false;
}
/**
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): mixed
{
if (\str_starts_with($name, 'with')) {
return $this->handleMagicWithCall($name, $arguments[0] ?? null);
}
return $this->client->$name(...$arguments);
}
/**
* @param array<string, mixed> $options
*
* @throws Exception
*/
protected function createSignature(string $method, string $url, array $options): string
{
return (new Signature($this->merchant))->createHeader($method, $url, $options);
}
/**
* @param array<string, mixed> $body
* @return array<string, mixed>
*
* @throws Exception
*/
protected function attachLegacySignature(array $body): array
{
return (new LegacySignature($this->merchant))->sign($body);
}
/**
* @throws InvalidArgumentException
* @throws InvalidConfigException
*/
public static function createMockClient(MockHttpClient $mockHttpClient): HttpClientInterface|Mock
{
$mockMerchant = new Merchant(
'mch_id',
/** @phpstan-ignore-next-line */
Mockery::mock(PrivateKey::class),
/** @phpstan-ignore-next-line */
Mockery::mock(PublicKey::class),
'mock-v3-key',
'mock-v2-key',
);
return Mockery::mock(static::class, [$mockMerchant, $mockHttpClient])
->shouldAllowMockingProtectedMethods()
->makePartial();
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay;
class Config extends \EasyWeChat\Kernel\Config
{
/**
* @var array<string>
*/
protected array $requiredKeys = [
'mch_id',
'secret_key',
'private_key',
'certificate',
];
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay\Contracts;
use EasyWeChat\Kernel\Contracts\Config;
use Symfony\Contracts\HttpClient\HttpClientInterface;
interface Application
{
public function getMerchant(): Merchant;
public function getConfig(): Config;
public function getHttpClient(): HttpClientInterface;
public function getClient(): HttpClientInterface;
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay\Contracts;
use EasyWeChat\Kernel\Support\PrivateKey;
use EasyWeChat\Kernel\Support\PublicKey;
interface Merchant
{
public function getMerchantId(): int;
public function getPrivateKey(): PrivateKey;
public function getSecretKey(): string;
public function getV2SecretKey(): ?string;
public function getCertificate(): PublicKey;
public function getPlatformCert(string $serial): ?PublicKey;
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay\Contracts;
use EasyWeChat\Kernel\Exceptions\BadResponseException;
use Psr\Http\Message\ResponseInterface;
interface ResponseValidator
{
/**
* @throws BadResponseException if the response is not successful.
*/
public function validate(ResponseInterface $response): void;
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay;
use function call_user_func_array;
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
use EasyWeChat\Kernel\Exceptions\RuntimeException;
use EasyWeChat\Kernel\Support\Str;
use EasyWeChat\Pay\Contracts\Merchant as MerchantInterface;
use function hash_hmac;
use function http_build_query;
use function is_string;
use function strtoupper;
use function urldecode;
class LegacySignature
{
public function __construct(protected MerchantInterface $merchant)
{
}
/**
* @param array<string, mixed> $params
* @return array<string, mixed>
*
* @throws \Exception
*/
public function sign(array $params): array
{
$nonce = Str::random();
$params = $attributes = array_filter(
\array_merge(
[
'nonce_str' => $nonce,
'sub_mch_id' => $params['sub_mch_id'] ?? null,
'sub_appid' => $params['sub_appid'] ?? null,
],
$params
)
);
ksort($attributes);
$attributes['key'] = $this->merchant->getV2SecretKey();
if (empty($attributes['key'])) {
throw new InvalidConfigException('Missing V2 API key.');
}
if (! empty($params['sign_type']) && 'HMAC-SHA256' === $params['sign_type']) {
$signType = fn (string $message): string => hash_hmac('sha256', $message, $attributes['key']);
} else {
$signType = 'md5';
}
$sign = call_user_func_array($signType, [urldecode(http_build_query($attributes))]);
if (! is_string($sign)) {
throw new RuntimeException('Failed to sign the request.');
}
$params['sign'] = strtoupper($sign);
return $params;
}
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay;
use function array_is_list;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
use EasyWeChat\Kernel\Support\PrivateKey;
use EasyWeChat\Kernel\Support\PublicKey;
use EasyWeChat\Pay\Contracts\Merchant as MerchantInterface;
use function intval;
use function is_string;
class Merchant implements MerchantInterface
{
/**
* @var array<string, PublicKey>
*/
protected array $platformCerts = [];
/**
* @param array<int|string, string|PublicKey> $platformCerts
*
* @throws InvalidArgumentException
* @throws InvalidConfigException
*/
public function __construct(
protected int|string $mchId,
protected PrivateKey $privateKey,
protected PublicKey $certificate,
protected string $secretKey,
protected ?string $v2SecretKey = null,
array $platformCerts = [],
) {
$this->platformCerts = $this->normalizePlatformCerts($platformCerts);
}
public function getMerchantId(): int
{
return intval($this->mchId);
}
public function getPrivateKey(): PrivateKey
{
return $this->privateKey;
}
public function getCertificate(): PublicKey
{
return $this->certificate;
}
public function getSecretKey(): string
{
return $this->secretKey;
}
public function getV2SecretKey(): ?string
{
return $this->v2SecretKey;
}
public function getPlatformCert(string $serial): ?PublicKey
{
return $this->platformCerts[$serial] ?? null;
}
/**
* @param array<array-key, string|PublicKey> $platformCerts
* @return array<string, PublicKey>
*
* @throws InvalidArgumentException
* @throws InvalidConfigException
*/
protected function normalizePlatformCerts(array $platformCerts): array
{
$certs = [];
$isList = array_is_list($platformCerts);
foreach ($platformCerts as $index => $publicKey) {
if (is_string($publicKey)) {
$publicKey = new PublicKey($publicKey);
}
if (! $publicKey instanceof PublicKey) {
throw new InvalidArgumentException('Invalid platform certficate.');
}
$certs[$isList ? $publicKey->getSerialNo() : $index] = $publicKey;
}
return $certs;
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace EasyWeChat\Pay;
use function is_array;
use function is_string;
use function json_decode;
use RuntimeException;
/**
* @property string $trade_state
*/
class Message extends \EasyWeChat\Kernel\Message
{
/**
* @return array<string, mixed>
*/
public function getOriginalAttributes(): array
{
$attributes = json_decode($this->getOriginalContents(), true);
return is_array($attributes) ? $attributes : [];
}
public function getEventType(): ?string
{
$eventType = $this->getOriginalAttributes()['event_type'];
if (! is_string($eventType)) {
throw new RuntimeException('Invalid event type.');
}
return $eventType;
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay;
use function base64_decode;
use EasyWeChat\Kernel\Exceptions\BadResponseException;
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
use EasyWeChat\Pay\Contracts\Merchant as MerchantInterface;
use const OPENSSL_ALGO_SHA256;
use Psr\Http\Message\ResponseInterface;
use function strval;
class ResponseValidator implements \EasyWeChat\Pay\Contracts\ResponseValidator
{
public const MAX_ALLOWED_CLOCK_OFFSET = 300;
public const HEADER_TIMESTAMP = 'Wechatpay-Timestamp';
public const HEADER_NONCE = 'Wechatpay-Nonce';
public const HEADER_SERIAL = 'Wechatpay-Serial';
public const HEADER_SIGNATURE = 'Wechatpay-Signature';
public function __construct(protected MerchantInterface $merchant)
{
}
/**
* @throws \EasyWeChat\Kernel\Exceptions\BadResponseException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
*/
public function validate(ResponseInterface $response): void
{
if ($response->getStatusCode() !== 200) {
throw new BadResponseException('Request Failed');
}
foreach ([self::HEADER_SIGNATURE, self::HEADER_TIMESTAMP, self::HEADER_SERIAL, self::HEADER_NONCE] as $header) {
if (! $response->hasHeader($header)) {
throw new BadResponseException("Missing Header: {$header}");
}
}
[$timestamp] = $response->getHeader(self::HEADER_TIMESTAMP);
[$nonce] = $response->getHeader(self::HEADER_NONCE);
[$serial] = $response->getHeader(self::HEADER_SERIAL);
[$signature] = $response->getHeader(self::HEADER_SIGNATURE);
$body = (string) $response->getBody();
$message = "{$timestamp}\n{$nonce}\n{$body}\n";
if (\time() - \intval($timestamp) > self::MAX_ALLOWED_CLOCK_OFFSET) {
throw new BadResponseException('Clock Offset Exceeded');
}
$publicKey = $this->merchant->getPlatformCert($serial);
if (! $publicKey) {
throw new InvalidConfigException(
"No platform certs found for serial: {$serial},
please download from wechat pay and set it in merchant config with key `certs`."
);
}
if (false === \openssl_verify(
$message,
base64_decode($signature),
strval($publicKey),
OPENSSL_ALGO_SHA256
)) {
throw new BadResponseException('Invalid Signature');
}
}
}

View File

@ -0,0 +1,149 @@
<?php
namespace EasyWeChat\Pay;
use Closure;
use EasyWeChat\Kernel\Contracts\Server as ServerInterface;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use EasyWeChat\Kernel\Exceptions\RuntimeException;
use EasyWeChat\Kernel\HttpClient\RequestUtil;
use EasyWeChat\Kernel\ServerResponse;
use EasyWeChat\Kernel\Support\AesGcm;
use EasyWeChat\Kernel\Traits\InteractWithHandlers;
use EasyWeChat\Pay\Contracts\Merchant as MerchantInterface;
use Exception;
use function is_array;
use function json_decode;
use function json_encode;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use function strval;
use Throwable;
/**
* @link https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_1.shtml
* @link https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_5.shtml
*/
class Server implements ServerInterface
{
use InteractWithHandlers;
protected ServerRequestInterface $request;
/**
* @throws Throwable
*/
public function __construct(
protected MerchantInterface $merchant,
?ServerRequestInterface $request,
) {
$this->request = $request ?? RequestUtil::createDefaultServerRequest();
}
/**
* @throws InvalidArgumentException
* @throws RuntimeException
*/
public function serve(): ResponseInterface
{
$message = $this->getRequestMessage();
try {
$defaultResponse = new Response(
200,
[],
strval(json_encode(['code' => 'SUCCESS', 'message' => '成功'], JSON_UNESCAPED_UNICODE))
);
$response = $this->handle($defaultResponse, $message);
if (! ($response instanceof ResponseInterface)) {
$response = $defaultResponse;
}
return ServerResponse::make($response);
} catch (Exception $e) {
return new Response(
500,
[],
strval(json_encode(['code' => 'ERROR', 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE))
);
}
}
/**
* @link https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_5.shtml
*
* @throws InvalidArgumentException
*/
public function handlePaid(callable $handler): static
{
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
return $message->getEventType() === 'TRANSACTION.SUCCESS' && $message->trade_state === 'SUCCESS'
? $handler($message, $next) : $next($message);
});
return $this;
}
/**
* @link https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_11.shtml
*
* @throws InvalidArgumentException
*/
public function handleRefunded(callable $handler): static
{
$this->with(function (Message $message, Closure $next) use ($handler): mixed {
return in_array($message->getEventType(), [
'REFUND.SUCCESS',
'REFUND.ABNORMAL',
'REFUND.CLOSED',
]) ? $handler($message, $next) : $next($message);
});
return $this;
}
/**
* @throws InvalidArgumentException
* @throws RuntimeException
*/
public function getRequestMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message|Message
{
$originContent = (string) ($request ?? $this->request)->getBody();
$attributes = json_decode($originContent, true);
if (! is_array($attributes)) {
throw new RuntimeException('Invalid request body.');
}
if (empty($attributes['resource']['ciphertext'])) {
throw new RuntimeException('Invalid request.');
}
$attributes = json_decode(
AesGcm::decrypt(
$attributes['resource']['ciphertext'],
$this->merchant->getSecretKey(),
$attributes['resource']['nonce'],
$attributes['resource']['associated_data'],
),
true
);
if (! is_array($attributes)) {
throw new RuntimeException('Failed to decrypt request message.');
}
return new Message($attributes, $originContent);
}
/**
* @throws InvalidArgumentException
* @throws RuntimeException
*/
public function getDecryptedMessage(?ServerRequestInterface $request = null): \EasyWeChat\Kernel\Message|Message
{
return $this->getRequestMessage($request);
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay;
use function base64_encode;
use EasyWeChat\Kernel\Support\Str;
use EasyWeChat\Pay\Contracts\Merchant as MerchantInterface;
use Exception;
use function http_build_query;
use function ltrim;
use Nyholm\Psr7\Uri;
use function openssl_sign;
use function parse_str;
use function strtoupper;
use function strval;
use function time;
class Signature
{
public function __construct(protected MerchantInterface $merchant)
{
}
/**
* @param array<string,mixed> $options
*
* @throws Exception
*/
public function createHeader(string $method, string $url, array $options): string
{
$uri = new Uri($url);
parse_str($uri->getQuery(), $query);
$uri = $uri->withQuery(http_build_query(array_merge($query, (array) $options['query'])));
$body = '';
$query = $uri->getQuery();
$timestamp = time();
$nonce = Str::random();
$path = '/'.ltrim($uri->getPath().(empty($query) ? '' : '?'.$query), '/');
if (! empty($options['body'])) {
$body = strval($options['body']);
}
$message = strtoupper($method)."\n".
$path."\n".
$timestamp."\n".
$nonce."\n".
$body."\n";
openssl_sign($message, $signature, $this->merchant->getPrivateKey()->getKey(), 'sha256WithRSAEncryption');
return sprintf(
'WECHATPAY2-SHA256-RSA2048 %s',
sprintf(
'mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"',
$this->merchant->getMerchantId(),
$nonce,
$timestamp,
$this->merchant->getCertificate()->getSerialNo(),
base64_encode($signature)
)
);
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Pay;
use EasyWeChat\Kernel\Support\Str;
use EasyWeChat\Pay\Contracts\Merchant as MerchantInterface;
use Exception;
use function sprintf;
class URLSchemeBuilder
{
public function __construct(protected MerchantInterface $merchant)
{
}
/**
* @throws Exception
*/
public function forProduct(string|int $productId, string $appId): string
{
$params = [
'appid' => $appId,
'mch_id' => $this->merchant->getMerchantId(),
'time_stamp' => time(),
'nonce_str' => Str::random(),
'product_id' => $productId,
];
$params['sign'] = (new LegacySignature($this->merchant))->sign($params);
return 'weixin://wxpay/bizpayurl?'.http_build_query($params);
}
public function forCodeUrl(string $codeUrl): string
{
return sprintf('weixin://wxpay/bizpayurl?sr=%s', $codeUrl);
}
}

View File

@ -0,0 +1,179 @@
<?php
namespace EasyWeChat\Pay;
use function base64_encode;
use function call_user_func_array;
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
use EasyWeChat\Kernel\Support\Str;
use EasyWeChat\Pay\Contracts\Merchant as MerchantInterface;
use Exception;
use function http_build_query;
use JetBrains\PhpStorm\ArrayShape;
use function openssl_sign;
use function strtoupper;
use function time;
use function urldecode;
class Utils
{
public function __construct(protected MerchantInterface $merchant)
{
}
/**
* @see https://pay.weixin.qq.com/wiki/doc/apiv3_partner/apis/chapter4_1_4.shtml
*
* @return array<string, mixed>
*
* @throws Exception
*/
#[ArrayShape([
'appId' => 'string',
'timeStamp' => 'string',
'nonceStr' => 'string',
'package' => 'string',
'signType' => 'string',
'paySign' => 'string',
])]
public function buildBridgeConfig(string $prepayId, string $appId, string $signType = 'RSA'): array
{
$params = [
'appId' => $appId,
'timeStamp' => strval(time()),
'nonceStr' => Str::random(),
'package' => "prepay_id=$prepayId",
'signType' => $signType,
];
$message = $params['appId']."\n".
$params['timeStamp']."\n".
$params['nonceStr']."\n".
$params['package']."\n";
// v2
if ($signType != 'RSA') {
$params['paySign'] = $this->createV2Signature($params);
} else {
// v3
$params['paySign'] = $this->createSignature($message);
}
return $params;
}
/**
* @see https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#58
*
* @return array<string, mixed>
*
* @throws Exception
*/
#[ArrayShape([
'appId' => 'string',
'nonceStr' => 'string',
'package' => 'string',
'signType' => 'string',
'paySign' => 'string',
'timestamp' => 'string',
])]
public function buildSdkConfig(string $prepayId, string $appId, string $signType = 'RSA'): array
{
$params = $this->buildBridgeConfig($prepayId, $appId, $signType);
$params['timestamp'] = $params['timeStamp'];
unset($params['timeStamp']);
return $params;
}
/**
* @see https://developers.weixin.qq.com/miniprogram/dev/api/payment/wx.requestPayment.html
*
* @return array<string, mixed>
*
* @throws Exception
*/
#[ArrayShape([
'appId' => 'string',
'timeStamp' => 'string',
'nonceStr' => 'string',
'package' => 'string',
'signType' => 'string',
'paySign' => 'string',
])]
public function buildMiniAppConfig(string $prepayId, string $appId, string $signType = 'RSA'): array
{
return $this->buildBridgeConfig($prepayId, $appId, $signType);
}
/**
* @see https://pay.weixin.qq.com/wiki/doc/apiv3_partner/apis/chapter4_2_4.shtml
*
* @return array<string, mixed>
*
* @throws Exception
*/
#[ArrayShape([
'appid' => 'string',
'partnerid' => 'int',
'prepayid' => 'string',
'noncestr' => 'string',
'timestamp' => 'int',
'package' => 'string',
'sign' => 'string',
])]
public function buildAppConfig(string $prepayId, string $appId): array
{
$params = [
'appid' => $appId,
'partnerid' => $this->merchant->getMerchantId(),
'prepayid' => $prepayId,
'noncestr' => Str::random(),
'timestamp' => time(),
'package' => 'Sign=WXPay',
];
$message = $params['appid']."\n".
$params['timestamp']."\n".
$params['noncestr']."\n".
$params['prepayid']."\n";
$params['sign'] = $this->createSignature($message);
return $params;
}
protected function createSignature(string $message): string
{
openssl_sign($message, $signature, $this->merchant->getPrivateKey(), 'sha256WithRSAEncryption');
return base64_encode($signature);
}
/**
* @throws InvalidConfigException
*/
public function createV2Signature(array $params): string
{
$method = 'md5';
$secretKey = $this->merchant->getV2SecretKey();
if (empty($secretKey)) {
throw new InvalidConfigException('Missing v2 secret key.');
}
if ('HMAC-SHA256' === $params['signType']) {
$method = function ($str) use ($secretKey) {
return hash_hmac('sha256', $str, $secretKey);
};
}
ksort($params);
$params['key'] = $secretKey;
// @phpstan-ignore-next-line
return strtoupper((string) call_user_func_array($method, [urldecode(http_build_query($params))]));
}
}