From 22ae96c2005237e7030baf2cd3e4db40250edc7c Mon Sep 17 00:00:00 2001 From: mouyong Date: Wed, 3 Jan 2024 23:35:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 20 + .gitattributes | 11 + .gitignore | 8 + .php_cs | 27 + LaravelReadme.md | 132 +++++ README.md | 20 + WebmanReadme.md | 83 +++ composer.json | 37 ++ examples/example_rsa_chunk.php | 43 ++ examples/example_rsa_nochunk.php | 97 ++++ phpunit.xml.dist | 21 + src/.gitkeep | 0 src/Contracts/AccessToken.php | 14 + src/Http/AbstractRequestClient.php | 143 +++++ src/Http/Request.php | 78 +++ src/Http/Response.php | 107 ++++ src/Http/StreamResponse.php | 64 +++ src/Install.php | 87 ++++ src/Traits/Clientable.php | 134 +++++ src/Traits/DateTime.php | 322 ++++++++++++ src/Traits/HasAttributes.php | 253 +++++++++ src/Traits/HasHttpRequests.php | 257 +++++++++ src/Traits/InteractsWithCache.php | 98 ++++ src/Traits/ModelResultTrait.php | 23 + src/Traits/PimpleApplicationTrait.php | 22 + src/Traits/ReplaceTrait.php | 47 ++ src/Traits/ResponseCastable.php | 72 +++ src/Traits/ResponseTrait.php | 296 +++++++++++ src/Traits/SplitTableTrait.php | 154 ++++++ src/Traits/WebmanResponseTrait.php | 276 ++++++++++ src/Utilities/ModelNoUtility.php | 110 ++++ src/Utilities/ModelUtility.php | 157 ++++++ src/Utilities/Transform.php | 201 +++++++ src/Utils/AES.php | 72 +++ src/Utils/Arr.php | 70 +++ src/Utils/CommandTool.php | 77 +++ src/Utils/Distance.php | 30 ++ src/Utils/Excel.php | 719 ++++++++++++++++++++++++++ src/Utils/File.php | 179 +++++++ src/Utils/HigherOrderTapProxy.php | 38 ++ src/Utils/Json.php | 65 +++ src/Utils/LaravelCache.php | 100 ++++ src/Utils/Process.php | 48 ++ src/Utils/RSA.php | 174 +++++++ src/Utils/State.php | 31 ++ src/Utils/Str.php | 218 ++++++++ src/Utils/Tree.php | 46 ++ src/Utils/Url.php | 38 ++ src/Utils/Uuid.php | 31 ++ src/Utils/XML.php | 158 ++++++ src/Utils/Zip.php | 197 +++++++ src/helpers.php | 200 +++++++ src/scripts/install.php | 6 + src/scripts/uninstall.php | 6 + src/stubs/WebmanBaseController.php | 10 + tests/.gitkeep | 0 56 files changed, 5927 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .php_cs create mode 100644 LaravelReadme.md create mode 100644 README.md create mode 100644 WebmanReadme.md create mode 100644 composer.json create mode 100644 examples/example_rsa_chunk.php create mode 100644 examples/example_rsa_nochunk.php create mode 100644 phpunit.xml.dist create mode 100644 src/.gitkeep create mode 100644 src/Contracts/AccessToken.php create mode 100644 src/Http/AbstractRequestClient.php create mode 100644 src/Http/Request.php create mode 100644 src/Http/Response.php create mode 100644 src/Http/StreamResponse.php create mode 100644 src/Install.php create mode 100644 src/Traits/Clientable.php create mode 100644 src/Traits/DateTime.php create mode 100644 src/Traits/HasAttributes.php create mode 100644 src/Traits/HasHttpRequests.php create mode 100644 src/Traits/InteractsWithCache.php create mode 100644 src/Traits/ModelResultTrait.php create mode 100644 src/Traits/PimpleApplicationTrait.php create mode 100644 src/Traits/ReplaceTrait.php create mode 100644 src/Traits/ResponseCastable.php create mode 100644 src/Traits/ResponseTrait.php create mode 100644 src/Traits/SplitTableTrait.php create mode 100644 src/Traits/WebmanResponseTrait.php create mode 100644 src/Utilities/ModelNoUtility.php create mode 100644 src/Utilities/ModelUtility.php create mode 100644 src/Utilities/Transform.php create mode 100644 src/Utils/AES.php create mode 100644 src/Utils/Arr.php create mode 100644 src/Utils/CommandTool.php create mode 100644 src/Utils/Distance.php create mode 100644 src/Utils/Excel.php create mode 100644 src/Utils/File.php create mode 100644 src/Utils/HigherOrderTapProxy.php create mode 100644 src/Utils/Json.php create mode 100644 src/Utils/LaravelCache.php create mode 100644 src/Utils/Process.php create mode 100644 src/Utils/RSA.php create mode 100644 src/Utils/State.php create mode 100644 src/Utils/Str.php create mode 100644 src/Utils/Tree.php create mode 100644 src/Utils/Url.php create mode 100644 src/Utils/Uuid.php create mode 100644 src/Utils/XML.php create mode 100644 src/Utils/Zip.php create mode 100644 src/helpers.php create mode 100644 src/scripts/install.php create mode 100644 src/scripts/uninstall.php create mode 100644 src/stubs/WebmanBaseController.php create mode 100644 tests/.gitkeep diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..df55cd7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = false + +[*.{vue,js,scss}] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9af3157 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto + +/tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.scrutinizer.yml export-ignore +.travis.yml export-ignore +phpunit.php export-ignore +phpunit.xml.dist export-ignore +phpunit.xml export-ignore +.php_cs export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..497c4e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea +*.DS_Store +/vendor +/coverage +sftp-config.json +composer.lock +.subsplit +.php_cs.cache diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..acc8ac6 --- /dev/null +++ b/.php_cs @@ -0,0 +1,27 @@ + + +This source file is subject to the MIT license that is bundled. +EOF; + +return PhpCsFixer\Config::create() + ->setRiskyAllowed(true) + ->setRules(array( + '@Symfony' => true, + 'header_comment' => array('header' => $header), + 'array_syntax' => array('syntax' => 'short'), + 'ordered_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'php_unit_construct' => true, + 'php_unit_strict' => true, + )) + ->setFinder( + PhpCsFixer\Finder::create() + ->exclude('vendor') + ->in(__DIR__) + ) +; \ No newline at end of file diff --git a/LaravelReadme.md b/LaravelReadme.md new file mode 100644 index 0000000..f396398 --- /dev/null +++ b/LaravelReadme.md @@ -0,0 +1,132 @@ +

support

+ +## 安装 + +```shell +$ composer require zhenmu/support -vvv +``` + +## 使用 + +1. 通过 `php artisan make:controller` 控制器生成后,继承同目录下的 `Controller` 基类. +2. 编写接口时可通过 `$this->success($data = [], $err_code = 200, $messsage = 'success');` 返回正确数据给接口. +3. 编写接口时可通过 `$this->fail($messsage = '', $err_code = 400);` 返回错误信息给接口. +4. 在 `app/Exceptions/Handler.php` 的 `register` 函数中, 注册 `ResponseTrait` 的 `renderableHandle`, 示例见下方错误处理. + + +### 控制器 + +```php +, \Psr\Log\LogLevel::*> + */ + protected $levels = [ + // + ]; + + /** + * A list of the exception types that are not reported. + * + * @var array> + */ + protected $dontReport = [ + // + ]; + + /** + * A list of the inputs that are never flashed for validation exceptions. + * + * @var array + */ + protected $dontFlash = [ + 'current_password', + 'password', + 'password_confirmation', + ]; + + /** + * Register the exception handling callbacks for the application. + * + * @return void + */ + public function register() + { + $this->reportable(function (Throwable $e) { + // + }); + + $this->renderable($this->renderableHandle()); // here + } +} + +``` + +## 控制器调用 + +``` +validate(\request(), [ + 'name' => 'required|string', + 'age' => 'nullable|integer', + ]); + + // your business logic + $error = false; + if ($error) { // here business logic error. + throw new \RuntimeException('error message'); + } + + return $this->success([ // here response success + 'key1' => 'value1', + 'key2' => 'value2', + ]); + } +} + +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f5959a --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +

support

+ +## 简介 + +php 基础支持,详细内容见 `src/Utils`、`src/Traits` 目录。 + +项目自动拆分,如需跟踪源码更新情况,请前往:https://github.com/plugins-world/packages 查看 PhpSupport 目录 + + +## 安装 + +```shell +$ composer require zhenmu/support -vvv +``` + + +## 使用 + +- [laravel 使用示例](/LaravelReadme.md) +- [webman 使用示例](/WebmanReadme.md) diff --git a/WebmanReadme.md b/WebmanReadme.md new file mode 100644 index 0000000..347f199 --- /dev/null +++ b/WebmanReadme.md @@ -0,0 +1,83 @@ +

support

+ +## 安装 + +```shell +$ composer require zhenmu/support -vvv +``` + +## 使用 + +1. 通过 `./webman make:controller` 控制器生成后,继承同目录下的 `WebmanBaseController` 基类。 +2. 编写接口时可通过 `$this->success($data = [], $err_code = 200, $messsage = 'success');` 返回正确数据给接口。 +3. 编写接口时可通过 `$this->fail($messsage = '', $err_code = 400);` 返回错误信息给接口。 +4. 在 `support/exception/Handler.php` 的 `render` 函数中,调用 `WebmanResponseTrait` 的 `$this->renderableHandle($request, $exception);` 示例见下方错误处理。 + + +### 控制器 + +```php +renderableHandle($request, $exception); // 这里进行调用,做了一些错误捕捉 + } +} +``` + +## 控制器调用 + +``` +validate(\request(), [ + 'name' => 'required|string', + 'age' => 'nullable|integer', + ]); + + // your logic + $error = false; + if ($error) { + throw new \RuntimeException('error message'); + } + + return $this->success([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); + } +} +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2121a4c --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "zhenmu/support", + "description": "Php 代码扩展包, 一些常用的 php 代码封装", + "keywords": ["support", "mouyong", "zhenmu", "laravel", "webman", "aes", "php", "composer", "xml", "uuid", "url", "state", "file", "tool", "utils", "util"], + "license": "MIT", + "authors": [ + { + "name": "mouyong", + "email": "my24251325@gmail.com", + "homepage": "https://github.com/mouyong", + "role": "Creator & Developer" + } + ], + "support": { + "issues": "https://github.com/plugins-world/php-support/issues", + "source": "https://github.com/plugins-world/php-support", + "homepage": "https://laravel-workerman.iwnweb.com/d/22-php-composer" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "ext-fileinfo": "*", + "ext-simplexml": "*", + "league/fractal": "^0.20.1", + "ramsey/uuid": "*", + "symfony/process": "*", + "nelexa/zip": "^4.0" + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "ZhenMu\\Support\\": "src" + } + } +} diff --git a/examples/example_rsa_chunk.php b/examples/example_rsa_chunk.php new file mode 100644 index 0000000..0379d51 --- /dev/null +++ b/examples/example_rsa_chunk.php @@ -0,0 +1,43 @@ + KEY_SIZE, + "private_key_type" => OPENSSL_KEYTYPE_RSA, +)); + +// 将私钥转换为字符串 +openssl_pkey_export($res, $privateKey); + +// 从私钥中得到公钥 +$publicKey = openssl_pkey_get_details($res)["key"]; + +// 需要加密的数据 +$plaintext = "Hello, World!"; + +// RSA 加密,将需要加密的数据按照最大加密长度分块 +$chunkSize = KEY_SIZE / 8 - 11; +$output = ""; +while ($plaintext) { + $chunk = substr($plaintext, 0, $chunkSize); + $plaintext = substr($plaintext, $chunkSize); + openssl_public_encrypt($chunk, $encrypted, $publicKey); + $output .= $encrypted; +} + +// 对 RSA 加密后的数据进行解密 +$plaintext = ""; +while ($output) { + $chunk = substr($output, 0, KEY_SIZE / 8); + $output = substr($output, KEY_SIZE / 8); + openssl_private_decrypt($chunk, $decrypted, $privateKey); + $plaintext .= $decrypted; +} + +// 输出解密后的数据 +echo $plaintext; + +?> diff --git a/examples/example_rsa_nochunk.php b/examples/example_rsa_nochunk.php new file mode 100644 index 0000000..38bf0e5 --- /dev/null +++ b/examples/example_rsa_nochunk.php @@ -0,0 +1,97 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, +)); +openssl_pkey_export($privateKey, $pkeyout); + +$publicKey = openssl_pkey_get_details($privateKey)['key']; + +var_dump($privateKey); +var_dump($pkeyout); +var_dump($publicKey); +die; + +echo '原始内容: '.$data."\n"; + +$pubkey = "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1zPb8FxkuL9hxM843X58 +4CrHIQr7YtTnbZwhSwbtCs907J3OnNBZbH6GvOQoqQ97JuhCVNSyzYc0CPsWzmc0 +3jlpiQiUmvifwYvBu1pZq7FLekEpCPud2fcfzbqcjqYEo7Z9iIt4zqU8y1AMQF+Z +K4HNtJnbNyqPfsTrKIUw9kj0l0HHFstkq6qGhW0+iqsbPDsjY4JDRKP0tiaaXyme +Oy1rr2tzyCmONjkOzlIyw3BobcjjCrpBpjQKXEdWscWJjhD9NobNQ15Oiqa0JzkT +KHj6BlMdgKY8HVmaeRS0u/tbAP3ph29rz72RlmHxe+XpPKOSqfegFTZcpPevhoPM +kwIDAQAB +-----END PUBLIC KEY-----"; +$prikey = "-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1zPb8FxkuL9hxM843X584CrHIQr7YtTnbZwhSwbtCs907J3O +nNBZbH6GvOQoqQ97JuhCVNSyzYc0CPsWzmc03jlpiQiUmvifwYvBu1pZq7FLekEp +CPud2fcfzbqcjqYEo7Z9iIt4zqU8y1AMQF+ZK4HNtJnbNyqPfsTrKIUw9kj0l0HH +Fstkq6qGhW0+iqsbPDsjY4JDRKP0tiaaXymeOy1rr2tzyCmONjkOzlIyw3Bobcjj +CrpBpjQKXEdWscWJjhD9NobNQ15Oiqa0JzkTKHj6BlMdgKY8HVmaeRS0u/tbAP3p +h29rz72RlmHxe+XpPKOSqfegFTZcpPevhoPMkwIDAQABAoIBAGNYL1ogbObUgp/G +QawObjtVxCM+3JndSxDQmJX4Fol9B68LkovVqtJo/m5IrXSODv4BDk32+qvilGTo +9LhH8KH9wvhdm6yGxcklaUPCC880w3Emj3j0HwS2Dlp8oTVA8rdY0U6thBFxOkVp +KJ63AxCQlZOfyxEGdsPAyAYmplmqr3Q3jlTk74nGJUA9n0ZkX7t010TNXVcS4FTA +3I+1FXYHX4QBw33X2Vj84Ur2dCSG7Lf2GcDXWLDOHyAWAyHEFN7hsGBFph7QTu0J +1dJ4xMqCAn3MyXogIjE8+2dhp/uh4DbzDPnftlmCkbgp+ssTGp893nwmLD+AtZME +2KBNLaECgYEA7fyXVKClr1t39O4E4AbX/LHPCG7fmowNCv1OyPUTN5JTuEx9ZPKW +r5WvBH5LgkBXDqByrd6GpsPA2Yk7SJpaMRlDjorMuXPNS/xYM/v/Wehm5C2Ovztn +sFwLLeOZ3K9gJWGuB5MQrP1+raw4YeOqvhGDeWAwZC872Lh/9uSBSmsCgYEA533J +NYzqWrMRDT9QaOfg8dSgYB/eiGTIeDyqhRogpY3rEWatE48pPaC9iSeZKz/EztYy +mnN9kGy1BfSqCpttqJ06Si8v/wZlAuqzraOuXaZu89DAYWTCSrCk0AXKl6DBOH4H +q9NlxEb1XV4bY1BtFAgBVZpcoVcAckILr3Ne4HkCgYEAprx92hDjhER1euj4CW1C +Dg0VnDbx+nl8+eIXPLxXxmuCtHECuaMs57/bay6BALTLSbgoIKDzfgtQJhj7rBZY +cmXc6xVb8eKsRzx5H5LCiN9Glz9D779TGkCipHf96JwGpKoXH79tw4WnJ06uAgdc +LOZgUr2NqeNd7qz1Gqll3BkCgYBdvd88EztXzUmrbqc2RCggZfUn19/6pa1Um2SG +D+WGhSja3BRcZk3SCgSWxPVOwT0GcVD+oKQJVywbJE+zietnK3xOTDuIb2N6QebO ++wiCHgKyMyekiPPw4QVsw9udeVilcsvSdgGw8Pctfw1iM1BomzFHJAI8x4mDu2EW +BIc4KQKBgFfWOJjLHGGJIZxq4Ib1KwXW3sdZvfR1032o8LxtAhuttSoNVmLrHDMD +RMTjGreucIWtI55/daiTykFa7cfaAVMo/4T/hIETMzgh6YifFKuKMKnwTLyXNGF9 +yRFAwtADJ47TdwRzMMp50jPoBBLSwBxoj9rHw5RV4237F1O4f7Iv +-----END RSA PRIVATE KEY-----"; + +function String2Hex($string){ + $hex = ''; + for ($i=0; $i < strlen($string); $i++){ + $ord = ord($string[$i]); + $hexCode = dechex($ord); + $hex .= substr('0'.$hexCode, -2); + } + return $hex; +} + +function signWkCode($data, $private_key) { + // $pri = formatPriKey($private_key); + $pri = $private_key; + $res = openssl_private_encrypt($data, $encrypted, $pri); + if(!$res) return false; + return String2Hex($encrypted); +} + +function formatPriKey($priKey) { + $fKey = "-----BEGIN PRIVATE KEY-----\n"; + $len = strlen($priKey); + for($i = 0; $i < $len; ) { + $fKey = $fKey . substr($priKey, $i, 64) . "\n"; + $i += 64; + } + $fKey .= "-----END PRIVATE KEY-----"; + return $fKey; +} + +// $data = '原始数据'; +// $private_key = '私钥字符串'; +$private_key = $prikey; +$wxcode = signWkCode($data, $private_key); +var_dump($wxcode); + +$privateKey = openssl_pkey_new(array( + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, +)); + +$publicKey = openssl_pkey_get_details($privateKey)['key']; +var_dump($privateKey); +var_dump($publicKey); diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e47284c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + ./tests/ + + + + + src/ + + + diff --git a/src/.gitkeep b/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Contracts/AccessToken.php b/src/Contracts/AccessToken.php new file mode 100644 index 0000000..3f53e5f --- /dev/null +++ b/src/Contracts/AccessToken.php @@ -0,0 +1,14 @@ +app = $app; + + static::$accessToken = $accessToken; + } + + public function setAccessToken(AccessToken $accessToken) + { + static::$accessToken = $accessToken; + + return $this; + } + + public function request($url, $method = 'GET', $options = []) + { + if (empty($this->middlewares)) { + $this->registerHttpMiddlewares(); + } + + $response = $this->performRequest($this->getRequestUrl($url), $method, $options); + + $response = $this->detectAndCastResponseToType($response, $this->getResponseType()); + + if ($this->getResponseType()) { + return $response->toArray(); + } + + return $response; + } + + public function getResponseType() + { + return null; + } + + protected function registerHttpMiddlewares() + { + // retry + $this->pushMiddleware($this->retryMiddleware(), 'retry'); + // access token + $this->pushMiddleware($this->accessTokenMiddleware(), 'access_token'); + // log + if (in_array('logger', $this->app->keys())) { + $this->pushMiddleware($this->logMiddleware(), 'log'); + } + } + + + /** + * Attache access token to request query. + * + * @return \Closure + */ + protected function accessTokenMiddleware() + { + return function (callable $handler) { + return function (RequestInterface $request, array $options) use ($handler) { + if (static::$accessToken instanceof AccessToken) { + $request = static::$accessToken->applyToRequest($request, $options); + } + + return $handler($request, $options); + }; + }; + } + + /** + * Log the request. + * + * @return \Closure + */ + protected function logMiddleware() + { + $formatter = new MessageFormatter($this->app['config']['http.log_template'] ?? MessageFormatter::DEBUG); + + return Middleware::log($this->app['logger'], $formatter, LogLevel::DEBUG); + } + + /** + * Return retry middleware. + * + * @return \Closure + */ + protected function retryMiddleware() + { + return Middleware::retry( + function ( + $retries, + RequestInterface $request, + ResponseInterface $response = null + ) { + // Limit the number of retries to 2 + if ($retries < ($this->app->config['http']['max_retries'] ?? 1) && $response && $body = $response->getBody()) { + // Retry on server errors + $response = json_decode($body, true); + + if ($this->isRetryResponse($response)) { + if (static::$accessToken instanceof AccessToken) { + static::$accessToken->refresh(); + } + + if (in_array('logger', $this->app->keys())) { + $this->app['logger']->debug('Retrying with refreshed access token.'); + } + + return true; + } + } + + return false; + }, + function () { + return abs($this->app->config['http.retry_delay'] ?? 500); + } + ); + } + + public function isRetryResponse(?array $response) + { + // DEMO: return !empty($response['code']) && in_array(abs($response['code']), [], true); + return false; + } +} \ No newline at end of file diff --git a/src/Http/Request.php b/src/Http/Request.php new file mode 100644 index 0000000..1548c6b --- /dev/null +++ b/src/Http/Request.php @@ -0,0 +1,78 @@ +baseUri, '/'); + } + + protected function getRequestUrl($url = '') + { + return sprintf('%s/%s', $this->getBaseUri(), ltrim($url, '/')); + } + + public function httpGet(string $url, array $data, array $options = []) + { + return $this->request($url, 'GET', [ + 'query' => $data, + ] + $options); + } + + public function httpPost(string $url, array $data, array $options = []) + { + return $this->request($url, 'POST', [ + 'form_params' => $data, + ] + $options); + } + + public function httpPostJson(string $url, array $data = [], array $query = [], array $options = []) + { + return $this->request($url, 'POST', ['query' => $query, 'json' => $data] + $options); + } + + public function httpUpload(string $url, array $files = [], array $form = [], array $query = [], array $options = []) + { + $multipart = []; + $headers = []; + + if (isset($form['filename'])) { + $headers = [ + 'Content-Disposition' => 'form-data; name="media"; filename="'.$form['filename'].'"' + ]; + } + + foreach ($files as $name => $path) { + $multipart[] = [ + 'name' => $name, + 'contents' => fopen($path, 'r'), + 'headers' => $headers + ]; + } + + foreach ($form as $name => $contents) { + $multipart[] = compact('name', 'contents'); + } + + return $this->request( + $url, + 'POST', + ['query' => $query, 'multipart' => $multipart, 'connect_timeout' => 30, 'timeout' => 30, 'read_timeout' => 30] + $options + ); + } + + public function httpDelete(string $url, array $data, array $options = []) + { + return $this->request($url, 'DELETE', [ + 'form_params' => $data, + ] + $options); + } +} \ No newline at end of file diff --git a/src/Http/Response.php b/src/Http/Response.php new file mode 100644 index 0000000..0f2aca7 --- /dev/null +++ b/src/Http/Response.php @@ -0,0 +1,107 @@ +getBody()->rewind(); + $contents = $this->getBody()->getContents(); + $this->getBody()->rewind(); + + return $contents; + } + + /** + * @param \Psr\Http\Message\ResponseInterface $response + * + * @return Response + */ + public static function buildFromPsrResponse(ResponseInterface $response) + { + return new static( + $response->getStatusCode(), + $response->getHeaders(), + $response->getBody(), + $response->getProtocolVersion(), + $response->getReasonPhrase() + ); + } + + /** + * Build to json. + * + * @return string + */ + public function toJson() + { + return json_encode($this->toArray()); + } + + /** + * Build to array. + * + * @return array + */ + public function toArray() + { + $content = $this->removeControlCharacters($this->getBodyContents()); + + if (false !== stripos($this->getHeaderLine('Content-Type'), 'xml') || 0 === stripos($content, 'toArray()); + } + + /** + * @return object + */ + public function toObject() + { + return json_decode($this->toJson()); + } + + /** + * @return bool|string + */ + public function __toString() + { + return $this->getBodyContents(); + } + + /** + * @param string $content + * + * @return string + */ + protected function removeControlCharacters(string $content) + { + return \preg_replace('/[\x00-\x1F\x80-\x9F]/u', '', $content); + } +} \ No newline at end of file diff --git a/src/Http/StreamResponse.php b/src/Http/StreamResponse.php new file mode 100644 index 0000000..b9c2909 --- /dev/null +++ b/src/Http/StreamResponse.php @@ -0,0 +1,64 @@ +getBody()->rewind(); + + $directory = rtrim($directory, '/'); + + if (!is_dir($directory)) { + mkdir($directory, 0755, true); // @codeCoverageIgnore + } + + if (!is_writable($directory)) { + throw new \InvalidArgumentException(sprintf("'%s' is not writable.", $directory)); + } + + $contents = $this->getBody()->getContents(); + + if (empty($contents) || '{' === $contents[0]) { + throw new \RuntimeException('Invalid media response content.'); + } + + if (empty($filename)) { + if (preg_match('/filename="(?.*?)"/', $this->getHeaderLine('Content-Disposition'), $match)) { + $filename = $match['filename']; + } else { + $filename = md5($contents); + } + } + + if ($appendSuffix && empty(pathinfo($filename, PATHINFO_EXTENSION))) { + $filename .= File::getStreamExt($contents); + } + + file_put_contents($directory.'/'.$filename, $contents); + + return $filename; + } + + /** + * @param string $directory + * @param string $filename + * @param bool $appendSuffix + * + * @return bool|int + */ + public function saveAs(string $directory, string $filename, bool $appendSuffix = true) + { + return $this->save($directory, $filename, $appendSuffix); + } +} \ No newline at end of file diff --git a/src/Install.php b/src/Install.php new file mode 100644 index 0000000..1ea1e0e --- /dev/null +++ b/src/Install.php @@ -0,0 +1,87 @@ + 'app/controller/WebmanBaseController.php' + ); + + /** + * Install + * @return void + */ + public static function install() + { + static::installByRelation(); + } + + /** + * Uninstall + * @return void + */ + public static function uninstall() + { + self::uninstallByRelation(); + } + + /** + * installByRelation + * @return void + */ + public static function installByRelation() + { + foreach (static::$pathRelation as $source => $dest) { + if ($pos = strrpos($dest, '/')) { + $parent_dir = base_path().'/'.substr($dest, 0, $pos); + if (!is_dir($parent_dir)) { + mkdir($parent_dir, 0777, true); + } + } + //symlink(__DIR__ . "/$source", base_path()."/$dest"); + copy_dir(__DIR__ . "/$source", base_path()."/$dest"); + echo "Create $dest +"; + } + } + + /** + * uninstallByRelation + * @return void + */ + public static function uninstallByRelation() + { + foreach (static::$pathRelation as $source => $dest) { + $path = base_path()."/$dest"; + if (static::isBaseController($path)) { + continue; + } + + if (!is_dir($path) && !is_file($path)) { + continue; + } + echo "Remove $dest +"; + if (is_file($path) || is_link($path)) { + unlink($path); + continue; + } + remove_dir($path); + } + } + + public static function isBaseController($path) + { + if (strpos($path, 'BaseController') !== false) { + return true; + } + + return false; + } +} diff --git a/src/Traits/Clientable.php b/src/Traits/Clientable.php new file mode 100644 index 0000000..3f1069f --- /dev/null +++ b/src/Traits/Clientable.php @@ -0,0 +1,134 @@ + $this->getBaseUri(), + 'timeout' => 5, // Request 5s timeout + 'http_errors' => false, + 'headers' => [ + 'Accept' => 'application/json', + ], + ]; + } + + public function getHttpClient() + { + return new Client($this->getOptions()); + } + + public function castResponse($response) + { + $content = $response->getBody()->getContents(); + + $data = json_decode($content, true) ?? []; + + return $data; + } + + public function paginate() + { + if (! data_get($this->result, 'data.paginate', false)) { + return null; + } + + $paginate = new \Illuminate\Pagination\LengthAwarePaginator( + items: data_get($this->result, 'data'), + total: data_get($this->result, 'meta.total'), + perPage: data_get($this->result, 'meta.page_size'), + currentPage: data_get($this->result, 'meta.current_page'), + ); + + $paginate + ->withPath('/'.\request()->path()) + ->withQueryString(); + + return $paginate; + } + + public function unwrapRequests(array $requests) + { + $results = $this->unwrap($requests); + + if (method_exists($this, 'caseUnwrapRequests')) { + $results = $this->caseUnwrapRequests($results); + } + + return $results; + } + + public function __call(string $method, array $args) + { + $result = $this->forwardCall($method, $args); + + if (method_exists($this, 'caseForwardCallResult')) { + $result = $this->caseForwardCallResult($result); + } + + return $result; + } + + public function forwardCall($method, $args) + { + // Asynchronous requests + if (method_exists(Utils::class, $method)) { + $results = call_user_func_array([Utils::class, $method], $args); + + if (!is_array($results)) { + return $results; + } + + $data = []; + foreach ($results as $key => $promise) { + $data[$key] = $this->castResponse($promise); + } + + $this->data = $data; + + return $this->data; + } + // Synchronization Request + else if (method_exists($this->getHttpClient(), $method)) { + $this->response = $this->getHttpClient()->$method(...$args); + + // return Promise response + if ($this->response instanceof Promise) { + return $this->response; + } + + // Response results processing + if ($this->response instanceof Response) { + $this->data = $this->castResponse($this->response); + } + } else { + throw new \RuntimeException(sprintf("unknown method %s::%s", get_class($this), $method)); + } + + // api data + return $this->data; + } +} diff --git a/src/Traits/DateTime.php b/src/Traits/DateTime.php new file mode 100644 index 0000000..d7abd61 --- /dev/null +++ b/src/Traits/DateTime.php @@ -0,0 +1,322 @@ + time()) { + $remainTime = $endTime - $now; + } + + return $remainTime; + } + + /** + * 剩余时间秒数转换为人性化时间 + * + * @param null|string|integer $endTimeOrRemainSeconds + * @return null|string + */ + public static function secondsToTime(mixed $endTimeOrRemainSeconds) + { + if (is_null($endTimeOrRemainSeconds)) { + return null;; + } + + if (is_string($endTimeOrRemainSeconds)) { + $seconds = static::remainTime($endTimeOrRemainSeconds); + } else if (is_int($endTimeOrRemainSeconds)) { + $seconds = $endTimeOrRemainSeconds; + } + + $years = floor($seconds / 31536000); + $months = floor(($seconds - ($years * 31536000)) / 2592000); + $days = floor(($seconds - ($years * 31536000) - ($months * 2592000)) / 86400); + $hours = floor(($seconds - ($years * 31536000) - ($months * 2592000) - ($days * 86400)) / 3600); + $minutes = floor(($seconds - ($years * 31536000) - ($months * 2592000) - ($days * 86400) - ($hours * 3600)) / 60); + $seconds = ($seconds - ($years * 31536000) - ($months * 2592000) - ($days * 86400) - ($hours * 3600) - ($minutes * 60)); + + $timeString = ""; + + if($years > 0) { + $timeString .= $years . "年"; + } + + if($months > 0) { + $timeString .= $months . "个月"; + } + + if($days > 0) { + $timeString .= $days . "天"; + } + + if($hours > 0) { + $timeString .= $hours . "小时"; + } + + if($minutes > 0) { + $timeString .= $minutes . "分钟"; + } + + if($seconds > 0) { + $timeString .= $seconds . "秒"; + } + + return $timeString; + } +} diff --git a/src/Traits/HasAttributes.php b/src/Traits/HasAttributes.php new file mode 100644 index 0000000..f641e4d --- /dev/null +++ b/src/Traits/HasAttributes.php @@ -0,0 +1,253 @@ +attributes = $attributes; + + return $this; + } + + /** + * Get Attributes. + * + * @param array $attributes + * + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Set attribute. + * + * @param string $attribute + * @param string $value + * + * @return $this + */ + public function setAttribute($attribute, $value) + { + Arr::set($this->attributes, $attribute, $value); + + return $this; + } + + /** + * Get attribute. + * + * @param string $attribute + * @param mixed $default + * + * @return mixed + */ + public function getAttribute($attribute, $default = null) + { + return Arr::get($this->attributes, $attribute, $default); + } + + /** + * @param string $attribute + * + * @return bool + */ + public function isRequired($attribute) + { + return in_array($attribute, $this->getRequired(), true); + } + + /** + * @return array|mixed + */ + public function getRequired() + { + return property_exists($this, 'required') ? $this->required : []; + } + + /** + * Set attribute. + * + * @param string $attribute + * @param mixed $value + * + * @return $this + */ + public function with($attribute, $value) + { + $this->snakeable && $attribute = Str::snake($attribute); + + $this->setAttribute($attribute, $value); + + return $this; + } + + /** + * Override parent set() method. + * + * @param string $attribute + * @param mixed $value + * + * @return $this + */ + public function set($attribute, $value) + { + $this->setAttribute($attribute, $value); + + return $this; + } + + /** + * Override parent get() method. + * + * @param string $attribute + * @param mixed $default + * + * @return mixed + */ + public function get($attribute, $default = null) + { + return $this->getAttribute($attribute, $default); + } + + /** + * @param string $key + * + * @return bool + */ + public function has(string $key) + { + return Arr::has($this->attributes, $key); + } + + /** + * @param array $attributes + * + * @return $this + */ + public function merge(array $attributes) + { + $this->attributes = array_merge($this->attributes, $attributes); + + return $this; + } + + /** + * @param array|string $keys + * + * @return array + */ + public function only($keys) + { + return Arr::only($this->attributes, $keys); + } + + /** + * Return all items. + * + * @return array + * + * @throws \InvalidArgumentException + */ + public function all() + { + $this->checkRequiredAttributes(); + + return $this->attributes; + } + + /** + * Magic call. + * + * @param string $method + * @param array $args + * + * @return $this + */ + public function __call($method, $args) + { + if (0 === stripos($method, 'with')) { + return $this->with(substr($method, 4), array_shift($args)); + } + + throw new \BadMethodCallException(sprintf('Method "%s" does not exists.', $method)); + } + + /** + * Magic get. + * + * @param string $property + * + * @return mixed + */ + public function __get($property) + { + return $this->get($property); + } + + /** + * Magic set. + * + * @param string $property + * @param mixed $value + * + * @return $this + */ + public function __set($property, $value) + { + return $this->with($property, $value); + } + + /** + * Whether or not an data exists by key. + * + * @param string $key + * + * @return bool + */ + public function __isset($key) + { + return isset($this->attributes[$key]); + } + + /** + * Check required attributes. + * + * @throws \InvalidArgumentException + */ + protected function checkRequiredAttributes() + { + foreach ($this->getRequired() as $attribute) { + if (is_null($this->get($attribute))) { + throw new \InvalidArgumentException(sprintf('"%s" cannot be empty.', $attribute)); + } + } + } +} diff --git a/src/Traits/HasHttpRequests.php b/src/Traits/HasHttpRequests.php new file mode 100644 index 0000000..228617a --- /dev/null +++ b/src/Traits/HasHttpRequests.php @@ -0,0 +1,257 @@ + 3, + 'http_errors' => false, + 'curl' => [ + \CURLOPT_IPRESOLVE => \CURL_IPRESOLVE_V4, + ], + ]; + + /** + * Set guzzle default settings. + * + * @param array $defaults + */ + public static function setDefaultOptions($defaults = []) + { + self::$defaults = $defaults; + } + + /** + * Return current guzzle default settings. + * + * @return array + */ + public static function getDefaultOptions(): array + { + return self::$defaults; + } + + /** + * Set GuzzleHttp\Client. + * + * @param \GuzzleHttp\ClientInterface $httpClient + * + * @return $this + */ + public function setHttpClient(ClientInterface $httpClient) + { + $this->httpClient = $httpClient; + + return $this; + } + + /** + * Return GuzzleHttp\ClientInterface instance. + * + * @return ClientInterface + */ + public function getHttpClient(): ClientInterface + { + if (!($this->httpClient instanceof ClientInterface)) { + if (property_exists($this, 'app') && $this->app instanceof Container && in_array('http_client', $this->app->keys())) { + $this->httpClient = $this->app['http_client']; + } else { + $this->httpClient = new Client(['handler' => HandlerStack::create($this->getGuzzleHandler())]); + } + } + + return $this->httpClient; + } + + /** + * Add a middleware. + * + * @param callable $middleware + * @param string $name + * + * @return $this + */ + public function pushMiddleware(callable $middleware, string $name = null) + { + if (!is_null($name)) { + $this->middlewares[$name] = $middleware; + } else { + array_push($this->middlewares, $middleware); + } + + return $this; + } + + /** + * Return all middlewares. + * + * @return array + */ + public function getMiddlewares(): array + { + return $this->middlewares; + } + + public function getResponseType() + { + return null; + } + + public function getRawResponseType() + { + return null; + } + + public function request($url, $method = 'GET', $options = []) + { + $response = $this->performRequest($url, $method, $options); + + $response = $this->detectAndCastResponseToType($response, $this->getResponseType()); + + return $response; + } + + /** + * Make a request. + * + * @param string $url + * @param string $method + * @param array $options + * + * @return \Psr\Http\Message\ResponseInterface + * + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function performRequest($url, $method = 'GET', $options = []): ResponseInterface + { + $method = strtoupper($method); + + $options = array_merge(static::$defaults, $options, ['handler' => $this->getHandlerStack()]); + + $options = $this->fixJsonIssue($options); + + if (property_exists($this, 'baseUri') && !is_null($this->baseUri)) { + $options['base_uri'] = rtrim($this->baseUri, '/') . '/'; + } + + $response = $this->getHttpClient()->request($method, ltrim($url, '/'), $options); + $response->getBody()->rewind(); + + return $response; + } + + public function rawRequest($url, $method = 'GET', $options = []) + { + $response = $this->performRawRequest($url, $method, $options); + + $response = $this->detectAndCastResponseToType($response, $this->getRawResponseType()); + + return $response; + } + + public function performRawRequest($url, $method = 'GET', $options = []) + { + $response = $this->getHttpClient()->request($method, $url, $options); + $response->getBody()->rewind(); + + return $response; + } + + /** + * @param \GuzzleHttp\HandlerStack $handlerStack + * + * @return $this + */ + public function setHandlerStack(HandlerStack $handlerStack) + { + $this->handlerStack = $handlerStack; + + return $this; + } + + /** + * Build a handler stack. + * + * @return \GuzzleHttp\HandlerStack + */ + public function getHandlerStack(): HandlerStack + { + if ($this->handlerStack) { + return $this->handlerStack; + } + + $this->handlerStack = HandlerStack::create($this->getGuzzleHandler()); + + foreach ($this->middlewares as $name => $middleware) { + $this->handlerStack->push($middleware, $name); + } + + return $this->handlerStack; + } + + /** + * @param array $options + * + * @return array + */ + protected function fixJsonIssue(array $options): array + { + if (isset($options['json']) && is_array($options['json'])) { + $options['headers'] = array_merge($options['headers'] ?? [], ['Content-Type' => 'application/json; charset=utf-8']); + + if (empty($options['json'])) { + $options['body'] = Utils::jsonEncode($options['json'], \JSON_FORCE_OBJECT); + } else { + $options['body'] = Utils::jsonEncode($options['json'], \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES); + } + + unset($options['json']); + } + + return $options; + } + + /** + * Get guzzle handler. + * + * @return callable + */ + protected function getGuzzleHandler() + { + if (property_exists($this, 'app') && isset($this->app['guzzle_handler'])) { + return is_string($handler = $this->app->raw('guzzle_handler')) + ? new $handler() + : $handler; + } + + return Utils::chooseHandler(); + } +} \ No newline at end of file diff --git a/src/Traits/InteractsWithCache.php b/src/Traits/InteractsWithCache.php new file mode 100644 index 0000000..92f7ddd --- /dev/null +++ b/src/Traits/InteractsWithCache.php @@ -0,0 +1,98 @@ +cache) { + return $this->cache; + } + + if (property_exists($this, 'app') && $this->app instanceof \Pimple\Container && isset($this->app['cache'])) { + $this->setCache($this->app['cache']); + + // Fix PHPStan error + assert($this->cache instanceof \Psr\SimpleCache\CacheInterface); + + return $this->cache; + } + + if (property_exists($this, 'app') && $this->app instanceof \Illuminate\Container\Container && $this->app->bound('cache')) { + $this->setCache($this->app['cache']->driver()); + + // Fix PHPStan error + assert($this->cache instanceof \Psr\SimpleCache\CacheInterface); + + return $this->cache; + } + + return $this->cache = $this->createDefaultCache(); + } + + /** + * Set cache instance. + * + * @param \Psr\SimpleCache\CacheInterface|\Psr\Cache\CacheItemPoolInterface $cache + * + * @return $this + * + * @throws \InvalidArgumentException + */ + public function setCache($cache) + { + if (empty(\array_intersect([SimpleCacheInterface::class, CacheItemPoolInterface::class], \class_implements($cache)))) { + throw new \InvalidArgumentException(\sprintf('The cache instance must implements %s or %s interface.', SimpleCacheInterface::class, CacheItemPoolInterface::class)); + } + + if ($cache instanceof CacheItemPoolInterface) { + if (!$this->isSymfony43OrHigher()) { + throw new \InvalidArgumentException(sprintf('The cache instance must implements %s', SimpleCacheInterface::class)); + } + $cache = new Psr16Cache($cache); + } + + $this->cache = $cache; + + return $this; + } + + /** + * @return SimpleCacheInterface|FilesystemCache + */ + protected function createDefaultCache() + { + if ($this->isSymfony43OrHigher()) { + return new Psr16Cache(new FilesystemAdapter('zhenmu', 1500)); + } + + return new FilesystemCache(); + } + + /** + * @return bool + */ + protected function isSymfony43OrHigher(): bool + { + return \class_exists('Symfony\Component\Cache\Psr16Cache'); + } +} diff --git a/src/Traits/ModelResultTrait.php b/src/Traits/ModelResultTrait.php new file mode 100644 index 0000000..314bd95 --- /dev/null +++ b/src/Traits/ModelResultTrait.php @@ -0,0 +1,23 @@ +get($columns); + } + + $perPage = request('per_page', $perPage) <= 100 ? request('per_page', $perPage) : 100; + + return $this->paginate($perPage, $columns); + + }); + \Illuminate\Database\Query\Builder::macro('result', $paginate); + } +} diff --git a/src/Traits/PimpleApplicationTrait.php b/src/Traits/PimpleApplicationTrait.php new file mode 100644 index 0000000..8715350 --- /dev/null +++ b/src/Traits/PimpleApplicationTrait.php @@ -0,0 +1,22 @@ +providers as $provider) { + $this->register(new $provider); + } + } + + public function __get($name) + { + if (in_array($name, $this->keys())) { + return $this[$name]; + } + + throw new \InvalidArgumentException("Class $name doesnt exists"); + } +} \ No newline at end of file diff --git a/src/Traits/ReplaceTrait.php b/src/Traits/ReplaceTrait.php new file mode 100644 index 0000000..c51d15e --- /dev/null +++ b/src/Traits/ReplaceTrait.php @@ -0,0 +1,47 @@ +lower()->toString(); + $method = sprintf("get%sReplacement", Str::studly($currentReplacementLower)); + + if (method_exists($this, $method)) { + $replaces[$currentReplacement] = $this->$method(); + } else { + \info($currentReplacement . " does match any replace content"); + // keep origin content + $replaces[$currentReplacement] = $key; + } + } + + return $replaces; + } + + public function getReplacedContent(string $content, array $keys = []) + { + if (!$keys) { + $keys = $this->getReplaceKeys($content); + } + + $replaces = $this->getReplacesByKeys($keys); + + return str_replace($keys, $replaces, $content); + } +} diff --git a/src/Traits/ResponseCastable.php b/src/Traits/ResponseCastable.php new file mode 100644 index 0000000..0622e28 --- /dev/null +++ b/src/Traits/ResponseCastable.php @@ -0,0 +1,72 @@ +getBody()->rewind(); + + switch ($type ?? 'array') { + case 'collection': + return $response->toCollection(); + case 'array': + return $response->toArray(); + case 'object': + return $response->toObject(); + case 'raw': + return $response; + default: + if (!is_subclass_of($type, Arrayable::class)) { + throw new \InvalidArgumentException(sprintf('Config key "response_type" classname must be an instanceof %s', Arrayable::class)); + } + + return new $type($response); + } + } + + /** + * @param mixed $response + * @param string|null $type + * + * @return array|Collection|mixed|object|Response + */ + protected function detectAndCastResponseToType($response, $type = null) + { + switch (true) { + case $response instanceof ResponseInterface: + $response = Response::buildFromPsrResponse($response); + + break; + case $response instanceof Arrayable: + $response = new Response(200, [], json_encode($response->toArray())); + + break; + case ($response instanceof Collection) || is_array($response) || is_object($response): + $response = new Response(200, [], json_encode($response)); + + break; + case is_scalar($response): + $response = new Response(200, [], (string) $response); + + break; + default: + throw new \InvalidArgumentException(sprintf('Unsupported response type "%s"', gettype($response))); + } + + return $this->castResponseToType($response, $type); + } +} \ No newline at end of file diff --git a/src/Traits/ResponseTrait.php b/src/Traits/ResponseTrait.php new file mode 100644 index 0000000..6a84618 --- /dev/null +++ b/src/Traits/ResponseTrait.php @@ -0,0 +1,296 @@ +perPage(); + $total = $paginator->total(); + } else { + if ($items instanceof Collection) { + $total = $items->count(); + } else { + $total = count($items); + } + } + + $pageSize = $pageSize ?? 15; + + $paginate = new \Illuminate\Pagination\LengthAwarePaginator( + items: $items, + total: $total, + perPage: $pageSize, + currentPage: \request('page'), + ); + + $paginate + ->withPath('/' . \request()->path()) + ->withQueryString(); + + return $this->paginate($paginate, null, $meta); + } + + public function paginate($data, ?callable $callable = null, array $meta = []) + { + // 处理集合数据 + if ($data instanceof \Illuminate\Database\Eloquent\Collection) { + return $this->success(array_map(function ($item) use ($callable) { + if ($callable) { + return $callable($item) ?? $item; + } + + return $item; + }, $data->all())); + } + + // 处理非分页数据 + if (!$data instanceof \Illuminate\Pagination\LengthAwarePaginator) { + return $this->success($data); + } + + // 处理分页数据 + $paginate = $data; + return $this->success([ + 'meta' => array_merge([ + 'total' => $paginate->total(), + 'current_page' => $paginate->currentPage(), + 'page_size' => $paginate->perPage(), + 'last_page' => $paginate->lastPage(), + ], $meta), + 'data' => array_map(function ($item) use ($callable) { + if ($callable) { + return $callable($item) ?? $item; + } + + return $item; + }, $paginate?->items()), + ]); + } + + public function success($data = [], $err_msg = 'success', $err_code = 200, $headers = [], $options = []) + { + static::setResponseCodeKey($options['responseCodeKey'] ?? 1); + static::setResponseSuccessCode($options['responseSuccessCode'] ?? 200); + + if (is_string($data)) { + $err_code = is_string($err_msg) ? $err_code : $err_msg; + $err_msg = $data; + $data = []; + } + + // 处理 meta 数据 + $meta = []; + if (isset($data['data']) && isset($data['meta'])) { + extract($data); + } + + $err_msg = static::string2utf8($err_msg); + + if ($err_code !== static::$responseSuccessCode) { + $err_code = static::$responseSuccessCode; + } + + $data = $data ?: null; + + $res = match (static::$responseCodeKey) { + default => [ + 'err_code' => $err_code, + 'err_msg' => $err_msg, + 'data' => $data, + ], + 1 => [ + 'err_code' => $err_code, + 'err_msg' => $err_msg, + 'data' => $data, + ], + 2 => [ + 'code' => $err_code, + 'message' => $err_msg, + 'data' => $data, + ], + 3 => [ + 'code' => $err_code, + 'msg' => $err_msg, + 'data' => $data, + ], + 4 => [ + 'errcode' => $err_code, + 'errmsg' => $err_msg, + 'data' => $data, + ], + }; + + $res = $res + array_filter(compact('meta')); + + return \response( + \json_encode($res, \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT), + Response::HTTP_OK, + array_merge([ + 'Content-Type' => 'application/json', + ], $headers) + ); + } + + public function fail($err_msg = 'unknown error', $err_code = 400, $data = [], $headers = [], $options = []) + { + static::setResponseCodeKey($options['responseCodeKey'] ?? 1); + + $res = match (static::$responseCodeKey) { + default => [ + 'err_code' => $err_code, + 'err_msg' => $err_msg, + 'data' => $data, + ], + 1 => [ + 'err_code' => $err_code, + 'err_msg' => $err_msg, + 'data' => $data, + ], + 2 => [ + 'code' => $err_code, + 'message' => $err_msg, + 'data' => $data, + ], + 3 => [ + 'code' => $err_code, + 'msg' => $err_msg, + 'data' => $data, + ], + 4 => [ + 'errcode' => $err_code, + 'errmsg' => $err_msg, + 'data' => $data, + ], + }; + + if (!\request()->wantsJson()) { + $err_msg = \json_encode($res, \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT); + if (!array_key_exists($err_code, Response::$statusTexts)) { + $err_code = 500; + } + + return \response( + $err_msg, + $err_code, + array_merge([ + 'Content-Type' => 'application/json', + ], $headers) + ); + } + + // return $this->success($data, $err_msg ?: 'unknown error', $err_code ?: 500, $headers, $options); + return \response( + \json_encode($res, \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT), + Response::HTTP_OK, + array_merge([ + 'Content-Type' => 'application/json', + ], $headers) + ); + } + + public function reportableHandle() + { + return function (\Throwable $e) { + // + }; + } + + public function renderableHandle() + { + return function (\Throwable $e) { + if (!\request()->wantsJson()) { + return; + } + + if ($e instanceof \Illuminate\Auth\AuthenticationException) { + return $this->fail('未登录', $e->getCode() ?: config('laravel-init-template.auth.unauthorize_code', 401)); + } + + if ($e instanceof \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException) { + if (\request()->wantsJson()) { + return $this->fail('未授权', $e->getStatusCode()); + } + + return \response()->noContent($e->getStatusCode(), $e->getHeaders()); + } + + if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpException) { + $message = '请求失败'; + if ($e->getStatusCode() == 403) { + $message = '拒绝访问'; + } + + return $this->fail($message, $e->getStatusCode()); + } + + if ($e instanceof \Illuminate\Validation\ValidationException) { + return $this->fail($e->validator->errors()->first(), Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if ($e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) { + return $this->fail('404 Data Not Found.', Response::HTTP_NOT_FOUND); + } + + if ($e instanceof \Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { + return $this->fail('404 Url Not Found.', Response::HTTP_NOT_FOUND); + } + + $code = $e->getCode() ?: Response::HTTP_INTERNAL_SERVER_ERROR; + if (method_exists($e, 'getStatusCode')) { + $code = $e->getStatusCode(); + } + + \info('error', [ + 'class' => get_class($e), + 'code' => $code, + 'message' => $e->getMessage(), + 'file_line' => sprintf('%s:%s', $e->getFile(), $e->getLine()), + ]); + + return $this->fail($e->getMessage(), $code); + }; + } +} diff --git a/src/Traits/SplitTableTrait.php b/src/Traits/SplitTableTrait.php new file mode 100644 index 0000000..4777cc8 --- /dev/null +++ b/src/Traits/SplitTableTrait.php @@ -0,0 +1,154 @@ +init($attributes, $suffix); + +// parent::__construct($attributes); +// } +// } + + +/** + * 分表查询示例 + */ +// $wechatBill = new WechatBill(); +// $wechatBill->setSuffix(202303); +// return $wechatBill->newQuery()->get(); + + +/** + * 分表写入 + */ +// return (new WechatBill([], 202303))->newInstance()->create([]); + + +trait SplitTableTrait +{ + /** + * 是否分表,默认false,即不分表 + * @var bool + */ + protected $isSplitTable = true; + + /** + * 最终生成表 + * @var + */ + protected $endTable; + + /** + * 后缀参数 + * @var null + */ + protected $suffix = null; + + /** + * 初始化分表处理 + * @param array $attributes + * @param $suffix + * @return void + */ + public function init(array $attributes = [], $suffix = null) + { + $this->endTable = $this->table; + + // isSplitTable参数为true时进行分表,否则不分表 + if ($this->isSplitTable) { + // 初始化后缀,未传则默认年月分表 + $this->suffix = $suffix ?: Carbon::now()->format('Ym'); + } + //初始化分表表名并创建 + $this->setSuffix($suffix); + } + + /** + * 设置表后缀, 如果设置分表后缀,可在service层调用生成自定义后缀表名, + * 但每次操作表之前都需要调用该方法以保证数据表的准确性 + * @param $suffix + * @return void + */ + public function setSuffix($suffix = null) + { + // isSplitTable参数为true时进行分表,否则不分表 + if ($this->isSplitTable) { + //初始化后缀,未传则默认年月分表 + $this->suffix = $suffix ?: Carbon::now()->format('Ym'); + } + + if ($this->suffix !== null) { + // 最终表替换模型中声明的表作为分表使用的表 + $this->table = $this->endTable.'_'.$this->suffix; + } + + // 调用时,创建分表,格式为 table_{$suffix} + // 未传自定义后缀情况下,,默认按年月分表格式为:orders_202205 + // 无论使用时是否自定义分表名,都会创建默认的分表,除非关闭该调用 + $this->createTable(); + } + + /** + * 提供一个静态方法设置表后缀 + * @param $suffix + * @return mixed + */ + public static function suffix($suffix = null) + { + $instance = new static; + $instance->setSuffix($suffix); + + return $instance->newQuery(); + } + + /** + * 创建新的"table_{$suffix}"的模型实例并返回 + * @param array $attributes + * @return object $model + */ + public function newInstance($attributes = [], $exists = false): object + { + $model = parent::newInstance($attributes, $exists); + $model->setSuffix($this->suffix); + + return $model; + } + + /** + * 创建分表,没有则创建,有则不处理 + * @return void + */ + protected function createTable() + { + $connectName = $this->getConnectionName(); + + // 初始化分表,,按年月分表格式为:orders_202205 + if (!Schema::connection($connectName)->hasTable($this->table)) { + Schema::connection($connectName)->create($this->table, function (Blueprint $table) { + $this->migrationUp($table); + }); + } + } + + abstract public function migrationUp(Blueprint $table): void; +} diff --git a/src/Traits/WebmanResponseTrait.php b/src/Traits/WebmanResponseTrait.php new file mode 100644 index 0000000..e42baaa --- /dev/null +++ b/src/Traits/WebmanResponseTrait.php @@ -0,0 +1,276 @@ +withPath('/' . \request()->path()) + ->withQueryString(); + + return $this->paginate($paginate); + } + + public function paginate($data, ?callable $callable = null) + { + // 处理集合数据 + if ($data instanceof \Illuminate\Database\Eloquent\Collection) { + return $this->success(array_map(function ($item) use ($callable) { + if ($callable) { + return $callable($item) ?? $item; + } + + return $item; + }, $data->all())); + } + + // 处理非分页数据 + if (!$data instanceof \Illuminate\Pagination\LengthAwarePaginator) { + return $this->success($data); + } + + // 处理分页数据 + $paginate = $data; + return $this->success([ + 'meta' => [ + 'total' => $paginate->total(), + 'current_page' => $paginate->currentPage(), + 'page_size' => $paginate->perPage(), + 'last_page' => $paginate->lastPage(), + ], + 'data' => array_map(function ($item) use ($callable) { + if ($callable) { + return $callable($item) ?? $item; + } + + return $item; + }, $paginate?->items()), + ]); + } + + public function success($data = [], $err_msg = 'success', $err_code = 200, $headers = [], $options = []) + { + static::setResponseCodeKey($options['responseCodeKey'] ?? 1); + static::setResponseSuccessCode($options['responseSuccessCode'] ?? 200); + + if (is_string($data)) { + $err_code = is_string($err_msg) ? $err_code : $err_msg; + $err_msg = $data; + $data = []; + } + + // 处理 meta 数据 + $meta = []; + if (isset($data['data']) && isset($data['meta'])) { + extract($data); + } + + $err_msg = static::string2utf8($err_msg); + + if ($err_code !== static::$responseSuccessCode) { + $err_code = static::$responseSuccessCode; + } + + $data = $data ?: null; + + $res = match (static::$responseCodeKey) { + default => [ + 'err_code' => $err_code, + 'err_msg' => $err_msg, + 'data' => $data, + ], + 1 => [ + 'err_code' => $err_code, + 'err_msg' => $err_msg, + 'data' => $data, + ], + 2 => [ + 'code' => $err_code, + 'message' => $err_msg, + 'data' => $data, + ], + 3 => [ + 'code' => $err_code, + 'msg' => $err_msg, + 'data' => $data, + ], + 4 => [ + 'errcode' => $err_code, + 'errmsg' => $err_msg, + 'data' => $data, + ], + }; + + $res = $res + array_filter(compact('meta')); + + return \response( + \json_encode($res, \JSON_UNESCAPED_SLASHES|\JSON_PRETTY_PRINT), + Response::HTTP_OK, + array_merge([ + 'Content-Type' => 'application/json', + ], $headers) + ); + } + + public function fail($err_msg = 'unknown error', $err_code = 400, $data = [], $headers = [], $options = []) + { + static::setResponseCodeKey($options['responseCodeKey'] ?? 1); + + $res = match (static::$responseCodeKey) { + default => [ + 'err_code' => $err_code, + 'err_msg' => $err_msg, + 'data' => $data, + ], + 1 => [ + 'err_code' => $err_code, + 'err_msg' => $err_msg, + 'data' => $data, + ], + 2 => [ + 'code' => $err_code, + 'message' => $err_msg, + 'data' => $data, + ], + 3 => [ + 'code' => $err_code, + 'msg' => $err_msg, + 'data' => $data, + ], + 4 => [ + 'errcode' => $err_code, + 'errmsg' => $err_msg, + 'data' => $data, + ], + }; + + if (!\request()->expectsJson()) { + $err_msg = \json_encode($res, \JSON_UNESCAPED_SLASHES|\JSON_PRETTY_PRINT); + if (!array_key_exists($err_code, Response::$statusTexts)) { + $err_code = 500; + } + + return \response( + $err_msg, + $err_code, + array_merge([ + 'Content-Type' => 'application/json', + ], $headers) + ); + } + + // return $this->success($data, $err_msg ?: 'unknown error', $err_code ?: 500, $headers, $options); + return \response( + \json_encode($res, \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT), + Response::HTTP_OK, + array_merge([ + 'Content-Type' => 'application/json', + ], $headers) + ); + } + + public function reportableHandle(\Throwable $e) + { + // + } + + public function renderableHandle() + { + return function (\Throwable $e) { + if (! \request()->expectsJson()) { + return; + } + + if ($e instanceof \Illuminate\Auth\AuthenticationException) { + return $this->fail('未登录', $e->getCode() ?: config('laravel-init-template.auth.unauthorize_code', 401)); + } + + if ($e instanceof \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException) { + if (\request()->expectsJson()) { + return $this->fail('未授权', $e->getStatusCode()); + } + + return \response()->noContent($e->getStatusCode(), $e->getHeaders()); + } + + if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpException) { + $message = '请求失败'; + if ($e->getStatusCode() == 403) { + $message = '拒绝访问'; + } + + return $this->fail($message, $e->getStatusCode()); + } + + if ($e instanceof \Illuminate\Validation\ValidationException) { + return $this->fail($e->validator->errors()->first(), Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if ($e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) { + return $this->fail('404 Data Not Found.', Response::HTTP_NOT_FOUND); + } + + if ($e instanceof \Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { + return $this->fail('404 Url Not Found.', Response::HTTP_NOT_FOUND); + } + + $code = $e->getCode() ?: Response::HTTP_INTERNAL_SERVER_ERROR; + if (method_exists($e, 'getStatusCode')) { + $code = $e->getStatusCode(); + } + + // \info('error', [ + // 'class' => get_class($e), + // 'code' => $code, + // 'message' => $e->getMessage(), + // 'file_line' => sprintf('%s:%s', $e->getFile(), $e->getLine()), + // ]); + + return $this->fail($e->getMessage(), $code); + }; + } +} diff --git a/src/Utilities/ModelNoUtility.php b/src/Utilities/ModelNoUtility.php new file mode 100644 index 0000000..60efb83 --- /dev/null +++ b/src/Utilities/ModelNoUtility.php @@ -0,0 +1,110 @@ +count(); + if ($exists) { + $nextIndex++; + $customerNumber = static::setCurrentIndexByIndex($model, $nextIndex, $field, $tmp, $prefix, $indexLength, $dateFormat); + } + + unset($tmp); + + return $customerNumber; + } + + public static function getNextIndex($model, string $field, $prefix = null, $orderByField = 'created_at', $indexLength = 4) + { + $currentIndex = ModelNoUtility::getCurrentIndex(...func_get_args()); + + return $currentIndex + 1; + } + + public static function getCurrentIndex($model, string $field, $prefix = null, $orderByField = 'created_at', $indexLength = 4) + { + if (!is_string($model)) { + $model = get_class($model); + } + + if (!defined("{$model}::CUSTOMER_NUMBER_PREFIX")) { + throw new \RuntimeException("{$model}::CUSTOMER_NUMBER_PREFIX doesn't exist."); + } + + $orderByField = $orderByField ?? 'created_at'; + + $date = now(); + $prefix = $model::CUSTOMER_NUMBER_PREFIX ?? $prefix; + $batch_number = $model::whereDate($orderByField, $date) + ->orderByDesc($orderByField) + ->count() ?? 0; + + $index = 0; + if ($batch_number) { + $index = $batch_number; + } + + return $index; + } + + public static function customerNumber(?string $prefix = null, int $currentIndex = 0, $indexLength = 4, string $dateFormat = 'ymd') + { + $nextIndex = $currentIndex + 1; + $nextIndexString = str_pad($nextIndex, $indexLength, '0', STR_PAD_LEFT); + + $date = date($dateFormat); + + $prefix = $prefix ?? ''; + + return "{$prefix}{$date}{$nextIndexString}"; + } +} \ No newline at end of file diff --git a/src/Utilities/ModelUtility.php b/src/Utilities/ModelUtility.php new file mode 100644 index 0000000..f8f3716 --- /dev/null +++ b/src/Utilities/ModelUtility.php @@ -0,0 +1,157 @@ +hasMethod($methodName)) { + return true; + } + $refClass = $refClass->getParentClass(); + } + + return false; + } + + /** + $relations = []; + // 定义查询条件与执行方式 + // => Model::where('where_field', 'where_field_value') + // => ->whereIn('where_field_in', ['where_field_value_1', 'where_field_value_2']) + // => ->get() + $relations['relationNameInUse']['wheres'][] = [Model::class, 'where_field', 'where_field_value']; + $relations['relationNameInUse']['wheres']['whereIn'] = [Model::class, 'where_field_in', ['where_field_value_1', 'where_field_value_2']]; + $relations['relationNameInUse']['page'] = 1; + $relations['relationNameInUse']['perPage'] = 20; + $relations['relationNameInUse']['performMethod'] = 'get'; + + $relations['tests']['wheres'][] = [Test::class, 'test_number', $params['test_numbers']]; + $relations['tests']['performMethod'] = 'get'; + + $relations['experiment_records']['wheres'][] = [CsimExperiment::class, 'experiment_batch_number', $csim_experiment['experiment_batch_number']]; + $relations['experiment_records']['performMethod'] = 'first'; + $relations['experiment_records']['params'] = [['id', 'experiment_batch_number']]; + + $relations['user']['wheres'][] = [User::class, 'experiment_batch_number', $csim_experiment['experiment_batch_number']]; + $relations['user']['performMethod'] = 'value'; + $relations['user']['params'] = ['username']; + + // 查询关联数据 + $relationData = RelationUtility::getRelations($relations); + dd($relationData); + */ + public static function getRelationData(array $relationsWhereList = []) + { + $relations = []; + + foreach ($relationsWhereList as $relationName => $relationsWheres) { + $relations[$relationName] = static::getData(...$relationsWheres); + } + + $filterRelations = array_filter($relations); + return $filterRelations; + } + + protected static function getData(array $wheres = [], $performMethod = null, $page = null, $perPage = null, bool $toArray = true, array $params = [['*']]) + { + if (empty($wheres)) { + return null; + } + + $model = null; + $query = null; + + foreach ($wheres as $whereKey => $where) { + if (!$model) { + $model = $where[0]; + } + unset($where[0]); + + if (!$model) { + continue; + } + + $whereMethod = 'where'; + if (is_string($whereKey)) { + $whereMethod = $whereKey; + } + + $query = $model::{$whereMethod}(...$where); + } + + if ($page && $perPage) { + $query->skip($perPage * $page - $perPage)->limit($perPage); + } + + $result = $query->{$performMethod}(...$params); + if ($toArray && is_object($result) && method_exists($result, 'toArray')) { + return $result?->toArray(); + } + + return $result; + } + + /** + // 关联关系 experiment => experiment_records + $data['experiment_records'] = ModelUtility::formatRecords($relations, 'experiment_records', function ($item, $relations) { + return CsimExperimentDataFormat::getExperimentInfo($item, $relations); + }); + */ + public static function formatRecords($relations, $relationName, $callable) + { + if (empty($relations[$relationName])) { + return []; + } + + $data = []; + foreach ($relations[$relationName] as $item) { + $data[] = $callable($item, $relations); + } + + return $data; + } + + /** + $data['analysis_records'] = ModelUtility::formatRecordsByWhere($relations, 'analysis_records', 'analysis_batch_number', $params['analysis_batch_number'], function ($data, $relations) { + return ModelUtility::formatDataItem($data, $relations, function ($item, $relations) { + return AnalysisDataFormat::getAnalysisInfo($item, $relations); + }); + }) + */ + public static function formatDataItem($data, $relations, $callable) + { + $result = []; + foreach($data as $item) { + $result[] = $callable($item, $relations); + } + return $result; + } + + /** + // 关联关系 test_experiments => tests + $data['test_number'] = $params['test_number']; + $data['test'] = static::formatRecordsByWhere($relations, 'tests', 'test_number', $params['test_number'], function ($item, $relations) { + return Test::getTestInfo($item, $relations); + }); + */ + public static function formatRecordsByWhere($relations, $relationName, $whereField, $whereValue, $callable, $performMethod = 'where', $toArray = true) + { + if (empty($relations[$relationName])) { + return []; + } + + $relationCollection = is_array($relations[$relationName]) ? collect($relations[$relationName]) : $relations[$relationName]; + $result = $relationCollection->{$performMethod}($whereField, $whereValue); + + if ($toArray && is_object($result) && method_exists($result, 'toArray')) { + $result = $result?->toArray(); + } + + return $callable($result, $relations); + } +} diff --git a/src/Utilities/Transform.php b/src/Utilities/Transform.php new file mode 100644 index 0000000..9d9077d --- /dev/null +++ b/src/Utilities/Transform.php @@ -0,0 +1,201 @@ +data = $data; + + $this->fractal = $fractal ?? new Manager(); + + if (\request()->has('include')) { + $this->fractal->parseIncludes(\request()->query('include')); + } + } + + /** + * Make a new class instance. + * + * @param Manager $fractal + */ + public static function make() + { + $instance = new static(...func_get_args()); + + return $instance->withData($instance->getData()); + } + + public function getData() + { + return $this->data; + } + + /** + * Make a JSON response with the transformed items. + * + * @param $data + * @param TransformerAbstract|null|boolean $transformer + * + * @return JsonResponse + * @throws \Exception + */ + public function withData($data, $transformer = null, $meta = []) + { + if (!$data) { + return null; + } + + if ($data instanceof Model) { + $result = $this->item($data, $transformer); + } else { + $result = $this->collection($data, $transformer); + if ($meta) { + $result['meta'] = isset($result['meta']) ? array_merge($result['meta'], $meta) : $meta; + } + } + + return $result; + } + + /** + * Transform a collection of data. + * + * @param $data + * @param TransformerAbstract|null|boolean $transformer + * @return array + * @throws \Exception + */ + public function collection($data, $transformer = null) + { + $transformer = ($transformer instanceof TransformerAbstract) ? $transformer : $this->fetchDefaultTransformer($data); + + if ($data instanceof LengthAwarePaginator) { + $collection = new FractalCollection($data->getCollection(), $transformer); + $collection->setPaginator(new IlluminatePaginatorAdapter($data)); + } else { + $collection = new FractalCollection($data, $transformer); + } + + $result = $this->fractal->createData($collection)->toArray(); + + return $result['data'] ?? $result; + } + + /** + * Transform a single data. + * + * @param $data + * @param TransformerAbstract|null $transformer + * @return array + * @throws \Exception + */ + public function item($data, $transformer = null) + { + $transformer = ($transformer instanceof TransformerAbstract) ? $transformer : $this->fetchDefaultTransformer($data); + + $result = $this->fractal->createData( + new FractalItem($data, $transformer) + )->toArray(); + + return $result['data'] ?? $result; + } + + /** + * Tries to fetch a default transformer for the given data. + * + * @param $data + * + * @return EmptyTransformer + * @throws \Exception + */ + protected function fetchDefaultTransformer($data) + { + if(($data instanceof LengthAwarePaginator || $data instanceof Collection) && $data->isEmpty()) { + $emptyTransformer = new class extends TransformerAbstract + { + public function transform() + { + return []; + } + }; + + return $emptyTransformer; + } + + $className = $this->getClassName($data); + + if ($this->hasDefaultTransformer($className)) { + $transformer = config('api.transformers.' . $className); + } else { + $classBasename = class_basename($className); + + if(!class_exists($transformer = "App\\Transformers\\{$classBasename}Transformer")) { + throw new \Exception("No transformer for {$className}"); + } + } + + return new $transformer; + } + + /** + * Check if the class has a default transformer. + * + * @param $className + * + * @return bool + */ + protected function hasDefaultTransformer($className) + { + return ! is_null(config('api.transformers.' . $className)); + } + + /** + * Get the class name from the given object. + * + * @param $object + * + * @return string + * @throws \Exception + */ + protected function getClassName($object) + { + if ($object instanceof LengthAwarePaginator || $object instanceof Collection) { + return get_class(Arr::first($object)); + } + + if (!is_string($object) && !is_object($object)) { + throw new \Exception("No transformer of \"{$object}\" found."); + } + + return get_class($object); + } +} diff --git a/src/Utils/AES.php b/src/Utils/AES.php new file mode 100644 index 0000000..52ec480 --- /dev/null +++ b/src/Utils/AES.php @@ -0,0 +1,72 @@ +partition(function ($item) use ($key, $values) { + return in_array($item[$key], $values); + }); + + $data = $findData->values()->toArray(); + + if (count($data) == 1) { + return $data[0]; + } + + return $data; + } + + // remove key value + public static function forget(?array $arrays = [], string $key, string|array $values) + { + if (empty($arrays)) { + return false; + } + + $values = (array) $values; + + [$findData, $otherData] = collect($arrays)->partition(function ($item) use ($key, $values) { + return in_array($item[$key], $values); + }); + + $arrays = $otherData->values()->toArray(); + + return true; + } + + // pull key value + public static function pull(?array &$arrays = [], string $key, string|array $values) + { + if (empty($arrays)) { + return []; + } + + $values = (array) $values; + + [$findData, $otherData] = collect($arrays)->partition(function ($item) use ($key, $values) { + return in_array($item[$key], $values); + }); + + $arrays = $otherData->values()->toArray(); + + $data = $findData->values()->toArray(); + + if (count($data) == 1) { + return $data[0]; + } + + return $data; + } +} diff --git a/src/Utils/CommandTool.php b/src/Utils/CommandTool.php new file mode 100644 index 0000000..22361dc --- /dev/null +++ b/src/Utils/CommandTool.php @@ -0,0 +1,77 @@ +executableFinder = new \Symfony\Component\Process\ExecutableFinder(); + + if (function_exists('base_path')) { + $this->defaultExtraDirs = array_merge($this->defaultExtraDirs, [base_path()]); + } + } + + public static function make() + { + return new static(); + } + + public static function getRealpath($path) + { + return realpath($path); + } + + public static function formatCommand($command) + { + if (is_string($command)) { + $command = explode(' ', $command); + } + + return $command; + } + + public function createProcess(array $command, string $cwd = null, array $env = null, $input = null, ?float $timeout = 60) + { + return tap(new \Symfony\Component\Process\Process(...func_get_args())); + } + + public static function findBinary(string $name, array $extraDirs = []) + { + $instance = static::make(); + + $extraDirs = array_merge($instance->defaultExtraDirs, $extraDirs); + + $extraDirs = array_map(fn ($dir) => rtrim($dir, '/'), $extraDirs); + + return $instance->executableFinder->find($name, null, $extraDirs); + } + + public static function getPhpProcess(array $argument) + { + $instance = new static(); + + $php = $instance->findBinary('php'); + + return $instance->createProcess([$php, ...$argument]); + } + + public static function getComposerProcess(array $argument) + { + $instance = new static(); + + $php = $instance->findBinary('php'); + + $composer = $instance->findBinary('composer'); + + return $instance->createProcess([$php, $composer, ...$argument]); + } +} diff --git a/src/Utils/Distance.php b/src/Utils/Distance.php new file mode 100644 index 0000000..543656d --- /dev/null +++ b/src/Utils/Distance.php @@ -0,0 +1,30 @@ + null, + ]); + } + + /** + * 获取 Worksheet + * + * @param Event $event + * @return Worksheet + */ + public static function getSheet(Event $event): Worksheet + { + $sheet = $event->sheet->getDelegate(); + + return $sheet; + } + + public static function getSheetMaxRowAndColumnInfo(Event $event): array + { + $sheet = Excel::getSheet($event); + + ['row' => $maxRow, 'column' => $maxColName] = $sheet->getHighestRowAndColumn(); + + // maxRow, maxCol 从 1 开始 + $maxCol = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($maxColName); + // A=65 + $maxColumnLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($maxCol); + + return [ + 'maxRow' => $maxRow, + 'maxCol' => $maxCol, + 'maxColumnLetter' => $maxColumnLetter, + ]; + } + + public static function getSheetCellNameByRowAndColumn(int $col, int $row) + { + $columnLetter = Excel::getSheetColumnLetter($col); + + $cell = "{$columnLetter}{$row}"; + + return [ + 'columnLetter' => $columnLetter, + 'cell' => $cell, + 'row' => $row, + ]; + } + + public static function getSheetColumnLetter(int $col) + { + $columnLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col); + + return $columnLetter; + } + + public static function handleAllCell(Event $event, callable $callable) + { + $sheet = Excel::getSheet($event); + + $sheetInfo = Excel::getSheetMaxRowAndColumnInfo($event); + + foreach (range(0, $sheetInfo['maxRow']) as $row) { + foreach (range(1, $sheetInfo['maxCol']) as $col) { + $cellInfo = Excel::getSheetCellNameByRowAndColumn($col, $row); + + $callable($event, $sheet, $sheetInfo, $cellInfo); + } + } + + $backTrace = debug_backtrace(2, 2); + $callFunctionName = $backTrace[1]['function']; + + info(sprintf( + "%s: 最大单元格为 %s, 最大列: %s 最大行号: %s", + $callFunctionName, + $cellInfo['cell'], + $sheetInfo['maxCol'], + $sheetInfo['maxRow'] + )); + } + + /** + * 处理导入数据的计算属性 + * + * call in: + * public static function beforeSheet(BeforeSheet $event) + * + * @param Event $event + * @return void + */ + public static function handleCalculateSheet(Event $event) + { + Excel::handleAllCell($event, function ($event, $sheet, $sheetInfo, $cellInfo) { + try { + $calcValue = $sheet->getCell($cellInfo['cell'])->getCalculatedValue(); + $newValue = $calcValue; + } catch (\Throwable $e) { + $value = $sheet->getCell($cellInfo['cell'])->getValue(); + + info("获取单元格 {$cellInfo['cell']} 计算结果错误", [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + 'cell' => $cellInfo['cell'], + 'origin_value' => $value, + ]); + + $newValue = $value; + } + + $sheet->getCell($cellInfo['cell'])->setValue($newValue); + }); + } + + /** + * 处理替换表头中的 *,兼容处理含计算属性的单元格 + * + * call in: + * public static function beforeSheet(BeforeSheet $event) + * + * @param array $row + * @param array $replaceFlag + * @param string $targetFlag + * @return array + */ + public static function toArray(array $row, $replaceFlag = ['*'], $targetFlag = ''): array + { + $data = []; + + // num: 兼容表头出现空的情况 + $num = 0; + foreach ($row as $key => $value) { + $rowKey = str_replace($replaceFlag, $targetFlag, $key); + if ($rowKey !== false) { + $rowKey = trim($rowKey); + $rowKey = $rowKey ?: $num; + } + + // can be call after handleCalculateSheet, will auto calcute cell value + // this line is fallback if not call Excel::handleCalculateSheet($event) + $newValue = preg_replace("/=\"(.*)\"/", '\1', $value); + if (strlen($newValue) == 1 && str_contains($newValue, '-')) { + $newValue = str_replace('-', '', $newValue); + } + + if (!empty($newValue)) { + $newValue = trim($newValue); + } + + $data[$rowKey] = $newValue ?: null; + + $num++; + } + + return $data; + } + + /** + * 加载导入表格的 datetime 数据 + * + * call in: + * public static function beforeSheet(BeforeSheet $event) + * + * @param string|null|mixed $datetime + * @param string $format + * @param string $soruceFormat + * @return void + */ + public static function datetimeFromCell(mixed $datetime = null, $format = 'Y-m-d H:i:s', $soruceFormat = 'Y-m-d H:i:s') + { + if (!$datetime) { + return null; + } + + $isPureInt = preg_match('/^\d+?$/', $datetime); + $isFloatData = preg_match('/^\d+?\.\d+?$/', $datetime); + + if ($isPureInt || $isFloatData) { + return \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($datetime)?->format($format); + } + + $datetimeData = explode(' ', $datetime); + + $day = null; + $time = null; + if (count($datetimeData) == 2) { + $day = $datetimeData[0]; + $time = $datetimeData[1]; + } else { + $day = $datetimeData[0]; + $time = ""; + } + + $day = match (true) { + default => null, + str_contains($day, '-') => $day, + str_contains($day, '.') => str_replace('.', '-', $day), + str_contains($day, '/') => str_replace('/', '-', $day), + }; + + $datetime = $day; + if ($time) { + $timeData = explode(':', $time); + + $timeData = array_map(function ($item) { + return str_pad($item, 2, 0, STR_PAD_LEFT); + }, $timeData); + + $timeData = array_filter($timeData); + + $timeDataItemCount = count($timeData); + $timeStr = implode(':', $timeData); + + $timeStr = match ($timeDataItemCount) { + default => '', + 0 => '', + 1 => $timeStr . ':00:00', + 2 => $timeStr . ':00', + 3 => $timeStr . '', + }; + + $datetime .= " " . $timeStr; + $datetime = rtrim($datetime); + } + + if (!str_contains($datetime, ':')) { + $datetime = Carbon::createFromDate($datetime)->format($format); + } else { + $datetime = Carbon::createFromFormat($soruceFormat, $datetime)->format($format); + } + + return $datetime; + } + + /** + * 带 * 单元格红色标记 + * 带 :// 添加超链接 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @return void + */ + public static function handleRequireCellTextColorForRedAndHyperLink(Event $event) + { + Excel::handleAllCell($event, function ($event, $sheet, $sheetInfo, $cellInfo) { + $value = $sheet->getCell($cellInfo['cell'])->getValue(); + + try { + if (str_contains($value, "'")) { + $newValue = str_replace("'", '', $value); + } else { + $calcValue = $sheet->getCell($cellInfo['cell'])->getCalculatedValue(); + $newValue = $calcValue; + } + } catch (\Throwable $e) { + info("获取单元格 {$cellInfo['cell']} 计算结果错误", [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + 'cell' => $cellInfo['cell'], + 'origin_value' => $value, + ]); + + $newValue = $value; + } + + if (str_contains($newValue ?? '', '*')) { + $sheet->getStyle($cellInfo['cell'])->getFont()->getColor()->setARGB(Color::COLOR_RED); + } + + if (str_contains($newValue ?? '', '://')) { + Excel::cellAddHyperLink($event, $cellInfo['cell']); + } + + $sheet->getCell($cellInfo['cell'])->setValue($newValue); + }); + } + + /** + * 从数组加载数据数据 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @param array $data + * @param string $startColumn + * @param integer $startRow + * @return void + */ + public static function loadDataFromArray(Event $event, array $data = [], string $startColumn = 'A', int $startRow = 1) + { + $sheet = Excel::getSheet($event); + + if ($startRow < 1) { + $startRow = 1; + } + + // startColumn + $startColumnNum = ord($startColumn); + + foreach ($data as $dataRow => $item) { + if (count($item) < 1) { + continue; + } + + // cellRow + $cellRow = $dataRow + $startRow; + + foreach (range(0, count($item) - 1) as $dataCol) { + $value = $item[$dataCol] ?? null; + + // A=65 + $columnName = chr($startColumnNum + $dataCol); + + $cell = "{$columnName}{$cellRow}"; + $sheet->setCellValue($cell, $value); + } + } + } + + /** + * 给指定列添加超链接 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @param string $columnLetter + * @param string $tooltip + * @return void + */ + public static function hyper(Event $event, string $columnLetter, $tooltip = "查看") + { + $sheet = Excel::getSheet($event); + + foreach ($sheet->getColumnIterator($columnLetter) as $row) { + foreach ($row->getCellIterator() as $cell) { + $value = $cell->getValue(); + + if (str_contains($value, '://')) { + $cell->setHyperlink(new Hyperlink($value, $tooltip)); + + Excel::cellAddColor($event, $cell); + } + } + } + } + + /** + * 给指定单元格添加超链接 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @param string $cell + * @param string $tooltip + * @return void + */ + public static function cellAddHyperLink(Event $event, string $cell, $tooltip = "查看") + { + $sheet = Excel::getSheet($event); + + $sheetCell = $sheet->getCell($cell); + + $value = $sheetCell->getValue(); + + if (str_contains($value, '://')) { + $sheetCell->setHyperlink(new Hyperlink($value, $tooltip)); + + Excel::cellAddColor($event, $sheetCell->getCoordinate(), Color::COLOR_BLUE); + } + } + + /** + * 给指定单元格添加颜色 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @param string $cell + * @param [type] $color + * @return void + */ + public static function cellAddColor(Event $event, string $cell, $color = Color::COLOR_BLACK) + { + $sheet = Excel::getSheet($event); + + $sheetCell = $sheet->getCell($cell); + + $sheetCell->getStyle()->getFont()->getColor()->setARGB($color); + } + + /** + * 合并单元格 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @param string $range + * @param string $behaviour + * @return void + */ + public static function mergeCells(Event $event, string $range, string $behaviour = Worksheet::MERGE_CELL_CONTENT_EMPTY) + { + $sheet = Excel::getSheet($event); + + $sheet->mergeCells($range, $behaviour); + } + + /** + * 设置单元格数据 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @param string $cell + * @param mixed $value + * @return void + */ + public static function setCellValue(Event $event, string $cell, $value) + { + $sheet = Excel::getSheet($event); + + $sheetCell = $sheet->getCell($cell); + + $sheetCell->setValue($value); + } + + public static function getListData(array $firstZipData, array ...$otherZipDatas) + { + $data = collect($firstZipData)->zip(...$otherZipDatas); + + return $data; + } + + /** + * 设置表头加粗居中 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @param string $cellOrRange + * @param array $styleArray + * @param boolean $advancedBorders + * @return void + */ + public static function setTitleStyle(Event $event, string $cellOrRange, array $styleArray = [], $advancedBorders = true) + { + $sheet = Excel::getSheet($event); + + // autoSize + foreach ($sheet->getColumnIterator() as $column) { + $sheet->getColumnDimension($column->getColumnIndex())->setAutoSize(true); + } + + $rangeCellNames = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::extractAllCellReferencesInRange($cellOrRange); + + $styleArray = array_merge([ + 'font' => [ + 'bold' => true, + 'size' => 14, + 'name' => 'Microsoft YaHei', + 'color' => [ + 'argb' => 'ffffff', + ], + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => [ + 'argb' => 'C00000', + ], + ], + ], $styleArray); + + foreach ($rangeCellNames as $cell) { + $value = $sheet->getCell($cell)->getValue(); + + if (str_contains($value, '*')) { + $styleArray['fill']['startColor']['argb'] = 'C00000'; + $styleArray['font']['color']['argb'] = 'ffffff'; + } else { + $styleArray['fill']['startColor']['argb'] = '404040'; + $styleArray['font']['color']['argb'] = 'ffffff'; + } + + $sheet->getStyle($cell)->applyFromArray($styleArray, $advancedBorders); + } + } + + public static function setExplainData($event, $explain, $inCellRange, $height = 200, $advancedBorders = true) + { + $sheet = Excel::getSheet($event); + + // $explain = <<<"TXT" + // 填写须知: + // 1. 请勿修改表格结构; + // 2. 红色字段为必填项,黑色字段为选填项; + // 3. 用户 ID:非必填,成员的唯一标识,可以由字母、数字、‘_-@.’符号组成,不填则由系统自动生成; + // 4. 姓名:默认名称,必填,如需设置中文、英语、日语名称,可按如下规则填写:默认名称 | CN-中文名 | EN-英语名 | JP-日语名,例如“张三 | CN-张三 | EN-ZhangSan | JP-張三”; + // 5. 联系手机:必填,且在本企业内不可重复,地区码必须包含加号 +,为保证可以正常编辑带地区码格式的国际手机号,建议将手机号一列的单元格格式调整为“文本”; + // 6. 部门:必填,上下级部门间使用“/”隔开,请从最上级部门(即企业名称)开始填写。例如“飞书有限公司/研发部"",若未填写则默认添加到选择的节点下,若归属于多个部门请用英文“,”隔开;请注意部门顺序,其中第一个部门将在导入后被标记为主部门,其余顺序也将在个人信息中体现; + // 7. 性别:请填写男或女; + // 8. 直属上级:请填写直属上级的手机号(若为国际手机号则必须包含加号以及国家地区码,例如:“+8589****33”)、邮箱或用户 ID,如匹配失败请检查是否填对字段或直属上级是否未导入通讯录; + // 9. 人员类型:必填,请从下列选项中选填一个选项:“正式”、“实习”、“外包”、“劳务”、“顾问”,若不填则默认为“正式”; + // 10. 部门负责人:请填入“是”、“否”,若不填则默认为“否”,若归属于多个部门请用英文“,”隔开; + // 11. 入职日期:必填,请按 YYYY-MM-DD 的格式填写,如 2019-01-01; + // 12. 手机号是否可见:请填入“是”、“否”,若不填则默认为“是”; + // 13. 工作城市:请先在“成员字段管理”中为“工作城市”添加选项,目前可从下列选项中选填一项:请先在“成员字段管理”中为“工作城市”添加选项并设置为“已启用”状态 + // 14. 职务:请先在“成员字段管理”中为“职务”添加选项,目前可从下列选项中选填一项:请先在“成员字段管理”中为“职务”添加选项并设置为“已启用”状态 + // 15. URL 类自定义字段:非必填,所有字段名后带“(URL)”的字段均为 URL 类自定义字段,可以超链接形式展示在客户端个人名片页。若需填写信息,则需按“标题 | URL | URL”格式填写,其中标题必填且至少需填写一个 URL,若只填写 1 个 URL 则移动端和桌面端共用该URL,若填写了 2 个 URL 则默认第一个为移动端 URL,URL 请以“http://”或“https://”开头。 + // TXT; + + $sheet->getCell('A1')->setValue($explain); + $sheet->getCell('A1')->setHyperlink(null); + + // $sheet->getColumnDimension('A')->setWidth(300); // 设置宽度 + $sheet->getRowDimension(1)->setRowHeight($height); + + $sheet->getStyle('A1')->applyFromArray([ + 'font' => [ + 'size' => 12, + 'name' => 'Microsoft YaHei', + 'color' => [ + 'argb' => '000000', + ], + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER + ], + 'alignment' => [ + // 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER, + 'wrapText' => true, + ], + ], $advancedBorders); + + Excel::mergeCells($event, $inCellRange); + } + + public static function setTitleStyleAndExplainData($event, $explain, $explainMergeRange, $titleRange) + { + // 带 * 单元格红色标记 + Excel::handleRequireCellTextColorForRedAndHyperLink($event); + // 表头加租居中 + Excel::setTitleStyle($event, $titleRange); + // 设置说明 + Excel::setExplainData($event, $explain, $explainMergeRange); + } + + public static function setListCell($event, $columnName, $dataStartCellNum, $sheetNameOrDropdownList, $startCellAndEndCell = null, $maxRowNum = 1000, $errorTitle = null, $errorMessage = null, $promptTitle = null, $promptMessage = null) + { + /** @see https://github.com/PHPOffice/PhpSpreadsheet/blob/master/samples/Basic/39_Dropdown.php */ + + if (!is_array($sheetNameOrDropdownList)) { + if (empty($startCellAndEndCell)) { + return null; + } + } + + /** @var \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet */ + $spreadsheet = Excel::getSheet($event); + + $dataEndCellNum = $dataStartCellNum + $maxRowNum; + foreach (range($dataStartCellNum, $dataEndCellNum) as $i) { + $validation = $spreadsheet->getCell($columnName . $i)->getDataValidation(); + $validation->setType(\PhpOffice\PhpSpreadsheet\Cell\DataValidation::TYPE_LIST); + $validation->setErrorStyle(\PhpOffice\PhpSpreadsheet\Cell\DataValidation::STYLE_INFORMATION); + $validation->setAllowBlank(false); + $validation->setShowInputMessage(true); + $validation->setShowErrorMessage(true); + $validation->setShowDropDown(true); + if ($errorTitle) { + // $validation->setErrorTitle('Input error'); + $validation->setErrorTitle('Input error'); + } + if ($errorMessage) { + // $validation->setError('Value is not in list.'); + $validation->setError($errorMessage); + } + if ($promptTitle) { + // $validation->setPromptTitle('Pick from list'); + $validation->setPromptTitle($promptTitle); + } + if ($promptMessage) { + // $validation->setPrompt('Please pick a value from the drop-down list.'); + $validation->setPrompt($promptMessage); + } + + + if (is_string($sheetNameOrDropdownList)) { + $validation->setFormula1(<<setFormula1(sprintf('"%s"', $dataString)); + } + } + } + + /** + * 设置单元格样式,默认 14号字体,不加粗居中 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * + * @param Event $event + * @param string $cellOrRange + * @param array $styleArray + * @param boolean $advancedBorders + * @return void + */ + public static function setCellStyle(Event $event, string $cellOrRange, array $styleArray = [], $advancedBorders = true) + { + $sheet = Excel::getSheet($event); + + $styleArray = array_merge([ + 'font' => [ + 'color' => [ + 'argb' => '000000', + ], + 'bold' => false, + 'size' => 12, + 'name' => '微软雅黑', + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER + ], + // 'fill' => [ + // 'fillType' => Fill::FILL_SOLID, + // 'startColor' => [ + // 'argb' => 'C00000', + // ], + // ], + ], $styleArray); + + $sheet->getStyle($cellOrRange)->applyFromArray($styleArray, $advancedBorders); + } + + /** + * 单元格数字转文字显示 + * + * call in: + * public static function afterSheet(AfterSheet $event) + * public function collection(Collection $collection) + * public function array(): array + * public function map($row): array + * public function model(Collection $model) + * + * @param string $format + * @param string|array ...$value + * @return string|null + */ + public static function valueToCellString($format = '="%s"', ...$value): ?string + { + if (!str_starts_with($format, '=')) { + $value = $format; + if (preg_match('/^\d+?$/', $format) == false) { + return $value; + } + + $format = '="%s"'; + + if (!$value) { + return null; + } + + $value = [$value]; + } + + return sprintf($format, ...$value); + } + + /** + * 单元格计算 cell + * + * @param [type] $value + * @return void + */ + public static function valueToCalcCell($value) + { + if (!str_starts_with($value, '=')) { + return $value; + } + + return "'".$value; + } +} diff --git a/src/Utils/File.php b/src/Utils/File.php new file mode 100644 index 0000000..e74ced4 --- /dev/null +++ b/src/Utils/File.php @@ -0,0 +1,179 @@ + '.wav', + 'audio/x-ms-wma' => '.wma', + 'video/x-ms-wmv' => '.wmv', + 'video/mp4' => '.mp4', + 'audio/mpeg' => '.mp3', + 'audio/amr' => '.amr', + 'application/vnd.rn-realmedia' => '.rm', + 'audio/mid' => '.mid', + 'image/bmp' => '.bmp', + 'image/gif' => '.gif', + 'image/png' => '.png', + 'image/tiff' => '.tiff', + 'image/jpeg' => '.jpg', + 'application/pdf' => '.pdf', + + // 列举更多的文件 mime, 企业号是支持的,公众平台这边之后万一也更新了呢 + 'application/msword' => '.doc', + + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => '.docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template' => '.dotx', + 'application/vnd.ms-word.document.macroEnabled.12' => '.docm', + 'application/vnd.ms-word.template.macroEnabled.12' => '.dotm', + + 'application/vnd.ms-excel' => '.xls', + + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => '.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template' => '.xltx', + 'application/vnd.ms-excel.sheet.macroEnabled.12' => '.xlsm', + 'application/vnd.ms-excel.template.macroEnabled.12' => '.xltm', + 'application/vnd.ms-excel.addin.macroEnabled.12' => '.xlam', + 'application/vnd.ms-excel.sheet.binary.macroEnabled.12' => '.xlsb', + + 'application/vnd.ms-powerpoint' => '.ppt', + + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => '.pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.template' => '.potx', + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow' => '.ppsx', + 'application/vnd.ms-powerpoint.addin.macroEnabled.12' => '.ppam', + ]; + + /** + * File header signatures. + * + * @var array + */ + protected static $signatures = [ + 'ffd8ff' => '.jpg', + '424d' => '.bmp', + '47494638' => '.gif', + '2f55736572732f6f7665' => '.png', + '89504e47' => '.png', + '494433' => '.mp3', + 'fffb' => '.mp3', + 'fff3' => '.mp3', + '3026b2758e66cf11' => '.wma', + '52494646' => '.wav', + '57415645' => '.wav', + '41564920' => '.avi', + '000001ba' => '.mpg', + '000001b3' => '.mpg', + '2321414d52' => '.amr', + '25504446' => '.pdf', + ]; + + /** + * Return steam extension. + * + * @param string $stream + * + * @return string|false + */ + public static function getStreamExt(string $stream) + { + $ext = self::getExtBySignature($stream); + + try { + if (empty($ext) && is_readable($stream)) { + $stream = file_get_contents($stream); + } + } catch (\Exception $e) { + } + + $fileInfo = new finfo(FILEINFO_MIME); + + $mime = strstr($fileInfo->buffer($stream), ';', true); + + return isset(self::$extensionMap[$mime]) ? self::$extensionMap[$mime] : $ext; + } + + /** + * Get file extension by file header signature. + * + * @param string $stream + * + * @return string + */ + public static function getExtBySignature(string $stream) + { + $prefix = strval(bin2hex(mb_strcut($stream, 0, 10))); + + foreach (self::$signatures as $signature => $extension) { + if (0 === strpos($prefix, strval($signature))) { + return $extension; + } + } + + return ''; + } + + /** + * Get the mime-type of a given file. + * + * @param string $path + * @return string|false + */ + public static function mimeTypeFromPath(string $path) + { + return finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path); + } + + /** + * Get the mime-type of a given content. + * + * @param string $path + * @return string|false + */ + public static function mimeTypeFromContent(string $content) + { + return finfo_buffer(finfo_open(FILEINFO_MIME_TYPE), $content); + } + + /** + * Ensure Filepath Exists + */ + public static function ensurePathExists(string $dirpath, $mode = 0755, $recursive = true, $force = false) + { + if (! is_dir($dirpath)) { + if ($force) { + @mkdir($dirpath, $mode, $recursive); + } else { + mkdir($dirpath, $mode, $recursive); + } + } + + return $dirpath; + } + + public static function scandir(string $dirpath, callable $callable) + { + $dirIterator = new \RecursiveDirectoryIterator($dirpath, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS); + $iterator = new \RecursiveIteratorIterator($dirIterator); + /** @var \SplFileInfo $fille */ + foreach ($iterator as $file) { + if (in_array($file->getBasename(), ['.', '..', '__MACOSX', '.DS_Store'])) continue; + if (str_contains($file->getPathname(), '__MACOSX')) continue; + + if ($callable($file) === false) { + return; + } + } + } +} diff --git a/src/Utils/HigherOrderTapProxy.php b/src/Utils/HigherOrderTapProxy.php new file mode 100644 index 0000000..269ffe5 --- /dev/null +++ b/src/Utils/HigherOrderTapProxy.php @@ -0,0 +1,38 @@ +target = $target; + } + + /** + * Dynamically pass method calls to the target. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + $this->target->{$method}(...$parameters); + + return $this->target; + } +} diff --git a/src/Utils/Json.php b/src/Utils/Json.php new file mode 100644 index 0000000..acba6e0 --- /dev/null +++ b/src/Utils/Json.php @@ -0,0 +1,65 @@ +filepath = $filepath; + + $this->decode(); + } + + public static function make(?string $filepath = null) + { + return new static($filepath); + } + + public function decode(?string $content = null) + { + if ($this->filepath && file_exists($this->filepath)) { + $content = @file_get_contents($this->filepath); + } + + if (!$content) { + $content = ''; + } + + $this->data = json_decode($content, true) ?? []; + + return $this; + } + + public function encode(?array $data = null, $options = null) + { + $defaultOptions = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_NUMERIC_CHECK| JSON_PRETTY_PRINT; + + if ($options) { + $options = $defaultOptions | $options; + } else { + $options = $defaultOptions; + } + + if ($data) { + $this->data = $data; + } + + return json_encode($this->data, $options); + } + + public function get(mixed $key = null, mixed $default = null) + { + if (!Arr::has($this->data, $key)) { + return $this->data; + } + + return Arr::get($this->data, $key, $default); + } +} diff --git a/src/Utils/LaravelCache.php b/src/Utils/LaravelCache.php new file mode 100644 index 0000000..d13379e --- /dev/null +++ b/src/Utils/LaravelCache.php @@ -0,0 +1,100 @@ + LaravelCache::NULL_KEY_NUM) { + return null; + } + + // 使用默认缓存时间 + if (is_callable($cacheTime)) { + $callable = $cacheTime; + + // 防止缓存雪崩,对不同数据随机缓存时间。从半小时到 1天 + $index = rand(0, 100) % count(LaravelCache::DEFAULT_CACHE_TIME); + $cacheSeconds = LaravelCache::DEFAULT_CACHE_TIME[$index]; + $cacheTime = now()->addSeconds($cacheSeconds); + } + + if (!is_callable($callable)) { + return null; + } + + if ($forever) { + $data = Cache::rememberForever($cacheKey, $callable); + } else { + $data = Cache::remember($cacheKey, $cacheTime, $callable); + } + + if (!$data) { + Cache::pull($cacheKey); + + $currentCacheKeyNullNum = (int) Cache::get($nullCacheKey); + + Cache::put($nullCacheKey, ++$currentCacheKeyNullNum, now()->addSeconds(LaravelCache::NULL_KEY_CACHE_TIME)); + } + + return $data; + } + + /** + * 执行指定函数并永久缓存 + * + * @param string $cacheKey + * @param callable|Carbon|null $cacheTime + * @param callable|null $callable + * @return mixed + */ + public static function rememberForever(string $cacheKey, callable|Carbon|null $cacheTime = null, callable $callable = null) + { + return LaravelCache::remember($cacheKey, $cacheTime, $callable, true); + } + + /** + * 转发调用 + * + * @param mixed $method + * @param mixed $args + * @return mixed + */ + public static function __callStatic(mixed $method, mixed $args) + { + return Cache::$method(...$args); + } +} diff --git a/src/Utils/Process.php b/src/Utils/Process.php new file mode 100644 index 0000000..2ea154b --- /dev/null +++ b/src/Utils/Process.php @@ -0,0 +1,48 @@ +setTimeout(900); + + if ($process->isTty()) { + $process->setTty(true); + } + + try { + if ($output !== false) { + $output = app(OutputInterface::class); + } + } catch (\Throwable $e) { + $output = $output ?? null; + } + + $envs = [ + 'PATH' => rtrim(`echo \$PATH`), + ] + $env; + + if ($output) { + $output->write("\n"); + $process->run( + function ($type, $line) use ($output) { + $output->write($line); + }, + $envs, + ); + } else { + $process->run(null, $envs); + } + + return $process; + } +} diff --git a/src/Utils/RSA.php b/src/Utils/RSA.php new file mode 100644 index 0000000..0b99385 --- /dev/null +++ b/src/Utils/RSA.php @@ -0,0 +1,174 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ), $config); + + $privateKeyObj = openssl_pkey_new($config); + + $publicKey = openssl_pkey_get_details($privateKeyObj)['key']; + openssl_pkey_export($privateKeyObj, $privateKey); + + return [ + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + ]; + } + + public static function singleLinePublicKey($publicKey) + { + $string = str_replace([ + "-----BEGIN PUBLIC KEY-----\n", + "-----END PUBLIC KEY-----", + ], '', $publicKey); + + $stringArray = explode("\n", $string); + $result = implode('', $stringArray); + + return $result; + } + + public static function singleLinePrivateKey($privateKey) + { + $string = str_replace([ + "-----BEGIN PRIVATE KEY-----\n", + "-----END PRIVATE KEY-----", + ], '', $privateKey); + + $stringArray = explode("\n", $string); + $result = implode('', $stringArray); + + return $result; + } + + public static function normalPublicKey($publicKey) + { + $fKey = "-----BEGIN PUBLIC KEY-----\n"; + $len = strlen($publicKey); + for ($i = 0; $i < $len;) { + $fKey = $fKey . substr($publicKey, $i, 64) . "\n"; + $i += 64; + } + $fKey .= "-----END PUBLIC KEY-----"; + return $fKey; + } + + public static function normalPrivateKey($privateKey) + { + $fKey = "-----BEGIN PRIVATE KEY-----\n"; + $len = strlen($privateKey); + for ($i = 0; $i < $len;) { + $fKey = $fKey . substr($privateKey, $i, 64) . "\n"; + $i += 64; + } + $fKey .= "-----END PRIVATE KEY-----"; + return $fKey; + } + + public static function chunkEncrypt($data, $publicKey, $keySize = 2048) + { + if (!$data) { + return null; + } + + if (!is_string($data)) { + $data = json_encode($data, JSON_NUMERIC_CHECK | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + if (str_contains($publicKey, "PUBLIC") === false) { + $publicKey = RSA::normalPublicKey($publicKey); // 格式化公钥为标准的 public key + } + + $plaintext = $data; + $chunkSize = $keySize / 8 - 11; + + $output = ""; + while ($plaintext) { + $chunk = substr($plaintext, 0, $chunkSize); + $plaintext = substr($plaintext, $chunkSize); + openssl_public_encrypt($chunk, $encrypted, $publicKey); + $output .= $encrypted; + } + + return Str::stringToHex($output); + } + + public static function chunkDecrypt($data, $privateKey, $keySize = 2048) + { + if (!$data) { + return null; + } + + if (str_contains($privateKey, "PRIVATE") === false) { + $privateKey = RSA::normalPrivateKey($privateKey); // 格式化私钥为标准的 private key + } + + $output = Str::hexToString($data); + + $plaintext = ""; + while ($output) { + $chunk = substr($output, 0, $keySize / 8); + $output = substr($output, $keySize / 8); + openssl_private_decrypt($chunk, $decrypted, $privateKey); + $plaintext .= $decrypted; + } + + return $plaintext; + } + + public static function encrypt($data, $privateKey) + { + if (!$data) { + return null; + } + + if (!is_string($data)) { + $data = json_encode($data, JSON_NUMERIC_CHECK | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + if (str_contains($privateKey, "PRIVATE") === false) { + $privateKey = RSA::normalPrivateKey($privateKey); // 格式化私钥为标准的 private key + } + + $res = openssl_private_encrypt($data, $encrypted, $privateKey); + + if (!$res) { + return false; + } + + return Str::stringToHex($encrypted); + } + + public static function decrypt($data, $publicKey) + { + if (!$data) { + return null; + } + + if (!is_string($data)) { + return false; + } + + if (str_contains($publicKey, "PUBLIC") === false) { + $publicKey = RSA::normalPublicKey($publicKey); // 格式化公钥为标准的 public key + } + + // $openssl_pub = openssl_pkey_get_public($publicKey); // 不知道作用,未使用 + + // 验签 + $resArr = openssl_public_decrypt(Str::hexToString($data), $decrypted, $publicKey); + + if (!$resArr) { + return false; + } + + return $decrypted; + } +} diff --git a/src/Utils/State.php b/src/Utils/State.php new file mode 100644 index 0000000..df3c9be --- /dev/null +++ b/src/Utils/State.php @@ -0,0 +1,31 @@ + str_repeat('*', 3), + $len > 3 => str_repeat('*', bcsub($len, 3)), + }; + + $offset = match (true) { + default => 1, + $len > 3 => 3, + }; + + $maskUser = substr_replace($user, $mask, $offset); + + return "{$maskUser}{$domain}"; + } + + // number + public static function maskNumber(string|int|null $number = null): ?string + { + if (empty($number)) { + return null; + } + + if (strlen($number) < 4) { + return $number; + } + + $head = substr($number, 0, 2); + $tail = substr($number, -2); + $starCount = strlen($number) - 4; + $star = str_repeat('*', $starCount); + + return $head.$star.$tail; + } + + // name + public static function maskName(?string $name = null): ?string + { + if (empty($number)) { + return null; + } + + $len = mb_strlen($name); + if ($len < 1) { + return $name; + } + $last = mb_substr($name, -1, 1); + $lastName = str_repeat('*', $len - 1); + + return $lastName.$last; + } + + // generate digital + public static function generateDigital(int $length = 6): int + { + return rand(pow(10, ($length - 1)), pow(10, $length) - 1); + } + + // qualify url + public static function qualifyUrl(?string $uri = null, ?string $domain = null): ?string + { + if (empty($uri)) { + return null; + } + + if (str_contains($uri, '://')) { + return $uri; + } + + if (! $domain) { + return sprintf('%s/%s', config('app.url'), ltrim($uri, '/')); + } + + return sprintf('%s/%s', rtrim($domain, '/'), ltrim($uri, '/')); + } + + // Whether it is a pure number + public static function isPureInt(mixed $variable): bool + { + return preg_match('/^\d+?$/', $variable); + } + + // It takes a hostname as a string and returns the domain name as a string + public static function extractDomainByHost(?string $host = null): ?string + { + if (empty($host)) { + return null; + } + + if ($host == 'localhost') { + // localhost + return 'localhost'; + } elseif (filter_var($host, FILTER_VALIDATE_IP)) { + // IPv4 and IPv6 + return $host; + } + + $ianaRoot = [ + // gTLDs + 'com', 'net', 'org', 'edu', 'gov', 'int', 'mil', 'arpa', 'biz', 'info', 'pro', 'name', 'coop', 'travel', 'xxx', 'idv', 'aero', 'museum', 'mobi', 'asia', 'tel', 'post', 'jobs', 'cat', + // ccTLDs + 'ad', 'ae', 'af', 'ag', 'ai', 'al', 'am', 'an', 'ao', 'aq', 'ar', 'as', 'at', 'au', 'aw', 'az', 'ba', 'bb', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 'bj', 'bm', 'bn', 'bo', 'br', 'bs', 'bt', 'bv', 'bw', 'by', 'bz', 'ca', 'cc', 'cd', 'cf', 'cg', 'ch', 'ci', 'ck', 'cl', 'cm', 'cn', 'co', 'cr', 'cu', 'cv', 'cx', 'cy', 'cz', 'de', 'dj', 'dk', 'dm', 'do', 'dz', 'ec', 'ee', 'eg', 'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 'fk', 'fm', 'fo', 'fr', 'ga', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gl', 'gm', 'gn', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gw', 'gy', 'hk', 'hm', 'hn', 'hr', 'ht', 'hu', 'id', 'ie', 'il', 'im', 'in', 'io', 'iq', 'ir', 'is', 'it', 'je', 'jm', 'jo', 'jp', 'ke', 'kg', 'kh', 'ki', 'km', 'kn', 'kp', 'kr', 'kw', 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk', 'lr', 'ls', 'ma', 'mc', 'md', 'me', 'mg', 'mh', 'mk', 'ml', 'mm', 'mn', 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my', 'mz', 'na', 'nc', 'ne', 'nf', 'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu', 'nz', 'om', 'pa', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pm', 'pn', 'pr', 'ps', 'pt', 'pw', 'py', 'qa', 're', 'ro', 'ru', 'rw', 'sa', 'sb', 'sc', 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'sk', 'sm', 'sn', 'so', 'sr', 'st', 'sv', 'sy', 'sz', 'tc', 'td', 'tf', 'tg', 'th', 'tj', 'tk', 'tl', 'tm', 'tn', 'to', 'tp', 'tr', 'tt', 'tv', 'tw', 'tz', 'ua', 'ug', 'uk', 'um', 'us', 'uy', 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 'wf', 'ws', 'ye', 'yt', 'yu', 'yr', 'za', 'zm', 'zw', + // new gTLDs (Business) + 'accountant', 'club', 'coach', 'college', 'company', 'construction', 'consulting', 'contractors', 'cooking', 'corp', 'credit', 'creditcard', 'dance', 'dealer', 'democrat', 'dental', 'dentist', 'design', 'diamonds', 'direct', 'doctor', 'drive', 'eco', 'education', 'energy', 'engineer', 'engineering', 'equipment', 'events', 'exchange', 'expert', 'express', 'faith', 'farm', 'farmers', 'fashion', 'finance', 'financial', 'fish', 'fit', 'fitness', 'flights', 'florist', 'flowers', 'food', 'football', 'forsale', 'furniture', 'game', 'games', 'garden', 'gmbh', 'golf', 'health', 'healthcare', 'hockey', 'holdings', 'holiday', 'home', 'hospital', 'hotel', 'hotels', 'house', 'inc', 'industries', 'insurance', 'insure', 'investments', 'islam', 'jewelry', 'justforu', 'kid', 'kids', 'law', 'lawyer', 'legal', 'lighting', 'limited', 'live', 'llc', 'llp', 'loft', 'ltd', 'ltda', 'managment', 'marketing', 'media', 'medical', 'men', 'money', 'mortgage', 'moto', 'motorcycles', 'music', 'mutualfunds', 'ngo', 'partners', 'party', 'pharmacy', 'photo', 'photography', 'photos', 'physio', 'pizza', 'plumbing', 'press', 'prod', 'productions', 'radio', 'rehab', 'ren', 'rent', 'repair', 'report', 'republican', 'restaurant', 'room', 'rugby', 'safe', 'sale', 'sarl', 'save', 'school', 'secure', 'security', 'services', 'shoes', 'show', 'soccer', 'spa', 'sport', 'sports', 'spot', 'srl', 'storage', 'studio', 'tattoo', 'taxi', 'team', 'tech', 'technology', 'thai', 'tips', 'tour', 'tours', 'toys', 'trade', 'trading', 'travelers', 'university', 'vacations', 'ventures', 'versicherung', 'versicherung', 'vet', 'wedding', 'wine', 'winners', 'work', 'works', 'yachts', 'zone', + // new gTLDs (Construction & Real Estate) + 'archi', 'architect', 'casa', 'contruction', 'estate', 'haus', 'house', 'immo', 'immobilien', 'lighting', 'loft', 'mls', 'realty', + // new gTLDs (Community & Religion) + 'academy', 'arab', 'bible', 'care', 'catholic', 'charity', 'christmas', 'church', 'college', 'community', 'contact', 'degree', 'education', 'faith', 'foundation', 'gay', 'halal', 'hiv', 'indiands', 'institute', 'irish', 'islam', 'kiwi', 'latino', 'mba', 'meet', 'memorial', 'ngo', 'phd', 'prof', 'school', 'schule', 'science', 'singles', 'social', 'swiss', 'thai', 'trust', 'university', 'uno', + // new gTLDs (E-commerce & Shopping) + 'auction', 'best', 'bid', 'boutique', 'center', 'cheap', 'compare', 'coupon', 'coupons', 'deal', 'deals', 'diamonds', 'discount', 'fashion', 'forsale', 'free', 'gift', 'gold', 'gratis', 'hot', 'jewelry', 'kaufen', 'luxe', 'luxury', 'market', 'moda', 'pay', 'promo', 'qpon', 'review', 'reviews', 'rocks', 'sale', 'shoes', 'shop', 'shopping', 'store', 'tienda', 'top', 'toys', 'watch', 'zero', + // new gTLDs (Dining) + 'bar', 'bio', 'cafe', 'catering', 'coffee', 'cooking', 'diet', 'eat', 'food', 'kitchen', 'menu', 'organic', 'pizza', 'pub', 'rest', 'restaurant', 'vodka', 'wine', + // new gTLDs (Travel) + 'abudhabi', 'africa', 'alsace', 'amsterdam', 'barcelona', 'bayern', 'berlin', 'boats', 'booking', 'boston', 'brussels', 'budapest', 'caravan', 'casa', 'catalonia', 'city', 'club', 'cologne', 'corsica', 'country', 'cruise', 'cruises', 'deal', 'deals', 'doha', 'dubai', 'durban', 'earth', 'flights', 'fly', 'fun', 'gent', 'guide', 'hamburg', 'helsinki', 'holiday', 'hotel', 'hoteles', 'hotels', 'ist', 'istanbul', 'joburg', 'koeln', 'land', 'london', 'madrid', 'map', 'melbourne', 'miami', 'moscow', 'nagoya', 'nrw', 'nyc', 'osaka', 'paris', 'party', 'persiangulf', 'place', 'quebec', 'reise', 'reisen', 'rio', 'roma', 'room', 'ruhr', 'saarland', 'stockholm', 'swiss', 'sydney', 'taipei', 'tickets', 'tirol', 'tokyo', 'tour', 'tours', 'town', 'travelers', 'vacations', 'vegas', 'wales', 'wien', 'world', 'yokohama', 'zuerich', + // new gTLDs (Sports & Hobbies) + 'art', 'auto', 'autos', 'baby', 'band', 'baseball', 'beats', 'beauty', 'beknown', 'bike', 'book', 'boutique', 'broadway', 'car', 'cars', 'club', 'coach', 'contact', 'cool', 'cricket', 'dad', 'dance', 'date', 'dating', 'design', 'dog', 'events', 'family', 'fan', 'fans', 'fashion', 'film', 'final', 'fishing', 'football', 'fun', 'furniture', 'futbol', 'gallery', 'game', 'games', 'garden', 'gay', 'golf', 'guru', 'hair', 'hiphop', 'hockey', 'home', 'horse', 'icu', 'joy', 'kid', 'kids', 'life', 'lifestyle', 'like', 'living', 'lol', 'makeup', 'meet', 'men', 'moda', 'moi', 'mom', 'movie', 'movistar', 'music', 'party', 'pet', 'pets', 'photo', 'photography', 'photos', 'pics', 'pictures', 'play', 'poker', 'rodeo', 'rugby', 'run', 'salon', 'singles', 'ski', 'skin', 'smile', 'soccer', 'social', 'song', 'soy', 'sport', 'sports', 'star', 'style', 'surf', 'tatoo', 'tennis', 'theater', 'theatre', 'tunes', 'vip', 'wed', 'wedding', 'winwinners', 'yoga', 'you', + // new gTLDs (Network Technology) + 'analytics', 'antivirus', 'app', 'blog', 'wiki', 'call', 'camera', 'channel', 'chat', 'click', 'cloud', 'computer', 'contact', 'data', 'dev', 'digital', 'direct', 'docs', 'domains', 'dot', 'download', 'email', 'foo', 'forum', 'graphics', 'guide', 'help', 'home', 'host', 'hosting', 'idn', 'link', 'lol', 'mail', 'mobile', 'network', 'online', 'open', 'page', 'phone', 'pin', 'search', 'site', 'software', 'webcam', 'local', 'tools', + // new gTLDs (Other) + 'airforce', 'army', 'black', 'blue', 'box', 'buzz', 'casa', 'cool', 'day', 'discover', 'donuts', 'exposed', 'fast', 'finish', 'fire', 'fyi', 'global', 'green', 'help', 'here', 'how', 'international', 'ira', 'jetzt', 'jot', 'like', 'live', 'kim', 'navy', 'new', 'news', 'next', 'ninja', 'now', 'one', 'ooo', 'pink', 'plus', 'red', 'solar', 'tips', 'today', 'weather', 'wow', 'wtf', 'xyz', 'abogado', 'adult', 'anquan', 'aquitaine', 'attorney', 'audible', 'autoinsurance', 'banque', 'bargains', 'bcn', 'beer', 'bet', 'bingo', 'blackfriday', 'bom', 'boo', 'bot', 'broker', 'builders', 'business', 'bzh', 'cab', 'cal', 'cam', 'camp', 'cancerresearch', 'capetown', 'carinsurance', 'casino', 'ceo', 'cfp', 'circle', 'claims', 'cleaning', 'clothing', 'codes', 'condos', 'connectors', 'courses', 'cpa', 'cymru', 'dds', 'delivery', 'desi', 'directory', 'diy', 'dvr', 'ecom', 'enterprises', 'esq', 'eus', 'fail', 'feedback', 'financialaid', 'frontdoor', 'fund', 'gal', 'gifts', 'gives', 'giving', 'glass', 'gop', 'got', 'gripe', 'grocery', 'group', 'guitars', 'hangout', 'homegoods', 'homes', 'homesense', 'hotels', 'ing', 'ink', 'juegos', 'kinder', 'kosher', 'kyoto', 'lat', 'lease', 'lgbt', 'liason', 'loan', 'loans', 'locker', 'lotto', 'love', 'maison', 'markets', 'matrix', 'meme', 'mov', 'okinawa', 'ong', 'onl', 'origins', 'parts', 'patch', 'pid', 'ping', 'porn', 'progressive', 'properties', 'property', 'protection', 'racing', 'read', 'realestate', 'realtor', 'recipes', 'rentals', 'sex', 'sexy', 'shopyourway', 'shouji', 'silk', 'solutions', 'stroke', 'study', 'sucks', 'supplies', 'supply', 'tax', 'tires', 'total', 'training', 'translations', 'travelersinsurcance', 'ventures', 'viajes', 'villas', 'vin', 'vivo', 'voyage', 'vuelos', 'wang', 'watches', + ]; + + $domainPartData = explode('.', $host); + + $reverseDomainData = array_reverse($domainPartData); + + $suffixDomainData = [$reverseDomainData[0], $reverseDomainData[1]]; + + $count = 0; + foreach ($suffixDomainData as $part) { + foreach ($ianaRoot as $value) { + if ($value === $part) { + $count++; + } + } + } + + $domain = match ($count) { + 1 => implode('.', array_reverse([$reverseDomainData[0], $reverseDomainData[1]])), + 2 => implode('.', array_reverse([$reverseDomainData[0], $reverseDomainData[1], $reverseDomainData[2]])), + default => $host, + }; + + return $domain ?? 'Unknown Error'; + } + + public static function extractDomainByUrl(string $url): string + { + $host = parse_url(https://github.com/$url,%20PHP_URL_HOST); + $domain = self::extractDomainByHost($host); + + return $domain ?? 'Unknown Error'; + } + + public static function slug(string $text): string + { + if (preg_match("/^[A-Za-z\s]+$/", $text)) { + $slug = Str::slug($text, '-'); + } else { + $slug = rawurlencode($text); + } + + $slug = StrSupport::lower($slug); + + return $slug; + } +} diff --git a/src/Utils/Tree.php b/src/Utils/Tree.php new file mode 100644 index 0000000..540c6ba --- /dev/null +++ b/src/Utils/Tree.php @@ -0,0 +1,46 @@ +getHex()->toString(); // like: 6b2092378b014528b30b4a9b5fab3ba7 + } + + return \Ramsey\Uuid\Uuid::uuid4()->toString(); // mysql uuid, like: 6b209237-8b01-4528-b30b-4a9b5fab3ba7 + } + + public static function getCurrentSerialNumber(string $modelClass, $serialNumberField = 'serial_number'): int + { + return $modelClass::whereDate('created_at', now())->max(DB::raw("cast(`{$serialNumberField}` as UNSIGNED INTEGER)")) ?? 0; + } + + public static function generateNextSerialNumberNo(int $serialNumber, int $padLength = 3): string + { + $nextSerialNumber = $serialNumber + 1; + + $no = str_pad($nextSerialNumber, $padLength, '0', STR_PAD_LEFT); + + return date('ymd') . $no; + } +} diff --git a/src/Utils/XML.php b/src/Utils/XML.php new file mode 100644 index 0000000..c607343 --- /dev/null +++ b/src/Utils/XML.php @@ -0,0 +1,158 @@ + $value) { + $_attr[] = "{$key}=\"{$value}\""; + } + + $attr = implode(' ', $_attr); + } + + $attr = trim($attr); + $attr = empty($attr) ? '' : " {$attr}"; + $xml = "<{$root}{$attr}>"; + $xml .= self::data2Xml($data, $item, $id); + $xml .= ""; + + return $xml; + } + + /** + * Build CDATA. + * + * @param string $string + * + * @return string + */ + public static function cdata($string) + { + return sprintf('', $string); + } + + /** + * Object to array. + * + * + * @param SimpleXMLElement $obj + * + * @return array + */ + protected static function normalize($obj) + { + $result = null; + + if (is_object($obj)) { + $obj = (array) $obj; + } + + if (is_array($obj)) { + foreach ($obj as $key => $value) { + $res = self::normalize($value); + if (('@attributes' === $key) && ($key)) { + $result = $res; // @codeCoverageIgnore + } else { + $result[$key] = $res; + } + } + } else { + $result = $obj; + } + + return $result; + } + + /** + * Array to XML. + * + * @param array $data + * @param string $item + * @param string $id + * + * @return string + */ + protected static function data2Xml($data, $item = 'item', $id = 'id') + { + $xml = $attr = ''; + + foreach ($data as $key => $val) { + if (is_numeric($key)) { + $id && $attr = " {$id}=\"{$key}\""; + $key = $item; + } + + $xml .= "<{$key}{$attr}>"; + + if ((is_array($val) || is_object($val))) { + $xml .= self::data2Xml((array) $val, $item, $id); + } else { + $xml .= is_numeric($val) ? $val : self::cdata($val); + } + + $xml .= ""; + } + + return $xml; + } + + /** + * Delete invalid characters in XML. + * + * @see https://www.w3.org/TR/2008/REC-xml-20081126/#charsets - XML charset range + * @see http://php.net/manual/en/regexp.reference.escape.php - escape in UTF-8 mode + * + * @param string $xml + * + * @return string + */ + public static function sanitize($xml) + { + return preg_replace('/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]+/u', '', $xml); + } +} \ No newline at end of file diff --git a/src/Utils/Zip.php b/src/Utils/Zip.php new file mode 100644 index 0000000..c4e1f34 --- /dev/null +++ b/src/Utils/Zip.php @@ -0,0 +1,197 @@ +zipFile = new ZipFile(); + } + + public function fixFilesChineseName($sourcePath) + { + $encoding_list = [ + "ASCII", 'UTF-8', "GB2312", "GBK", 'BIG5' + ]; + + try { + $zip = new \ZipArchive(); + $openResult = $zip->open($sourcePath); + if ($openResult !== true) { + throw new \Exception('Cannot Open zip file: ' . $sourcePath); + } + $fileNum = $zip->numFiles; + + $files = []; + for ($i = 0; $i < $fileNum; $i++) { + $statInfo = $zip->statIndex($i, \ZipArchive::FL_ENC_RAW); + + $encode = mb_detect_encoding($statInfo['name'], $encoding_list); + $string = mb_convert_encoding($statInfo['name'], 'UTF-8', $encode); + + $zip->renameIndex($i, $string); + $newStatInfo = $zip->statIndex($i, \ZipArchive::FL_ENC_RAW); + + $files[] = $newStatInfo; + } + } catch (\Throwable $e) { + throw $e; + } finally { + $zip->close(); + } + + return $files; + } + + public function pack(string $sourcePath, ?string $filename = null, ?string $targetPath = null): ?string + { + if (!File::exists($sourcePath)) { + throw new \RuntimeException("Directory to be decompressed does not exist {$sourcePath}"); + } + + $filename = $filename ?? File::name($sourcePath); + $targetPath = $targetPath ?? File::dirname($sourcePath); + $targetPath = $targetPath ?: File::dirname($sourcePath); + + File::ensureDirectoryExists($targetPath); + + $zipFilename = str_contains($filename, '.zip') ? $filename : $filename . '.zip'; + $zipFilepath = "{$targetPath}/{$zipFilename}"; + + while (File::exists($zipFilepath)) { + $basename = File::name($zipFilepath); + $zipCount = count(File::glob("{$targetPath}/{$basename}*.zip")); + + $zipFilename = $basename . $zipCount . '.zip'; + $zipFilepath = "{$targetPath}/{$zipFilename}"; + } + + // Compression + $this->zipFile->addDirRecursive($sourcePath, $filename); + $this->zipFile->saveAsFile($zipFilepath); + + return $targetPath; + } + + public function unpack(string $sourcePath, ?string $targetPath = null): ?string + { + try { + // Detects the file type and unpacks only zip files + $mimeType = File::mimeType($sourcePath); + } catch (\Throwable $e) { + \info("Unzip failed {$e->getMessage()}"); + throw new \RuntimeException("Unzip failed {$e->getMessage()}"); + } + + // Get file types (only directories and zip files are processed) + $type = match (true) { + default => null, + str_contains($mimeType, 'directory') => 1, + str_contains($mimeType, 'zip') => 2, + }; + + if (is_null($type)) { + \info("unsupport mime type $mimeType"); + throw new \RuntimeException("unsupport mime type $mimeType"); + } + + // Make sure the unzip destination directory exists + $targetPath = $targetPath ?? config('plugins.paths.unzip_target_path'); + if (empty($targetPath)) { + \info('targetPath cannot be empty'); + throw new \RuntimeException('targetPath cannot be empty'); + } + + if (!is_dir($targetPath)) { + File::ensureDirectoryExists($targetPath); + } + + if ($targetPath == $sourcePath) { + return $targetPath; + } + + // Empty the directory to avoid leaving files of other plugins + File::cleanDirectory($targetPath); + + // Directory without unzip operation, copy the original directory to the temporary directory + if ($type == 1) { + File::copyDirectory($sourcePath, $targetPath); + + // Make sure the directory decompression level is the top level of the plugin directory + $targetPath = $this->ensureDoesntHaveSubdir($targetPath); + + return $targetPath; + } + + if ($type == 2) { + $this->fixFilesChineseName($sourcePath); + + // unzip + $zipFile = $this->zipFile->openFile($sourcePath); + $zipFile->extractTo($targetPath); + + // Make sure the directory decompression level is the top level of the plugin directory + $targetPath = $this->ensureDoesntHaveSubdir($targetPath); + + // Decompress to the specified directory + return $targetPath; + } + + return null; + } + + public function ensureDoesntHaveSubdir(string $targetPath): string + { + $targetPath = $targetPath ?? config('plugins.paths.unzip_target_path'); + + $pattern = sprintf('%s/*', rtrim($targetPath, DIRECTORY_SEPARATOR)); + + $files = []; + foreach (File::glob($pattern) as $file) { + if (str_contains($file, '__MACOSX')) { + continue; + } + + $files[] = $file; + } + + $fileCount = count($files); + if ($fileCount > 1) { + throw new \RuntimeException("Cannot handle the zip file, zip file count is: {$fileCount}, extract path is: {$targetPath}"); + } + + $tmpDir = $targetPath . '-subdir'; + File::ensureDirectoryExists($tmpDir); + + $firstEntryname = File::basename(current($files)); + + $path = $targetPath . "/{$firstEntryname}"; + $tmpTargetPath = $tmpDir . "/{$firstEntryname}"; + $parentDir = dirname($tmpTargetPath); + File::ensureDirectoryExists($parentDir); + + if (is_dir($path)) { + File::copyDirectory($path, $tmpDir); + } else { + File::copyDirectory(dirname($path), $parentDir); + } + + File::cleanDirectory($targetPath); + File::copyDirectory($tmpDir, $targetPath); + File::deleteDirectory($tmpDir); + + return $targetPath; + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..b3ccaf6 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,200 @@ + CURL_HTTP_VERSION_1_1, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36', + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_TIMEOUT => 30, + CURLOPT_RETURNTRANSFER => true, // 要求结果为字符串且输出到屏幕上 + ]; + + // 设置代理 + if (!empty($config['proxy']['host']) && !empty($config['proxy']['port'])) { + $options[CURLOPT_PROXY] = $config['proxy']['host']; + $options[CURLOPT_PROXYPORT] = $config['proxy']['port']; + } + + // 设置证书 + if (!empty($config['use_cert']) && $config['use_cert'] === true) { + if (!empty($config['use_cert']['ssl_cert_path']) && !empty($config['use_cert']['ssl_key_path'])) { + //使用证书:cert 与 key 分别属于两个 .pem 文件 + $options[CURLOPT_SSLCERTTYPE] = 'PEM'; + $options[CURLOPT_SSLCERT] = $config['use_cert']['ssl_cert_path']; + $options[CURLOPT_SSLKEYTYPE] = 'PEM'; + $options[CURLOPT_SSLKEY] = $config['use_cert']['ssl_key_path']; + } + } + + if ($method === 'JSON' && !in_array('application/json', $headers)) { + $headers['Content-Type'] = 'application/json'; + } + + // 设置header + if (!empty($headers)) { + // curl 请求的 headers 必须处理成 array("xxx: xxx", "xxx: xxx") 格式的数组 + $options[CURLOPT_HTTPHEADER] = array_map(function ($key, $value) { + return $key . ': '. $value; + }, array_keys($headers), $headers); + } + + if (stripos($url, "https://") !== false) { + $options[CURLOPT_SSLVERSION] = CURL_SSLVERSION_TLSv1; + $options[CURLOPT_SSL_VERIFYPEER] = false; // 对认证证书来源的检 + $options[CURLOPT_SSL_VERIFYHOST] = false; // 从证书中检查SSL加密算法是否存 + } else { + $options[CURLOPT_SSL_VERIFYPEER] = true; + $options[CURLOPT_SSL_VERIFYHOST] = 2; //严格校验 + } + + if ($method === 'POST' || $method == 'JSON') { + if (in_array('application/json', $headers)) { + $params = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_NUMERIC_CHECK); + + $options[CURLOPT_CUSTOMREQUEST] = 'POST'; + } else { + $options[CURLOPT_POST] = true; + } + + $options[CURLOPT_URL] = $url; + $options[CURLOPT_POSTFIELDS] = $params; + } else { + if ($params) { + if (is_array($params)) { + $params = http_build_query($params, '', '&', PHP_QUERY_RFC3986); + } + + $options[CURLOPT_URL] = $url . '?' . $params; + } else { + $options[CURLOPT_URL] = $url; + } + } + + curl_setopt_array($ch, $options); + + $response = curl_exec($ch); + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $httpInfo = array_merge($httpInfo, curl_getinfo($ch)); + + if ($response === FALSE) { + //echo "cURL Error: " . curl_error($ch); + return [ + 'code' => 1, + 'message' => "cURL Error: " . curl_error($ch), + 'data' => [ + 'httpCode' => $httpCode, + 'httpInfo' => $httpInfo, + 'response' => $response, + ], + ]; + } + + curl_close($ch); + + if (stripos($response, 'Sfdump') !== false || stripos($response, 'exception') !== false) { + mdump('server debug:'); + mdd($response); + } + + return [ + 'code' => 0, + 'message' => 'success', + 'data' => [ + 'httpCode' => $httpCode, + 'httpInfo' => $httpInfo, + 'response' => $response, + ], + ]; + } +} + +if (!function_exists('p')) { + /** + * 调试方法 + * + * @param array $data [description] + */ + function p($data, $die = 1) + { + echo "
";
+        print_r($data);
+        echo "
"; + if ($die) die; + } +} + +if (!function_exists('mdump')) { + /** + * 调试方法 + * + * @param array $data [description] + */ + function mdump() + { + foreach (func_get_args() as $item) { + p($item, 0); + } + } +} + +if (!function_exists('mdd')) { + /** + * 调试方法 + * + * @param array $data [description] + */ + function mdd() + { + mdump(...func_get_args()); + die; + } +} diff --git a/src/scripts/install.php b/src/scripts/install.php new file mode 100644 index 0000000..80bdfa7 --- /dev/null +++ b/src/scripts/install.php @@ -0,0 +1,6 @@ +#!/usr/bin/env php +