feat(support): 初始化项目基础结构和核心功能

- 添加了 Laravel 项目配置文件,包括 IDE 字典、模块定义和版本控制设置
- 配置了 PHP 开发环境,包含依赖路径、代码质量工具和测试框架设置
- 实现了模型转换特性 (ModelCastSetter),支持对象到数组的自动序列化
- 创建了通用数组处理类 (Arrayable),提供对象与数组间的便捷转换方法
- 开发了 RSA 加密解密工具类,支持证书解析、密钥验证和数据安全操作
- 编写了单元测试和功能测试示例,确保代码质量和功能正确性
- 设置了 Pest 测试框架基础配置,便于后续扩展测试用例
- 添加了 Carbon 和 Paratest 等常用工具的二进制代理脚本
This commit is contained in:
yeyixianyang 2025-12-07 21:02:32 +08:00
commit bc42abeae8
13 changed files with 4912 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

41
composer.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "jltx/support",
"description": "项目辅助功能",
"minimum-stability": "stable",
"type": "library",
"autoload": {
"psr-4": {
"Jltx\\Support\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Jltx\\Support\\Tests\\": "tests/"
}
},
"authors": [
{
"name": "yeyixianyang",
"email": "yeyixianyang@163.com"
}
],
"require": {
"php": "^8.4",
"illuminate/support": "^12.41",
"illuminate/database": "^12.41",
"ext-openssl": "*"
},
"require-dev": {
"pestphp/pest": "^4.1",
"laravel/pint": "^1.26"
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"scripts": {
"format": "./vendor/bin/pint",
"test": "./vendor/bin/pest"
}
}

4219
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
phpunit.xml Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
<directory>src</directory>
</include>
</source>
</phpunit>

View File

@ -0,0 +1,18 @@
<?php
namespace Jltx\Support\Concerns;
use Illuminate\Database\Eloquent\Model;
use Jltx\Support\Helpers\Arrayable;
trait ModelCastSetter
{
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [
$key => $value ? json_encode(
$value instanceof Arrayable ? $value->toArray() : (array) $value,
) : null,
];
}
}

122
src/Helpers/Arrayable.php Normal file
View File

@ -0,0 +1,122 @@
<?php
namespace Jltx\Support\Helpers;
use ArrayAccess;
use JsonSerializable;
abstract class Arrayable implements \Illuminate\Contracts\Support\Arrayable, ArrayAccess, JsonSerializable
{
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* 默认的将属性转为数组
*/
public function toArray(): array
{
$data = [];
foreach (get_object_vars($this) as $property => $value) {
if (is_object($value) && method_exists($value, 'toArray')) {
$data[$property] = $value->toArray();
} elseif (is_array($value)) {
$data[$property] = array_map(function ($item) {
return is_object($item) && method_exists($item, 'toArray')
? $item->toArray()
: $item;
}, $value);
} else {
$data[$property] = $value;
}
}
return $data;
}
/**
* 是否存在属性
*/
public function offsetExists(mixed $offset): bool
{
return property_exists($this, $offset);
}
/**
* 获取属性值
*/
public function offsetGet(mixed $offset): mixed
{
return $this->$offset ?? null;
}
/**
* 设置属性值
*/
public function offsetSet(mixed $offset, mixed $value): void
{
$this->$offset = $value;
}
/**
* 删除属性值
*/
public function offsetUnset(mixed $offset): void
{
if (property_exists($this, $offset)) {
unset($this->$offset);
}
}
/**
* 仅获取指定属性
*/
public function only(array $keys): array
{
$result = [];
foreach (get_object_vars($this) as $property => $value) {
if (in_array($property, $keys, true)) {
$result[$property] = $this->normalizeValue($value);
}
}
return $result;
}
/**
* 统一格式化数组值或对象值(用于 only / except
*/
protected function normalizeValue(mixed $value): mixed
{
if (is_object($value) && method_exists($value, 'toArray')) {
return $value->toArray();
}
if (is_array($value)) {
return array_map(fn ($item) => is_object($item) && method_exists($item, 'toArray')
? $item->toArray()
: $item, $value);
}
return $value;
}
/**
* 排除指定属性
*/
public function except(array $keys): array
{
$result = [];
foreach (get_object_vars($this) as $property => $value) {
if (! in_array($property, $keys, true)) {
$result[$property] = $this->normalizeValue($value);
}
}
return $result;
}
}

272
src/Security/RSA.php Normal file
View File

@ -0,0 +1,272 @@
<?php
namespace Jltx\Support\Security;
use OpenSSLAsymmetricKey;
use OpenSSLCertificate;
use RuntimeException;
readonly class RSA
{
private const string PKCS1_PRIVATE_KEY_PREFIX = '-----BEGIN RSA PRIVATE KEY-----';
private const string PKCS1_PRIVATE_KEY_SUFFIX = '-----END RSA PRIVATE KEY-----';
private const string PKCS8_PRIVATE_KEY_PREFIX = '-----BEGIN PRIVATE KEY-----';
private const string PKCS8_PRIVATE_KEY_SUFFIX = '-----END PRIVATE KEY-----';
private const string PUBLIC_KEY_PREFIX = '-----BEGIN PUBLIC KEY-----';
private const string PUBLIC_KEY_SUFFIX = '-----END PUBLIC KEY-----';
private const string CERT_PREFIX = '-----BEGIN CERTIFICATE-----';
private const string CERT_SUFFIX = '-----END CERTIFICATE-----';
private const string PKCS8 = 'PKCS#8';
private const string PKCS1 = 'PKCS#1';
public static function getPublicKeyStringFromCert(string $certContent): string
{
$publicKey = self::getPublicKeyFromCert($certContent);
$detail = openssl_pkey_get_details($publicKey);
if (! $detail) {
throw new RuntimeException('获取公钥详情失败');
}
return $detail['key'];
}
public static function getPublicKeyFromCert(string $certContent): OpenSSLAsymmetricKey
{
$cert = self::getCert($certContent);
$publicKey = openssl_get_publickey($cert);
if (! $publicKey) {
throw new RuntimeException('从证书中读取公钥失败');
}
return $publicKey;
}
public static function getCert(string $certContent): OpenSSLCertificate
{
$cert = openssl_x509_read($certContent);
if (! $cert) {
throw new RuntimeException('证书加载失败');
}
return $cert;
}
public static function getCertSn(string $certContent): string
{
$cert = self::getCert($certContent);
$info = openssl_x509_parse($cert);
if (! $info) {
throw new RuntimeException('解析证书失败');
}
return $info['serialNumberHex'];
}
public static function encrypt(
string $plaintext,
OpenSSLAsymmetricKey $publicKey,
int $padding = OPENSSL_PKCS1_OAEP_PADDING,
): string {
if (! openssl_public_encrypt($plaintext, $encrypted, $publicKey, $padding)) {
throw new RuntimeException('加密失败');
}
return base64_encode($encrypted);
}
public static function verify(
string $message,
string $signature,
OpenSSLAsymmetricKey $publicKey,
int|string $algo,
): bool {
return (bool) openssl_verify($message, base64_decode($signature), $publicKey, $algo);
}
public static function sign(string $message, OpenSSLAsymmetricKey $privateKey, int|string $algo): string
{
if (! openssl_sign($message, $signature, $privateKey, $algo)) {
throw new RuntimeException('签名失败');
}
return base64_encode($signature);
}
public static function decrypt(
string $ciphertext,
OpenSSLAsymmetricKey $privateKey,
int $padding = OPENSSL_PKCS1_OAEP_PADDING,
): string {
if (! openssl_private_decrypt(base64_decode($ciphertext), $decrypted, $privateKey, $padding)) {
throw new RuntimeException('解密失败');
}
return $decrypted;
}
public static function certificateFormat(string $certContent): string
{
$certContent = self::removePrefixAndSuffix($certContent);
return self::CERT_PREFIX."\n".chunk_split($certContent, 64).self::CERT_SUFFIX;
}
private static function removePrefixAndSuffix(string $content): string
{
// 去掉可能的PEM头尾和换行符
$content = preg_replace('/-----(BEGIN|END)[\w\s]+-----/', '', $content);
$content = preg_replace('/\s+/', '', $content);
// 删除 PEM 头尾和换行
$content = trim($content);
return str_replace(["\n", "\r", ' '], '', $content);
}
public static function generateKeyPair(): array
{
$config = [
'digest_alg' => 'sha256',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$res = openssl_pkey_new($config);
if (! $res) {
throw new RuntimeException('Failed to create a new RSA key pair.');
}
// 导出私钥
$privateKey = '';
if (! openssl_pkey_export($res, $privateKey)) {
throw new RuntimeException('Failed to export the private key.');
}
// 获取公钥详情
$details = openssl_pkey_get_details($res);
if (! isset($details['key'])) {
throw new RuntimeException('Failed to retrieve the public key.');
}
// 返回公钥和私钥
return [
'private_key' => $privateKey,
'public_key' => $details['key'],
];
}
public static function validateCertificateContent(string $certContent): bool
{
try {
$cert = self::getCert($certContent);
return (bool) $cert;
} catch (RuntimeException) {
return false;
}
}
public static function validatePublicKeyContent(string $publicKeyContent): bool
{
try {
$formattedPublicKey = self::publicKeyFormat($publicKeyContent);
$publicKey = self::getPublicKey($formattedPublicKey);
return (bool) $publicKey;
} catch (RuntimeException) {
return false;
}
}
public static function publicKeyFormat(string $publicKeyContent): string
{
$publicKeyContent = self::removePrefixAndSuffix($publicKeyContent);
return self::PUBLIC_KEY_PREFIX."\n".chunk_split($publicKeyContent, 64).self::PUBLIC_KEY_SUFFIX;
}
public static function getPublicKey(string $publicKeyContent): OpenSSLAsymmetricKey
{
$publicKey = openssl_pkey_get_public($publicKeyContent);
if (! $publicKey) {
throw new RuntimeException('非法公钥');
}
return $publicKey;
}
public static function validatePrivateKeyContent(string $privateKeyContent): bool
{
try {
$formattedPrivateKey = self::privateKeyFormat($privateKeyContent);
$privateKey = self::getPrivateKey($formattedPrivateKey);
return (bool) $privateKey;
} catch (RuntimeException) {
return false;
}
}
public static function privateKeyFormat(string $privateKeyContent): string
{
$pkcs = self::guessPrivateKeyFormat($privateKeyContent);
if (is_null($pkcs)) {
throw new RuntimeException('不能识别的PKCS格式');
}
$prefix = $pkcs == self::PKCS8 ? self::PKCS8_PRIVATE_KEY_PREFIX : self::PKCS1_PRIVATE_KEY_PREFIX;
$suffix = $pkcs == self::PKCS8 ? self::PKCS8_PRIVATE_KEY_SUFFIX : self::PKCS1_PRIVATE_KEY_SUFFIX;
$privateKeyContent = self::removePrefixAndSuffix($privateKeyContent);
return $prefix."\n".chunk_split($privateKeyContent, 64).$suffix;
}
private static function guessPrivateKeyFormat(string $key): ?string
{
// 去掉可能的PEM头尾和换行符
$key = self::removePrefixAndSuffix($key);
// base64 decode
$decoded = base64_decode($key, true);
if ($decoded === false) {
return null;
}
// 判断是否包含 PKCS#8 的 OID 结构
// OID for rsaEncryption is 1.2.840.113549.1.1.1
$pkcs8OidSequence = hex2bin('300d06092a864886f70d0101010500');
if (str_contains($decoded, $pkcs8OidSequence)) {
return self::PKCS8;
}
// 如果第一个字节是 0x30后面是 ASN.1 sequence for integer
if (str_starts_with($decoded, "\x30")) {
// 简单推测 PKCS#1
return self::PKCS1;
}
return null;
}
public static function getPrivateKey(string $privateKeyContent): OpenSSLAsymmetricKey
{
$privateKey = openssl_pkey_get_private($privateKeyContent);
if (! $privateKey) {
throw new RuntimeException('非法私钥');
}
return $privateKey;
}
}

View File

@ -0,0 +1,5 @@
<?php
test('example', function () {
expect(true)->toBeTrue();
});

47
tests/Pest.php Normal file
View File

@ -0,0 +1,47 @@
<?php
use Jltx\Support\Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
pest()->extend(TestCase::class)->in('Unit', 'Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function something()
{
// ..
}

10
tests/TestCase.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace Jltx\Support\Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

View File

@ -0,0 +1,5 @@
<?php
test('example', function () {
expect(true)->toBeTrue();
});

124
tests/Unit/RsaTest.php Normal file
View File

@ -0,0 +1,124 @@
<?php
namespace Jltx\Support\Tests\Unit;
use Jltx\Support\Security\RSA;
use RuntimeException;
beforeEach(function () {
// 生成测试用的密钥对
$this->keyPair = RSA::generateKeyPair();
$this->privateKeyContent = $this->keyPair['private_key'];
$this->publicKeyContent = $this->keyPair['public_key'];
// 格式化密钥
$this->formattedPrivateKey = RSA::privateKeyFormat($this->privateKeyContent);
$this->formattedPublicKey = RSA::publicKeyFormat($this->publicKeyContent);
// 获取密钥资源
$this->privateKey = RSA::getPrivateKey($this->formattedPrivateKey);
$this->publicKey = RSA::getPublicKey($this->formattedPublicKey);
});
it('can generate a key pair', function () {
$keyPair = RSA::generateKeyPair();
expect($keyPair)->toBeArray()
->and($keyPair)->toHaveKey('private_key')
->and($keyPair)->toHaveKey('public_key')
->and($keyPair['private_key'])->toBeString()
->and($keyPair['public_key'])->toBeString();
});
it('can format private key', function () {
$formatted = RSA::privateKeyFormat($this->privateKeyContent);
expect($formatted)->toContain('PRIVATE KEY');
});
it('can format public key', function () {
$formatted = RSA::publicKeyFormat($this->publicKeyContent);
expect($formatted)->toContain('PUBLIC KEY');
});
it('can get private key resource', function () {
$privateKey = RSA::getPrivateKey($this->formattedPrivateKey);
expect($privateKey)->toBeObject();
});
it('can get public key resource', function () {
$publicKey = RSA::getPublicKey($this->formattedPublicKey);
expect($publicKey)->toBeObject();
});
it('can encrypt and decrypt data', function () {
$plaintext = 'Hello, World!';
$encrypted = RSA::encrypt($plaintext, $this->publicKey);
$decrypted = RSA::decrypt($encrypted, $this->privateKey);
expect($encrypted)->toBeString()
->and($decrypted)->toBeString()
->and($decrypted)->toEqual($plaintext);
});
it('can sign and verify data', function () {
$message = 'Hello, World!';
$algorithm = OPENSSL_ALGO_SHA256;
$signature = RSA::sign($message, $this->privateKey, $algorithm);
$isValid = RSA::verify($message, $signature, $this->publicKey, $algorithm);
expect($signature)->toBeString()
->and($isValid)->toBeTrue();
});
it('returns false for invalid signature', function () {
$message = 'Hello, World!';
$fakeMessage = 'Fake Message';
$algorithm = OPENSSL_ALGO_SHA256;
$signature = RSA::sign($message, $this->privateKey, $algorithm);
$isValid = RSA::verify($fakeMessage, $signature, $this->publicKey, $algorithm);
expect($isValid)->toBeFalse();
});
it('validates private key content', function () {
$isValid = RSA::validatePrivateKeyContent($this->privateKeyContent);
expect($isValid)->toBeTrue();
});
it('validates invalid private key content as false', function () {
$isValid = RSA::validatePrivateKeyContent('invalid private key');
expect($isValid)->toBeFalse();
});
it('validates public key content', function () {
$isValid = RSA::validatePublicKeyContent($this->publicKeyContent);
expect($isValid)->toBeTrue();
});
it('validates invalid public key content as false', function () {
$isValid = RSA::validatePublicKeyContent('invalid public key');
expect($isValid)->toBeFalse();
});
it('throws exception for invalid private key', function () {
$invalidKey = "-----BEGIN PRIVATE KEY-----\ninvalidcontent\n-----END PRIVATE KEY-----";
expect(fn () => RSA::getPrivateKey($invalidKey))->toThrow(RuntimeException::class, '非法私钥');
});
it('throws exception for invalid public key', function () {
$invalidKey = "-----BEGIN PUBLIC KEY-----\ninvalidcontent\n-----END PUBLIC KEY-----";
expect(fn () => RSA::getPublicKey($invalidKey))->toThrow(RuntimeException::class, '非法公钥');
});

View File

@ -0,0 +1,7 @@
<?php
namespace Jltx\Support\Tests\Unit;
test('simple test', function () {
expect(true)->toBeTrue();
});