This commit is contained in:
Aether
2025-09-18 10:46:54 +08:00
commit 0920cef866
62 changed files with 13547 additions and 0 deletions

52
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# Dev Container Dockerfile
#
# @link https://www.hyperf.io
# @document https://hyperf.wiki
# @contact group@hyperf.io
# @license https://github.com/hyperf/hyperf/blob/master/LICENSE
FROM hyperf/hyperf:8.3-alpine-v3.19-swoole
LABEL maintainer="Hyperf Developers <group@hyperf.io>" version="1.0" license="MIT" app.name="Hyperf"
##
# ---------- env settings ----------
##
# --build-arg timezone=Asia/Shanghai
ARG timezone
ENV TIMEZONE=${timezone:-"Asia/Shanghai"} \
APP_ENV=dev \
SCAN_CACHEABLE=(false)
# update
RUN set -ex \
# show php version and extensions
&& php -v \
&& php -m \
&& php --ri swoole \
# ---------- some config ----------
&& cd /etc/php* \
# - config PHP
&& { \
echo "upload_max_filesize=128M"; \
echo "post_max_size=128M"; \
echo "memory_limit=1G"; \
echo "date.timezone=${TIMEZONE}"; \
} | tee conf.d/99_overrides.ini \
# - config timezone
&& ln -sf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime \
&& echo "${TIMEZONE}" > /etc/timezone \
# ---------- clear works ----------
&& rm -rf /var/cache/apk/* /tmp/* /usr/share/man \
&& echo -e "\033[42;37m Build Completed :).\033[0m\n"
WORKDIR /opt/www
# Composer Cache
# COPY ./composer.* /opt/www/
# RUN composer install --no-dev --no-scripts
COPY . /opt/www
RUN composer install && php bin/hyperf.php
EXPOSE 9501

View File

@@ -0,0 +1,7 @@
{
"build": {
"context": "..",
"dockerfile": "./Dockerfile"
},
"forwardPorts": [9501]
}

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
**
!app/
!bin/
!config/
!composer.*

25
.env Normal file
View File

@@ -0,0 +1,25 @@
APP_NAME=hyperf-
APP_ENV=dev
DB_DRIVER=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=hyperf_
DB_USERNAME=hyperf_
DB_PASSWORD=4cfDRXZSksn7npiP
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
DB_PREFIX=_
REDIS_HOST=localhost
REDIS_AUTH=(null)
REDIS_PORT=6379
REDIS_DB=0
JSON_RPC_HOST=0.0.0.0
JSON_RPC_PORT=9610
SWOOLE_WORKER_NUM=1
NACOS_HOST=192.168.28.199
NACOS_PORT=8848
NACOS_NAMESPACE=e42b853c-5195-478b-b5e3-6d49f6a45053

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
APP_NAME=hyperf-demo
APP_ENV=dev
DB_DRIVER=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=hyperf_demo
DB_USERNAME=hyperf_demo
DB_PASSWORD=4cfDRXZSksn7npiP
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
DB_PREFIX=dm_
REDIS_HOST=localhost
REDIS_AUTH=(null)
REDIS_PORT=6379
REDIS_DB=0
JSON_RPC_HOST=0.0.0.0
JSON_RPC_PORT=9610
SWOOLE_WORKER_NUM=1

54
.github/workflows/Dockerfile vendored Normal file
View File

@@ -0,0 +1,54 @@
# Default Dockerfile
#
# @link https://www.hyperf.io
# @document https://hyperf.wiki
# @contact group@hyperf.io
# @license https://github.com/hyperf/hyperf/blob/master/LICENSE
FROM hyperf/hyperf:8.3-alpine-v3.19-swoole
LABEL maintainer="Hyperf Developers <group@hyperf.io>" version="1.0" license="MIT" app.name="Hyperf"
##
# ---------- env settings ----------
##
# --build-arg timezone=Asia/Shanghai
ARG timezone
ENV TIMEZONE=${timezone:-"Asia/Shanghai"} \
APP_ENV=prod \
SCAN_CACHEABLE=(true)
# update
RUN set -ex \
# show php version and extensions
&& php -v \
&& php -m \
&& php --ri swoole \
# ---------- some config ----------
&& cd /etc/php* \
# - config PHP
&& { \
echo "upload_max_filesize=128M"; \
echo "post_max_size=128M"; \
echo "memory_limit=1G"; \
echo "date.timezone=${TIMEZONE}"; \
} | tee conf.d/99_overrides.ini \
# - config timezone
&& ln -sf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime \
&& echo "${TIMEZONE}" > /etc/timezone \
# ---------- clear works ----------
&& rm -rf /var/cache/apk/* /tmp/* /usr/share/man \
&& echo -e "\033[42;37m Build Completed :).\033[0m\n"
WORKDIR /opt/www
# Composer Cache
# COPY ./composer.* /opt/www/
# RUN composer install --no-dev --no-scripts
COPY . /opt/www
RUN print "\n" | composer install -o && php bin/hyperf.php
EXPOSE 9501
ENTRYPOINT ["php", "/opt/www/bin/hyperf.php", "start"]

12
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: Build Docker
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Build
run: cp -rf .github/workflows/Dockerfile . && docker build -t hyperf .

25
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
on:
push:
# Sequence of patterns matched against refs/tags
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Release
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
.buildpath
.settings/
.project
*.patch
.idea/
.git/
runtime/
vendor/
.phpintel/
#.env
.DS_Store
.phpunit*
*.cache
.vscode/
/phpstan.neon
/phpunit.xml

57
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,57 @@
# usermod -aG docker gitlab-runner
stages:
- build
- deploy
variables:
PROJECT_NAME: hyperf
REGISTRY_URL: registry-docker.org
build_test_docker:
stage: build
before_script:
# - git submodule sync --recursive
# - git submodule update --init --recursive
script:
- docker build . -t $PROJECT_NAME
- docker tag $PROJECT_NAME $REGISTRY_URL/$PROJECT_NAME:test
- docker push $REGISTRY_URL/$PROJECT_NAME:test
only:
- test
tags:
- builder
deploy_test_docker:
stage: deploy
script:
- docker stack deploy -c deploy.test.yml --with-registry-auth $PROJECT_NAME
only:
- test
tags:
- test
build_docker:
stage: build
before_script:
# - git submodule sync --recursive
# - git submodule update --init --recursive
script:
- docker build . -t $PROJECT_NAME
- docker tag $PROJECT_NAME $REGISTRY_URL/$PROJECT_NAME:$CI_COMMIT_REF_NAME
- docker tag $PROJECT_NAME $REGISTRY_URL/$PROJECT_NAME:latest
- docker push $REGISTRY_URL/$PROJECT_NAME:$CI_COMMIT_REF_NAME
- docker push $REGISTRY_URL/$PROJECT_NAME:latest
only:
- tags
tags:
- builder
deploy_docker:
stage: deploy
script:
- echo SUCCESS
only:
- tags
tags:
- builder

92
.php-cs-fixer.php Executable file
View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
return (new Config())
->setRiskyAllowed(true)
->setRules([
'@PSR2' => true,
'@Symfony' => true,
'@DoctrineAnnotation' => true,
'@PhpCsFixer' => true,
'header_comment' => [
'comment_type' => 'PHPDoc',
'header' => '', // $header,
'separate' => 'none',
'location' => 'after_declare_strict',
],
'array_syntax' => [
'syntax' => 'short',
],
'list_syntax' => [
'syntax' => 'short',
],
'concat_space' => [
'spacing' => 'one',
],
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => true,
'import_functions' => null,
],
'blank_line_before_statement' => [
'statements' => [
'declare',
],
],
'general_phpdoc_annotation_remove' => [
'annotations' => [
'author',
],
],
'ordered_imports' => [
'imports_order' => [
'class', 'function', 'const',
],
'sort_algorithm' => 'alpha',
],
'single_line_comment_style' => [
'comment_types' => [
],
],
'yoda_style' => [
'always_move_variable' => false,
'equal' => false,
'identical' => false,
],
'phpdoc_align' => [
'align' => 'left',
],
'multiline_whitespace_before_semicolons' => [
'strategy' => 'no_multi_line',
],
'constant_case' => [
'case' => 'lower',
],
'class_attributes_separation' => true,
'combine_consecutive_unsets' => true,
'declare_strict_types' => true,
'linebreak_after_opening_tag' => true,
'lowercase_static_reference' => true,
'no_useless_else' => true,
'no_unused_imports' => true,
'not_operator_with_successor_space' => true,
'not_operator_with_space' => false,
'ordered_class_elements' => true,
'php_unit_strict' => false,
'phpdoc_separation' => false,
'single_quote' => true,
'standardize_not_equals' => true,
'multiline_comment_opening_closing' => true,
'single_line_empty_body' => false,
])
->setFinder(
Finder::create()
->exclude('public')
->exclude('runtime')
->exclude('vendor')
->in(__DIR__)
)
->setUsingCache(false);

12
.phpstorm.meta.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
namespace PHPSTORM_META {
// Reflect
override(\Psr\Container\ContainerInterface::get(0), map(['' => '@']));
override(\Hyperf\Context\Context::get(0), map(['' => '@']));
override(\make(0), map(['' => '@']));
override(\di(0), map(['' => '@']));
override(\Hyperf\Support\make(0), map(['' => '@']));
override(\Hyperf\Support\optional(0), type(0));
override(\Hyperf\Tappable\tap(0), type(0));
}

54
Dockerfile Normal file
View File

@@ -0,0 +1,54 @@
# Default Dockerfile
#
# @link https://www.hyperf.io
# @document https://hyperf.wiki
# @contact group@hyperf.io
# @license https://github.com/hyperf/hyperf/blob/master/LICENSE
FROM hyperf/hyperf:8.3-alpine-v3.19-swoole
LABEL maintainer="Hyperf Developers <group@hyperf.io>" version="1.0" license="MIT" app.name="Hyperf"
##
# ---------- env settings ----------
##
# --build-arg timezone=Asia/Shanghai
ARG timezone
ENV TIMEZONE=${timezone:-"Asia/Shanghai"} \
APP_ENV=prod \
SCAN_CACHEABLE=(true)
# update
RUN set -ex \
# show php version and extensions
&& php -v \
&& php -m \
&& php --ri swoole \
# ---------- some config ----------
&& cd /etc/php* \
# - config PHP
&& { \
echo "upload_max_filesize=128M"; \
echo "post_max_size=128M"; \
echo "memory_limit=1G"; \
echo "date.timezone=${TIMEZONE}"; \
} | tee conf.d/99_overrides.ini \
# - config timezone
&& ln -sf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime \
&& echo "${TIMEZONE}" > /etc/timezone \
# ---------- clear works ----------
&& rm -rf /var/cache/apk/* /tmp/* /usr/share/man \
&& echo -e "\033[42;37m Build Completed :).\033[0m\n"
WORKDIR /opt/www
# Composer Cache
# COPY ./composer.* /opt/www/
# RUN composer install --no-dev --no-scripts
COPY . /opt/www
RUN composer install --no-dev -o && php bin/hyperf.php
EXPOSE 9501
ENTRYPOINT ["php", "/opt/www/bin/hyperf.php", "start"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Hyperf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

0
README.md Normal file
View File

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace App\Controller;
use Hyperf\Di\Annotation\Inject;
use Hyperf\HttpServer\Contract\RequestInterface;
use Hyperf\HttpServer\Contract\ResponseInterface;
use Psr\Container\ContainerInterface;
abstract class AbstractController
{
#[Inject]
protected ContainerInterface $container;
#[Inject]
protected RequestInterface $request;
#[Inject]
protected ResponseInterface $response;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace App\Controller;
class IndexController extends AbstractController
{
public function index()
{
$user = $this->request->input('user', 'Hyperf');
$method = $this->request->getMethod();
return [
'method' => $method,
'message' => "Hello {$user}.",
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace App\Exception\Handler;
use Hyperf\Contract\StdoutLoggerInterface;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Psr\Http\Message\ResponseInterface;
use Throwable;
class AppExceptionHandler extends ExceptionHandler
{
public function __construct(protected StdoutLoggerInterface $logger)
{
}
public function handle(Throwable $throwable, ResponseInterface $response)
{
$this->logger->error(sprintf('%s[%s] in %s', $throwable->getMessage(), $throwable->getLine(), $throwable->getFile()));
$this->logger->error($throwable->getTraceAsString());
return $response->withHeader('Server', 'Hyperf')->withStatus(500)->withBody(new SwooleStream('Internal Server Error.'));
}
public function isValid(Throwable $throwable): bool
{
return true;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace App\Listener;
use Hyperf\Collection\Arr;
use Hyperf\Database\Events\QueryExecuted;
use Hyperf\Event\Annotation\Listener;
use Hyperf\Event\Contract\ListenerInterface;
use Hyperf\Logger\LoggerFactory;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
#[Listener]
class DbQueryExecutedListener implements ListenerInterface
{
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(ContainerInterface $container)
{
$this->logger = $container->get(LoggerFactory::class)->get('sql');
}
public function listen(): array
{
return [
QueryExecuted::class,
];
}
/**
* @param QueryExecuted $event
*/
public function process(object $event): void
{
if ($event instanceof QueryExecuted) {
$sql = $event->sql;
if (! Arr::isAssoc($event->bindings)) {
$position = 0;
foreach ($event->bindings as $value) {
$position = strpos($sql, '?', $position);
if ($position === false) {
break;
}
$value = "'{$value}'";
$sql = substr_replace($sql, $value, $position, 1);
$position += strlen($value);
}
}
$this->logger->info(sprintf('[%s] %s', $event->time, $sql));
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace App\Listener;
use Hyperf\Command\Event\AfterExecute;
use Hyperf\Coordinator\Constants;
use Hyperf\Coordinator\CoordinatorManager;
use Hyperf\Event\Annotation\Listener;
use Hyperf\Event\Contract\ListenerInterface;
#[Listener]
class ResumeExitCoordinatorListener implements ListenerInterface
{
public function listen(): array
{
return [
AfterExecute::class,
];
}
public function process(object $event): void
{
CoordinatorManager::until(Constants::WORKER_EXIT)->resume();
}
}

31
bin/hyperf.php Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env php
<?php
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
ini_set('display_errors', 'on');
ini_set('display_startup_errors', 'on');
ini_set('memory_limit', '1G');
error_reporting(E_ALL);
! defined('BASE_PATH') && define('BASE_PATH', dirname(__DIR__, 1));
require BASE_PATH . '/vendor/autoload.php';
! defined('SWOOLE_HOOK_FLAGS') && define('SWOOLE_HOOK_FLAGS', Hyperf\Engine\DefaultOption::hookFlags());
// Self-called anonymous function that creates its own scope and keep the global namespace clean.
(function () {
Hyperf\Di\ClassLoader::init();
/** @var Psr\Container\ContainerInterface $container */
$container = require BASE_PATH . '/config/container.php';
$application = $container->get(Hyperf\Contract\ApplicationInterface::class);
$application->run();
})();

88
composer.json Executable file
View File

@@ -0,0 +1,88 @@
{
"name": "hyperf/hyperf-skeleton",
"type": "project",
"keywords": [
"php",
"swoole",
"framework",
"hyperf",
"microservice",
"middleware"
],
"description": "A coroutine framework that focuses on hyperspeed and flexible, specifically use for build microservices and middlewares.",
"license": "Apache-2.0",
"require": {
"php": ">=8.1",
"hyperf/cache": "~3.1.0",
"hyperf/command": "~3.1.0",
"hyperf/config": "~3.1.0",
"hyperf/database": "~3.1.0",
"hyperf/db-connection": "~3.1.0",
"hyperf/engine": "^2.10",
"hyperf/framework": "~3.1.0",
"hyperf/guzzle": "~3.1.0",
"hyperf/http-server": "~3.1.0",
"hyperf/json-rpc": "~3.1.0",
"hyperf/logger": "~3.1.0",
"hyperf/memory": "~3.1.0",
"hyperf/model-cache": "^3.1",
"hyperf/paginator": "^3.1",
"hyperf/process": "~3.1.0",
"hyperf/redis": "~3.1.0",
"hyperf/rpc": "~3.1.0",
"hyperf/rpc-client": "~3.1.0",
"hyperf/rpc-server": "~3.1.0",
"hyperf/service-governance-nacos": "^3.1",
"hyperf/validation": "^3.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"hyperf/devtool": "~3.1.0",
"hyperf/testing": "~3.1.0",
"hyperf/watcher": "^3.1",
"mockery/mockery": "^1.0",
"phpstan/phpstan": "^1.0",
"swoole/ide-helper": "^5.0"
},
"suggest": {
"ext-openssl": "Required to use HTTPS.",
"ext-json": "Required to use JSON.",
"ext-pdo": "Required to use MySQL Client.",
"ext-pdo_mysql": "Required to use MySQL Client.",
"ext-redis": "Required to use Redis Client."
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Aether\\": "extend/Aether/PHP/Hyperf"
},
"files": []
},
"autoload-dev": {
"psr-4": {
"HyperfTest\\": "./test/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"optimize-autoloader": true,
"sort-packages": true
},
"extra": [],
"scripts": {
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-autoload-dump": [
"rm -rf runtime/container"
],
"test": "co-phpunit --prepend test/bootstrap.php --colors=always",
"cs-fix": "php-cs-fixer fix $1",
"analyse": "phpstan analyse --memory-limit 300M",
"start": [
"Composer\\Config::disableProcessTimeout",
"php ./bin/hyperf.php start"
]
}
}

10449
composer.lock generated Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
'scan' => [
'paths' => [
BASE_PATH . '/app',
],
'ignore_annotations' => [
'mixin',
],
],
];

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
];

19
config/autoload/cache.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
'default' => [
'driver' => Hyperf\Cache\Driver\RedisDriver::class,
'packer' => Hyperf\Codec\Packer\PhpSerializerPacker::class,
'prefix' => 'c:',
'skip_cache_results' => [],
],
];

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
];

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
use function Hyperf\Support\env;
return [
'default' => [
'driver' => env('DB_DRIVER', 'mysql'),
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'hyperf'),
'port' => env('DB_PORT', 3306),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'collation' => env('DB_COLLATION', 'utf8_unicode_ci'),
'prefix' => env('DB_PREFIX', ''),
'pool' => [
'min_connections' => 1,
'max_connections' => 10,
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'max_idle_time' => (float) env('DB_MAX_IDLE_TIME', 60),
],
'commands' => [
'gen:model' => [
'path' => 'app/Model',
'force_casts' => true,
'inheritance' => 'Model',
],
],
],
];

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
];

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
'generator' => [
'amqp' => [
'consumer' => [
'namespace' => 'App\\Amqp\\Consumer',
],
'producer' => [
'namespace' => 'App\\Amqp\\Producer',
],
],
'aspect' => [
'namespace' => 'App\\Aspect',
],
'command' => [
'namespace' => 'App\\Command',
],
'controller' => [
'namespace' => 'App\\Controller',
],
'job' => [
'namespace' => 'App\\Job',
],
'listener' => [
'namespace' => 'App\\Listener',
],
'middleware' => [
'namespace' => 'App\\Middleware',
],
'Process' => [
'namespace' => 'App\\Processes',
],
],
];

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
'handler' => [
'http' => [
Hyperf\HttpServer\Exception\Handler\HttpExceptionHandler::class,
App\Exception\Handler\AppExceptionHandler::class,
],
'jsonrpc-http' => [
Aether\Exception\AppExceptionHandler::class,
],
],
];

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
Hyperf\ExceptionHandler\Listener\ErrorExceptionHandler::class,
Hyperf\Command\Listener\FailToHandleListener::class,
];

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
'default' => [
'handler' => [
'class' => Monolog\Handler\StreamHandler::class,
'constructor' => [
'stream' => BASE_PATH . '/runtime/logs/hyperf.log',
'level' => Monolog\Logger::DEBUG,
],
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
'constructor' => [
'format' => null,
'dateFormat' => 'Y-m-d H:i:s',
'allowInlineLineBreaks' => true,
],
],
],
];

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
'http' => [
],
];

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
return [
];

29
config/autoload/redis.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
use function Hyperf\Support\env;
return [
'default' => [
'host' => env('REDIS_HOST', 'localhost'),
'auth' => env('REDIS_AUTH', null),
'port' => (int) env('REDIS_PORT', 6379),
'db' => (int) env('REDIS_DB', 0),
'pool' => [
'min_connections' => 1,
'max_connections' => 10,
'connect_timeout' => 10.0,
'wait_timeout' => 3.0,
'heartbeat' => -1,
'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60),
],
],
];

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Hyperf\Framework\Bootstrap\PipeMessageCallback;
use Hyperf\Framework\Bootstrap\WorkerExitCallback;
use Hyperf\Framework\Bootstrap\WorkerStartCallback;
use Hyperf\JsonRpc\HttpServer;
use Hyperf\Server\Event;
use Hyperf\Server\Server;
use Swoole\Constant;
use function Hyperf\Support\env;
return [
'mode' => SWOOLE_PROCESS,
'servers' => [
[
'name' => 'jsonrpc-http',
'type' => Server::SERVER_HTTP,
'host' => env('JSON_RPC_HOST', '0.0.0.0'),
'port' => (int) env('JSON_RPC_PORT', 9620),
'sock_type' => SWOOLE_SOCK_TCP,
'callbacks' => [
Event::ON_REQUEST => [HttpServer::class, 'onRequest'],
],
'settings' => [
'open_eof_split' => true,
'package_eof' => "\r\n",
'package_max_length' => 1024 * 1024 * 2,
],
'options' => [
// Whether to enable request lifecycle event
'enable_request_lifecycle' => false,
],
],
],
'settings' => [
Constant::OPTION_ENABLE_COROUTINE => true,
Constant::OPTION_WORKER_NUM => (int) env('SWOOLE_WORKER_NUM', swoole_cpu_num()), //swoole_cpu_num(),
Constant::OPTION_PID_FILE => BASE_PATH . '/runtime/hyperf.pid',
Constant::OPTION_OPEN_TCP_NODELAY => true,
Constant::OPTION_MAX_COROUTINE => 100000,
Constant::OPTION_OPEN_HTTP2_PROTOCOL => true,
Constant::OPTION_MAX_REQUEST => 100000,
Constant::OPTION_SOCKET_BUFFER_SIZE => 2 * 1024 * 1024,
Constant::OPTION_BUFFER_OUTPUT_SIZE => 2 * 1024 * 1024,
],
'callbacks' => [
Event::ON_WORKER_START => [WorkerStartCallback::class, 'onWorkerStart'],
Event::ON_PIPE_MESSAGE => [PipeMessageCallback::class, 'onPipeMessage'],
Event::ON_WORKER_EXIT => [WorkerExitCallback::class, 'onWorkerExit'],
],
];

View File

@@ -0,0 +1,30 @@
<?php
use function Hyperf\Support\env;
return [
'enable' => [
'discovery' => true,
'register' => true,
],
'consumers' => [],
'providers' => [],
'drivers' => [
'nacos' => [
// nacos server url like https://nacos.hyperf.io, Priority is higher than host:port
// 'url' => '',
// The nacos host info
'host' => env('NACOS_HOST', '127.0.0.1'),
'port' => env('NACOS_PORT', 8848),
// The nacos account info
'username' => null,
'password' => null,
'guzzle' => [
'config' => null,
],
'group_name' => 'api',
'namespace_id' => env('NACOS_NAMESPACE', 'dev'),
'heartbeat' => 5,
],
],
];

33
config/config.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
use Hyperf\Contract\StdoutLoggerInterface;
use Psr\Log\LogLevel;
use function Hyperf\Support\env;
return [
'app_name' => env('APP_NAME', 'skeleton'),
'app_env' => env('APP_ENV', 'dev'),
'scan_cacheable' => env('SCAN_CACHEABLE', false),
StdoutLoggerInterface::class => [
'log_level' => [
LogLevel::ALERT,
LogLevel::CRITICAL,
LogLevel::DEBUG,
LogLevel::EMERGENCY,
LogLevel::ERROR,
LogLevel::INFO,
LogLevel::NOTICE,
LogLevel::WARNING,
],
],
];

21
config/container.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
/**
* Initialize a dependency injection container that implemented PSR-11 and return the container.
*/
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
use Hyperf\Context\ApplicationContext;
use Hyperf\Di\Container;
use Hyperf\Di\Definition\DefinitionSourceFactory;
$container = new Container((new DefinitionSourceFactory())());
return ApplicationContext::setContainer($container);

18
config/routes.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
use Hyperf\HttpServer\Router\Router;
Router::addRoute(['GET', 'POST', 'HEAD'], '/', 'App\Controller\IndexController@index');
Router::get('/favicon.ico', function () {
return '';
});

30
deploy.test.yml Normal file
View File

@@ -0,0 +1,30 @@
version: '3.7'
services:
hyperf:
image: $REGISTRY_URL/$PROJECT_NAME:test
environment:
- "APP_PROJECT=hyperf"
- "APP_ENV=testing"
ports:
- "9501:9501"
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
update_config:
parallelism: 2
delay: 5s
order: start-first
networks:
- hyperf_net
configs:
- source: hyperf_v1.0
target: /opt/www/.env
configs:
hyperf_v1.0:
external: true
networks:
hyperf_net:
external: true

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3'
services:
hyperf-skeleton:
container_name: hyperf-skeleton
image: hyperf-skeleton
build:
context: .
volumes:
- ./:/opt/www
ports:
- "9610:9610"
environment:
- APP_ENV=dev
- SCAN_CACHEABLE=false
networks:
default:
name: hyperf-skeleton

View File

@@ -0,0 +1,40 @@
<?php
namespace Aether;
interface AetherCrudInterface
{
/**
* 列表
* @return array
*/
public function list(): array;
/**
* 查询
* @param int $id
* @return object
*/
public function detail(int $id): object;
/**
* 新增
* @param array $data
* @return int
*/
public function create(array $data): int;
/**
* 更新
* @param int $id
* @param array $data
* @return bool
*/
public function update(int $id, array $data): bool;
/**
* 删除
* @param int $id
* @return bool
*/
public function delete(int $id): bool;
}

View File

@@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
namespace Aether;
use Aether\Contract\TreeableInterface;
use Aether\Exception\BusinessException;
use Hyperf\Database\Model\Builder;
use Throwable;
/**
* 抽象CRUD服务基类封装通用逻辑
*/
abstract class AetherCrudService extends AetherService implements AetherCrudInterface
{
protected array $search = [];
protected function getSearch(): array
{
return $this->search;
}
protected array $ignoreSearchFields = [];
protected function getIgnoreSearchFields(): array
{
return $this->ignoreSearchFields;
}
/**
* 获取当前服务对应的模型实例(由子类实现)
*/
protected abstract function getModel(): AetherModel;
/**
* 获取当前服务对应的验证器实例(由子类实现)
*/
protected abstract function getValidator(): AetherValidator;
/**
* 通用列表查询(支持分页和树形结构)
*/
public function list(array $params = []): array
{
$model = $this->getModel();
$query = $model->newQuery();
// 通过模型配置自动应用所有搜索条件
$this->applySearch($query, $params);
// 动态应用排序
$sortConfig = $model->getSortConfig();
if ($sortConfig) {
$query->orderBy($sortConfig['field'], $sortConfig['direction']);
}
$withDeleted = filter_var($params['with_deleted'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($withDeleted) {
$query->withTrashed();
}
// 存在分页参数page或size则进行分页查询
if (isset($params['page']) || isset($params['size'])) {
$page = (int)($params['page'] ?? 1);
$size = (int)($params['size'] ?? 10);
// 确保分页参数合法性
$page = max(1, $page);
$size = max(1, min(100, $size)); // 限制最大页大小为100
$result = $query->paginate($size, ['*'], 'page', $page);
return [
'total' => $result->total(),
'list' => $result->items()
];
}
// 无分页参数时返回完整数据集合
$items = $query->get()->toArray();
// 若模型支持树形结构则构建树形,否则返回普通数组
if ($model instanceof TreeableInterface) {
return $model::buildTree($items, (int)($params['parent_id'] ?? 0));
}
return $items;
}
/**
* 通用详情查询
*/
public function detail(int $id): object
{var_dump('detail');
return $this->getModel()->findOrFailById($id);
}
/**
* 通用创建逻辑
* @throws BusinessException|Throwable
*/
public function create(array $data): int
{
// 数据验证(使用子类指定的验证器)
$this->getValidator()->scene('create', $data)->check();
return $this->transaction(function () use ($data) {
$model = $this->getModel()->createOne($data);
$this->logger()->info('创建资源', [
'id' => $model->id,
'code' => $data['code'] ?? $model->code
]);
return $model->id;
});
}
/**
* 通用更新逻辑
* @throws BusinessException|Throwable
*/
public function update(int $id, array $data): bool
{
$model = $this->getModel();
$resource = $model->findById($id);
$this->checkResourceExists($resource);
// 数据验证
$this->getValidator()->scene('update', $data)->check();
// 钩子:处理更新时的特殊逻辑(如禁止自身为父级)
$this->handleUpdateSpecialLogic($id, $data);
return $this->transaction(function () use ($id, $data) {
$result = $this->getModel()->updateById($id, $data);
$this->logger()->info('更新资源', [
'id' => $id,
'data' => $data
]);
return $result;
});
}
/**
* 通用删除逻辑
* @throws BusinessException|Throwable
*/
public function delete(int $id): bool
{
$model = $this->getModel();
$resource = $model->findById($id);
$this->checkResourceExists($resource);
// 钩子:删除前检查(如子级存在性)
$this->checkChildrenBeforeDelete($id);
return $this->transaction(function () use ($id) {
$result = $this->getModel()->deleteById($id);
$this->logger()->info('删除资源', ['id' => $id]);
return $result;
});
}
/**
* 钩子方法:更新时的特殊逻辑(子类可重写)
*/
protected function handleUpdateSpecialLogic(int $id, array &$data): void
{
// 通用逻辑禁止将自身设为父级适用于有parent_id的场景
if (isset($data['parent_id']) && $data['parent_id'] == $id) {
throw new BusinessException('不能将自身设为父级', 400);
}
}
/**
* 钩子方法:删除前检查子级(子类可重写)
*/
protected function checkChildrenBeforeDelete(int $id): void
{
// 默认不检查,需要的子类重写
}
/**
* 根据模型的$search配置自动应用搜索条件到查询构建器
*/
public function applySearch(Builder $query, array $params): void
{
foreach ($this->search as $field => $rule) {
// 跳过未传递的参数
if (!isset($params[$field])) {
continue;
}
$value = $params[$field];
$this->applySearchRule($query, $field, $value, $rule);
}
}
/**
* 应用单个搜索规则
*/
protected function applySearchRule(Builder $query, string $field, $value, $rule): void
{
// 处理规则格式(支持字符串简写或数组配置)
$config = is_array($rule) ? $rule : ['type' => $rule];
$type = $config['type'];
switch ($type) {
case '=': // 精确匹配
$query->where($field, $value);
break;
case 'like': // 模糊匹配
$query->where($field, 'like', "%{$value}%");
break;
case 'between': // 范围查询(支持数组或两个参数)
$values = is_array($value) ? $value : [$value, $params[$field . '_end'] ?? $value];
$query->whereBetween($field, $values);
break;
case 'callback': // 自定义回调
if (isset($config['handler']) && is_callable($config['handler'])) {
call_user_func($config['handler'], $query, $value);
}
break;
// 可扩展其他类型in、>、< 等
}
}
/**
* 软删除恢复
* @throws BusinessException|Throwable
*/
public function restore(int $id): bool
{
$model = $this->getModel();
// 必须使用withTrashed()才能查询到已删除记录
$resource = $model->newQuery()->withTrashed()->find($id);
$this->checkResourceExists($resource, '恢复的资源不存在');
return $this->transaction(function () use ($id) {
$result = $this->getModel()->newQuery()->withTrashed()
->where('id', $id)->restore();
$this->logger()->info('恢复软删除资源', ['id' => $id]);
return $result;
});
}
/**
* 批量软删除
* @throws BusinessException|Throwable
*/
public function batchDelete(array $ids): bool
{
if (empty($ids)) {
throw new BusinessException('请选择要删除的记录', 400);
}
$model = $this->getModel();
$exists = $model->whereIn('id', $ids)->exists();
if (!$exists) {
throw new BusinessException('部分记录不存在', 404);
}
return $this->transaction(function () use ($ids) {
$result = $this->getModel()->whereIn('id', $ids)->delete();
$this->logger()->info('批量软删除资源', ['ids' => $ids]);
return $result > 0;
});
}
/**
* 批量恢复软删除
* @throws BusinessException|Throwable
*/
public function batchRestore(array $ids): bool
{
if (empty($ids)) {
throw new BusinessException('请选择要恢复的记录', 400);
}
$model = $this->getModel();
$exists = $model->newQuery()->withTrashed()
->whereIn('id', $ids)
->whereNotNull('deleted_at')
->exists();
if (!$exists) {
throw new BusinessException('部分记录不存在或未被删除', 404);
}
return $this->transaction(function () use ($ids) {
$result = $this->getModel()->newQuery()->withTrashed()
->whereIn('id', $ids)->restore();
$this->logger()->info('批量恢复资源', ['ids' => $ids]);
return $result > 0;
});
}
/**
* 永久删除(物理删除)
* @throws BusinessException|Throwable
*/
public function forceDelete(int $id): bool
{
$model = $this->getModel();
$resource = $model->newQuery()->withTrashed()->find($id);
$this->checkResourceExists($resource, '要删除的资源不存在');
return $this->transaction(function () use ($id) {
$result = $this->getModel()->newQuery()->withTrashed()
->where('id', $id)->forceDelete();
$this->logger()->info('永久删除资源', ['id' => $id]);
return $result;
});
}
}

View File

@@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace Aether;
use Closure;
use DateTime;
use Exception;
use Hyperf\Context\ApplicationContext;
use Hyperf\Contract\LengthAwarePaginatorInterface;
use Hyperf\Database\Model\Builder;
use Hyperf\Database\Model\Collection;
use Hyperf\Database\Model\ModelNotFoundException;
use Hyperf\DbConnection\Model\Model as HyperfModel;
use Hyperf\DbConnection\Db;
use Hyperf\HttpServer\Contract\RequestInterface;
use Hyperf\ModelCache\Cacheable;
use Hyperf\ModelCache\CacheableInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Throwable;
abstract class AetherModel extends HyperfModel implements CacheableInterface
{
use Cacheable;
// use AetherSoftDelete; 移除,由子类决定是否使用
/**
* 批量赋值白名单.
*/
protected array $fillable = [];
/**
* 时间戳格式.
*/
protected ?string $dateFormat = 'Y-m-d H:i:s';
/**
* 搜索器规则配置.
*/
protected array $search = [];
/**
* 获取器规则配置.
*/
protected array $append = [];
/**
* 隐藏字段.
*/
protected array $hidden = [
'created_at',
'updated_at',
'deleted_at',
// 'deleted_at' // 移除,由子类决定是否使用
];
/**
* 排序配置:
* - false: 禁用排序
* - 字符串: 排序字段(默认升序)
* - 数组: ['field' => '字段名', 'direction' => 'asc/desc']
*/
protected string|array|bool $sortable = 'sort'; // 默认按sort字段升序
/**
* 获取排序配置
* @return array|null [field, direction] 或 null禁用排序
*/
public function getSortConfig(): ?array
{
if ($this->sortable === false) {
return null;
}
// 处理字符串配置(如 'sort' 或 'create_time'
if (is_string($this->sortable)) {
return [
'field' => $this->sortable,
'direction' => 'asc'
];
}
return null;
}
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->bootBaseModel();
}
/**
* 快捷创建.
*/
public static function createOne(array $data): AetherModel|Builder|HyperfModel
{
return static::query()->create($data);
}
/**
* 快捷更新.
*/
public static function updateById(int $id, array $data): bool
{
return static::query()->where('id', $id)->update($data) > 0;
}
/**
* 快捷删除指定ID的记录
*
* @param int $id 要删除的记录ID
* @return bool 成功删除返回true
* @throws ModelNotFoundException 当记录不存在时抛出
* @throws Exception 当删除操作发生其他错误时抛出
*/
public static function deleteById(int $id): bool
{
if (!static::query()->where('id', $id)->exists()) {
throw new ModelNotFoundException(sprintf(
'找不到ID为 %d 的 %s 记录',
$id,
static::class
));
}
return static::query()->where('id', $id)->delete() > 0;
}
/**
* 快捷查找.
* @param int $id
* @return Builder|Builder[]|Collection|HyperfModel
* @throws Exception 当删除操作发生其他错误时抛出
* @throws ModelNotFoundException
*/
public static function findById(int $id): array|Builder|Collection|HyperfModel
{
$record = static::query()->find($id);
if (is_null($record)) {
throw new ModelNotFoundException(sprintf('找不到 ID 为 %d 的记录', $id));
}
return $record;
}
/**
* 快捷查找或失败.
* @param int $id 要查找的记录ID
* @return AetherModel 根据ID查找记录不存在则抛出异常
* 根据ID查找记录不存在则抛出异常
* @throws Exception 当删除操作发生其他错误时抛出
* @throws ModelNotFoundException 当记录不存在时抛出
*/
public static function findOrFailById(int $id): static
{
$record = static::query()->find($id);
if(is_null($record)) {
throw new ModelNotFoundException(sprintf(
'找不到ID为 %d 的 %s 记录',
$id,
static::class
));
}
return $record;// static::query()->findOrFail($id);
}
/**
* 获取列表.
*/
public static function getList(
array $conditions = [],
array $columns = ['*'],
array $orders = []
): Collection {
$query = static::buildQuery($conditions, $orders);
return $query->get($columns);
}
/**
* 分页查询列表.
*/
public static function getPageList(
array $conditions = [],
int $page = 1,
int $pageSize = 10,
array $columns = ['*'],
array $orderBy = []
): LengthAwarePaginatorInterface {
// 直接通过静态方法链构建查询
$query = static::query();
// 应用条件
foreach ($conditions as $field => $value) {
$query->where($field, $value);
}
// 应用排序
foreach ($orderBy as $field => $direction) {
$query->orderBy($field, $direction);
}
// 执行分页
return $query->paginate($pageSize, $columns, 'page', $page);
}
/**
* 获取器处理.
*/
public function getAttribute(string $key): mixed
{
$value = parent::getAttribute($key);
// 检查是否有自定义获取器
$getterMethod = 'get' . ucfirst($key) . 'Attr';
if (method_exists($this, $getterMethod)) {
return $this->{$getterMethod}($value);
}
// 应用获取器规则
if (isset($this->append[$key])) {
return $this->applyGetterRule($value, $this->append[$key]);
}
return $value;
}
/**
* 批量更新.
*/
public static function batchUpdate(array $conditions, array $data): int
{
return static::query()->where($conditions)->update($data);
}
/**
* 事务处理.
* @throws Throwable
*/
public static function transaction(Closure $closure): mixed
{
return Db::transaction($closure);
}
/**
* 初始化模型.
*/
protected function bootBaseModel(): void
{
// 自动注册搜索器和获取器
$this->registerSearchers();
$this->registerGetters();
}
/**
* 注册搜索器.
*/
protected function registerSearchers(): void
{
// 为模型查询添加全局作用域,自动应用搜索规则
static::addGlobalScope('auto_search', function (Builder $query) {
$searchConditions = $this->getSearchConditions();
if (empty($searchConditions)) {
return;
}
// 应用搜索条件到查询
$this->applySearch($query, $searchConditions);
});
}
/**
* 获取搜索条件.
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
protected function getSearchConditions(): array
{
$request = ApplicationContext::getContainer()->get(RequestInterface::class);
$allParams = $request->all();
if (is_array($allParams) && count($allParams) === 1 && is_array($allParams[0])) {
$allParams = $allParams[0];
}
$allowedFields = array_keys($this->search);
return array_intersect_key($allParams, array_flip($allowedFields));
}
protected function registerGetters(): void
{
foreach ($this->append as $field => $rule) {
// 为字段注册获取器回调
$this->registerGetter($field, $rule);
}
}
/**
* 为单个字段注册获取器.
* @param string $field 字段名
* @param array|Closure|string $rule 规则(类型/带参数的类型/闭包)
*/
protected function registerGetter(string $field, array|Closure|string $rule): void
{
// 生成获取器方法名(遵循 Hyperf 模型获取器规范get{Field}Attr
$getterMethod = 'get' . ucfirst($field) . 'Attr';
// 若已存在自定义获取器方法,则不覆盖
if (method_exists($this, $getterMethod)) {
return;
}
// 动态定义获取器方法
$this->{$getterMethod} = function ($value) use ($rule) {
// 闭包规则直接执行
if ($rule instanceof Closure) {
return $rule($value, $this); // 传入当前模型实例方便关联字段处理
}
// 解析规则类型和参数
if (is_array($rule)) {
$type = $rule[0];
$params = $rule[1] ?? [];
} else {
$type = $rule;
$params = [];
}
// 应用规则(复用/扩展 applyGetterRule 方法)
return $this->applyGetterRule($value, $type, $params);
};
}
/**
* 应用获取器规则(支持参数).
* @param mixed $value 字段原始值
* @param string $type 规则类型
* @param array $params 规则参数
*/
protected function applyGetterRule(mixed $value, string $type, array $params = []): mixed
{
return match ($type) {
// 基础规则
'date' => $value instanceof DateTime ? $value->format('Y-m-d') : $value,
'datetime' => $value instanceof DateTime ? $value->format('Y-m-d H:i:s') : $value,
'timestamp' => $value instanceof DateTime ? $value->getTimestamp() : $value,
'boolean' => (bool) $value,
'json' => is_string($value) ? json_decode($value, true) : $value,
// 参数化规则
'number' => $this->formatNumber($value, $params), // 数字格式化
'truncate' => $this->truncateString($value, $params), // 字符串截断
'enum' => $this->mapEnum($value, $params), // 枚举映射
default => $value,
};
}
// 数字格式化(示例参数化实现)
protected function formatNumber($value, array $params): string
{
$precision = $params['precision'] ?? 2; // 默认保留2位小数
return number_format((float) $value, $precision);
}
// 字符串截断(示例参数化实现)
protected function truncateString($value, array $params): string
{
$length = $params['length'] ?? 20; // 默认截断到20字符
$suffix = $params['suffix'] ?? '...'; // 省略符
if (! is_string($value)) {
$value = (string) $value;
}
return mb_strlen($value) > $length
? mb_substr($value, 0, $length) . $suffix
: $value;
}
// 枚举映射(示例参数化实现)
protected function mapEnum($value, array $params): mixed
{
$map = $params['map'] ?? []; // 枚举映射表,如 [1 => '男', 2 => '女']
return $map[$value] ?? $value;
}
// 修正 buildQuery 方法,避免使用 new static()
protected static function buildQuery(array $conditions = [], array $orderBy = []): Builder
{
// 使用静态 query() 方法获取查询构建器
$query = static::query();
// 处理搜索条件
foreach ($conditions as $field => $value) {
$query->where($field, $value);
}
// 处理排序
foreach ($orderBy as $field => $direction) {
$query->orderBy($field, $direction);
}
return $query;
}
/**
* 应用搜索条件.
*/
protected function applySearch(Builder $query, array $conditions): void
{
foreach ($conditions as $field => $value) {
// 跳过非字符串的字段名(防止索引数组键导致的类型错误)
if (!is_string($field)) {
continue;
}
// 处理嵌套关系查询(如:user.name
if (str_contains($field, '.')) {
[$relation, $relationField] = explode('.', $field, 2);
$query->whereHas($relation, function ($q) use ($relationField, $value) {
$q->where($relationField, $value);
});
continue;
}
// 检查是否有自定义搜索器方法
$searchMethod = 'search' . ucfirst($field);
if (method_exists($this, $searchMethod)) {
$this->{$searchMethod}($query, $value);
continue;
}
// 应用搜索规则配置
if (isset($this->search[$field])) {
$this->applySearchRule($query, $field, $value, $this->search[$field]);
continue;
}
// 默认精确匹配(仅对$search中允许的字段生效因已通过白名单过滤
$query->where($field, $value);
}
}
/**
* 应用搜索规则.
*/
protected function applySearchRule(Builder $query, string $field, mixed $value, array|string $rule): void
{
if (is_array($rule)) {
$type = $rule[0];
$params = $rule[1] ?? [];
} else {
$type = $rule;
$params = [];
}
switch ($type) {
case 'like':
$query->where($field, 'like', "%{$value}%");
break;
case 'like_left':
$query->where($field, 'like', "%{$value}");
break;
case 'like_right':
$query->where($field, 'like', "{$value}%");
break;
case 'in':
$query->whereIn($field, (array) $value);
break;
case 'not_in':
$query->whereNotIn($field, (array) $value);
break;
case 'gt':
$query->where($field, '>', $value);
break;
case 'lt':
$query->where($field, '<', $value);
break;
case 'gte':
$query->where($field, '>=', $value);
break;
case 'lte':
$query->where($field, '<=', $value);
break;
case 'between':
$query->whereBetween($field, (array) $value);
break;
case 'null':
$query->whereNull($field);
break;
case 'not_null':
$query->whereNotNull($field);
break;
default:
$query->where($field, $type, $value);
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Aether;
use Aether\Exception\BusinessException;
use Hyperf\Di\Annotation\Inject;
use Hyperf\Logger\LoggerFactory;
use Hyperf\DbConnection\Db;
use Psr\Log\LoggerInterface;
use Throwable;
abstract class AetherService
{
#[Inject]
protected LoggerFactory $loggerFactory;
/**
* 获取当前服务日志器
*/
protected function logger(): LoggerInterface
{
// return $this->loggerFactory->get(substr(strrchr(static::class, '\\'), 1));
// 提取类名如ExamTypeService作为日志通道名
$className = substr(strrchr(static::class, '\\'), 1);
return $this->loggerFactory->get($className);
}
/**
* 事务处理
* @throws Throwable
*/
protected function transaction(callable $callback)
{
return Db::transaction($callback);
}
/**
* 检查资源是否存在(不存在则抛出异常)
* @throws BusinessException
*/
protected function checkResourceExists(?object $resource, string $message = '资源不存在'): void
{
if (empty($resource)) {
throw new BusinessException($message, BusinessException::RESOURCE_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Aether;
use Aether\Exception\ValidationFailedException;
use Hyperf\Di\Annotation\Inject;
use Hyperf\Validation\Contract\ValidatorFactoryInterface;
use Hyperf\Validation\Validator;
use Hyperf\Context\ApplicationContext;
abstract class AetherValidator
{
#[Inject]
protected ValidatorFactoryInterface $validationFactory;
/**
* 当前场景名
*/
public ?string $currentScene = null;
/**
* 待验证数据
*/
protected array $data = [];
/**
* 自定义验证规则子类可通过该属性注册无需重写registerRules
* 格式:['规则名' => 闭包/类方法]
*/
protected array $customRules = [];
/**
* 静态快捷验证方法(简化调用)
*/
public static function validate(string $scene, array $data = []): array
{
// return (new static())->scene($scene, $data)->check();
// 从容器中获取当前类的实例(确保依赖注入生效)
$instance = ApplicationContext::getContainer()->get(static::class);
return $instance->scene($scene, $data)->check();
}
/**
* 设置验证场景和数据(支持链式调用)
*/
public function scene(string $scene, array $data = []): self
{
$this->currentScene = $scene;
$this->data = $data;
return $this;
}
/**
* 执行验证(失败抛出异常)
*/
public function check(): array
{
if (empty($this->currentScene)) {
throw new \RuntimeException('请先设置验证场景');
}
$scenes = $this->scenes();
if (!isset($scenes[$this->currentScene])) {
throw new \RuntimeException("验证场景不存在:{$this->currentScene}");
}
$sceneConfig = $scenes[$this->currentScene];
return $this->validateData(
$this->data,
$sceneConfig['rules'],
$sceneConfig['messages'] ?? [],
$sceneConfig['attributes'] ?? []
);
}
/**
* 实际执行验证的逻辑(重命名方法名更清晰)
*/
protected function validateData(array $data, array $rules, array $messages = [], array $attributes = []): array
{
$validator = $this->validationFactory->make($data, $rules, $messages, $attributes);
$this->registerRules($validator);
if ($validator->fails()) {
throw new ValidationFailedException(
$validator,
$this->currentScene ?? '',
$validator->errors()->first()
);
}
return $validator->validated();
}
/**
* 格式化验证错误信息(统一格式,供异常处理器复用)
*/
public function formatValidationErrors(Validator $validator): array
{
$errors = [];
$failedRules = $validator->failed();
$errorMessages = $validator->errors()->getMessages();
$attributes = $validator->attributes();
foreach ($failedRules as $field => $rules) {
$errors[] = [
'field' => $field,
'field_label' => $attributes[$field] ?? $field,
'message' => $errorMessages[$field][0] ?? '',
'rules' => array_keys($rules),
'value' => $validator->getValue($field)
];
}
return $errors;
}
/**
* 自动注册自定义规则(优先使用$customRules属性
*/
protected function registerRules(Validator $validator): void
{
foreach ($this->customRules as $ruleName => $rule) {
$validator->extend($ruleName, $rule);
}
}
/**
* 定义场景验证规则(子类实现)
*/
abstract protected function scenes(): array;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Aether\Contract;
/**
* 树形结构接口,标识模型支持树形功能
*/
interface TreeableInterface
{
/**
* 构建树形结构
* @param array $items 原始数据
* @param int $parentId 根节点ID
* @return array
*/
public static function buildTree(array $items, int $parentId = 0): array;
/**
* 获取子节点ID集合
* @param int $id 节点ID
* @return array
*/
public function getChildIds(int $id): array;
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Aether\Exception;
use Aether\AetherValidator;
use Hyperf\Contract\StdoutLoggerInterface;
use Hyperf\Database\Model\ModelNotFoundException; // 引入模型未找到异常
use Hyperf\ExceptionHandler\ExceptionHandler;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Hyperf\Validation\ValidationException;
use Psr\Http\Message\ResponseInterface;
use Throwable;
use Hyperf\Context\Context;
use function Hyperf\Support\env;
class AetherExceptionHandler extends ExceptionHandler
{
public function __construct(protected StdoutLoggerInterface $logger)
{
}
public function handle(Throwable $throwable, ResponseInterface $response): ResponseInterface
{
$requestId = Context::get('request_id', uniqid());
$result = $this->formatErrorResponse($throwable, $requestId);
// 记录错误日志(包含完整堆栈)
$this->logger->error(sprintf(
'Exception [%s] | RequestId: %s | Message: %s in %s:%d',
get_class($throwable),
$requestId,
$throwable->getMessage(),
$throwable->getFile(),
$throwable->getLine()
));
if (env('APP_ENV') === 'dev') {
$this->logger->error($throwable->getTraceAsString());
}
// 确保状态码合法100-599
$statusCode = $result['code'] ?? 500;
if ($statusCode < 100 || $statusCode >= 600) {
$statusCode = 500;
}
// 构建响应
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($statusCode)
->withBody(new SwooleStream(json_encode($result, JSON_UNESCAPED_UNICODE)));
}
private function formatErrorResponse(Throwable $throwable, string $requestId): array
{
// 模型未找到异常
if ($throwable instanceof ModelNotFoundException) {
return [
'code' => 404, // 资源不存在标准状态码
'message' => $throwable->getMessage() ?: '请求的资源不存在',
'data' => env('APP_ENV') === 'dev' ? [
'file' => $throwable->getFile(),
'line' => $throwable->getLine()
] : null,
'request_id' => $requestId,
'timestamp' => time()
];
}
// 自定义验证异常
if ($throwable instanceof ValidationFailedException) {
return $this->formatValidationError($throwable, $requestId);
}
// 原生验证异常
if ($throwable instanceof ValidationException) {
return $this->formatNativeValidationError($throwable, $requestId);
}
// 其他异常如RuntimeException等
return [
'code' => 600,
'message' => env('APP_ENV') === 'dev' ? $throwable->getMessage() : '服务暂时不可用',
'data' => env('APP_ENV') === 'dev' ? [
'file' => $throwable->getFile(),
'line' => $throwable->getLine(),
'trace' => explode("\n", $throwable->getTraceAsString())
] : null,
'request_id' => $requestId,
'timestamp' => time()
];
}
private function formatValidationError(ValidationFailedException $e, string $requestId): array
{
$validatorInstance = new class extends AetherValidator {
protected function scenes(): array { return []; }
};
return [
'code' => 422,
'message' => $e->getMessage(),
'data' => [
'errors' => $validatorInstance->formatValidationErrors($e->validator),
'scene' => $e->getScene(),
'validated_data' => env('APP_ENV') === 'dev' ? $e->validator->getData() : null
],
'request_id' => $requestId,
'timestamp' => time()
];
}
private function formatNativeValidationError(ValidationException $e, string $requestId): array
{
$validatorInstance = new class extends AetherValidator {
protected function scenes(): array { return []; }
};
return [
'code' => 422,
'message' => '参数验证失败',
'data' => [
'errors' => $validatorInstance->formatValidationErrors($e->validator),
'validated_data' => env('APP_ENV') === 'dev' ? $e->validator->getData() : null
],
'request_id' => $requestId,
'timestamp' => time()
];
}
public function isValid(Throwable $throwable): bool
{
return true;
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Aether\Exception;
use Aether\AetherValidator;
use Hyperf\Contract\StdoutLoggerInterface;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Hyperf\Validation\ValidationException;
use Psr\Http\Message\ResponseInterface;
use Throwable;
use Hyperf\Context\Context;
use function Hyperf\Support\env;
class AppExceptionHandler extends ExceptionHandler
{
public function __construct(protected StdoutLoggerInterface $logger)
{
}
public function handle(Throwable $throwable, ResponseInterface $response): ResponseInterface
{
$requestId = Context::get('request_id', uniqid());
$result = $this->formatErrorResponse($throwable, $requestId);
$this->logger->error(sprintf(
'Exception: %s[%s] in %s:%d',
get_class($throwable),
$throwable->getMessage(),
$throwable->getFile(),
$throwable->getLine()
));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($result['code'] ?? 500)
->withBody(new SwooleStream(json_encode($result, JSON_UNESCAPED_UNICODE)));
}
/**
* 统一错误响应格式
*/
private function formatErrorResponse(Throwable $throwable, string $requestId): array
{
// 处理自定义验证异常
if ($throwable instanceof ValidationFailedException) {
return $this->formatValidationError($throwable, $requestId);
}
// 处理原生验证异常
if ($throwable instanceof ValidationException) {
return $this->formatNativeValidationError($throwable, $requestId);
}
// 处理其他异常
return [
'code' => 500,
'message' => env('APP_ENV') === 'dev' ? $throwable->getMessage() : '服务暂时不可用',
'data' => env('APP_ENV') === 'dev' ? [
'file' => $throwable->getFile(),
'line' => $throwable->getLine(),
'trace' => explode("\n", $throwable->getTraceAsString())
] : null,
'request_id' => $requestId,
'timestamp' => time()
];
}
/**
* 格式化自定义验证异常
*/
private function formatValidationError(ValidationFailedException $e, string $requestId): array
{
// 复用AetherValidator的错误格式化方法
$validatorInstance = new class extends AetherValidator {
protected function scenes(): array { return []; }
};
return [
'code' => 422,
'message' => $e->getMessage(),
'data' => [
'errors' => $validatorInstance->formatValidationErrors($e->validator),
'scene' => $e->getScene(), // 直接从异常获取场景
'validated_data' => env('APP_ENV') === 'dev' ? $e->validator->getData() : null
],
'request_id' => $requestId,
'timestamp' => time()
];
}
/**
* 格式化原生验证异常(保持格式一致)
*/
private function formatNativeValidationError(ValidationException $e, string $requestId): array
{
$validatorInstance = new class extends AetherValidator {
protected function scenes(): array { return []; }
};
return [
'code' => 422,
'message' => '参数验证失败',
'data' => [
'errors' => $validatorInstance->formatValidationErrors($e->validator),
'validated_data' => env('APP_ENV') === 'dev' ? $e->validator->getData() : null
],
'request_id' => $requestId,
'timestamp' => time()
];
}
public function isValid(Throwable $throwable): bool
{
return true;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Aether\Exception;
use Hyperf\Server\Exception\ServerException;
class BusinessException extends ServerException
{
// 错误码常量(按业务模块划分)
public const VALIDATION_ERROR = 400; // 参数验证失败
public const AUTH_ERROR = 401; // 认证失败
public const PERMISSION_DENY = 403; // 权限不足
public const RESOURCE_NOT_FOUND = 404; // 资源不存在
public const SCENE_NOT_FOUND = 400;
/**
* 额外错误数据(如验证详情)
*/
protected ?array $errorData = null;
public function __construct(
string $message,
int $code = 500,
?\Throwable $previous = null,
?array $errorData = null
) {
parent::__construct($message, $code, $previous);
$this->errorData = $errorData;
}
/**
* 获取额外错误数据
*/
public function getErrorData(): ?array
{
return $this->errorData;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Aether\Exception;
use Hyperf\Validation\ValidationException;
use Hyperf\Validation\Validator;
use Psr\Http\Message\ResponseInterface;
class ValidationFailedException extends ValidationException
{
/**
* 验证场景
*/
protected string $scene;
public function __construct(
Validator $validator,
string $scene,
string $message = '参数验证失败',
?ResponseInterface $response = null // 新增response参数符合父类要求
) {
// 父类构造函数仅接受 $validator 和 $response
parent::__construct($validator, $response);
// 单独设置消息(父类的 $message 为 protected 属性,可直接赋值)
$this->message = $message;
$this->scene = $scene;
}
public function getScene(): string
{
return $this->scene;
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Aether\Traits;
use App\Notice\Enum\NoticeStatusEnum;
use App\Notice\Model\NoticeStatsModel;
use InvalidArgumentException;
use ReflectionClass;
trait AetherEnum
{
/**
* 获取所有枚举值数组(严格保持定义顺序).
* @return array<int|string> 枚举值集合
*/
public static function values(): array
{
self::validateEnumStructure();
$values = [];
foreach (self::cases() as $case) {
$values[] = $case->value;
}
return $values;
}
/**
* 获取所有枚举描述数组与values()顺序一一对应).
* @return array<string> 描述文本集合
*/
public static function descriptions(): array
{
self::validateEnumStructure();
$descriptions = [];
foreach (self::cases() as $case) {
$descriptions[] = $case->description();
}
return $descriptions;
}
/**
* 获取值-描述映射数组(用于下拉选择等场景).
* @return array<int|string, string> 键为枚举值,值为描述文本
*/
public static function valueMap(): array
{
self::validateEnumStructure();
$map = [];
foreach (self::cases() as $case) {
$map[$case->value] = $case->description();
}
return $map;
}
/**
* 根据值获取枚举实例(严格模式).
* @param int|string $value 枚举值
* @return AetherEnum|NoticeStatsModel|NoticeStatusEnum 枚举实例
*/
public static function fromValue(int|string $value): self
{
self::validateEnumStructure();
// 检查值类型是否与枚举类型匹配基于第一个case的类型
$firstCase = self::cases()[0] ?? null;
if ($firstCase) {
$expectedType = gettype($firstCase->value);
$actualType = gettype($value);
if ($expectedType !== $actualType) {
throw new InvalidArgumentException(sprintf(
'枚举值类型不匹配,%s期望%s类型实际为%s',
self::class,
$expectedType,
$actualType
));
}
}
$enum = self::tryFrom($value);
if (! $enum) {
throw new InvalidArgumentException(sprintf(
'无效的%s值: %s允许值: %s',
self::class,
$value,
implode(', ', self::values())
));
}
return $enum;
}
/**
* 根据描述获取枚举实例(精确匹配).
* @param string $description 描述文本
* @return null|AetherEnum|NoticeStatsModel|NoticeStatusEnum 匹配的枚举实例无匹配时返回null
*/
public static function fromDescription(string $description): ?self
{
self::validateEnumStructure();
foreach (self::cases() as $case) {
if ($case->description() === $description) {
return $case;
}
}
return null;
}
/**
* 检查值是否为有效的枚举值(严格类型检查).
* @param int|string $value 待检查的值
* @return bool 是否有效
*/
public static function isValidValue(int|string $value): bool
{
self::validateEnumStructure();
foreach (self::cases() as $case) {
if ($case->value === $value) { // 严格相等,避免类型松散匹配
return true;
}
}
return false;
}
/**
* 校验枚举结构合法性替代__init私有静态方法.
*/
private static function validateEnumStructure(): void
{
// 检查当前类是否为枚举
if (! (new ReflectionClass(self::class))->isEnum()) {
throw new InvalidArgumentException(sprintf(
'AetherEnum trait仅允许枚举类使用%s不是枚举',
self::class
));
}
// 检查枚举是否实现了description()方法
if (! method_exists(self::class, 'description')) {
throw new InvalidArgumentException(sprintf(
'枚举类%s必须实现description()方法(返回字符串描述)',
self::class
));
}
// 检查description()方法返回值是否为字符串
$sampleCase = self::cases()[0] ?? null;
if ($sampleCase && ! is_string($sampleCase->description())) {
throw new InvalidArgumentException(sprintf(
'枚举类%s的description()方法必须返回字符串',
self::class
));
}
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Aether\Traits;
trait AetherSearchable
{
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Aether\Traits;
use Hyperf\Database\Model\SoftDeletes;
/**
* 通用软删除Trait供需要软删除的模型使用
*/
trait AetherSoftDelete
{
use SoftDeletes;
/**
* 初始化软删除相关配置自动隐藏deleted_at字段
*/
protected function initializeAetherSoftDeletes(): void
{
// 自动将deleted_at添加到隐藏字段避免序列化时暴露
if (!in_array('deleted_at', $this->hidden, true)) {
$this->hidden[] = 'deleted_at';
}
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Aether\Traits;
use Aether\AetherModel;
use Hyperf\Database\Model\Collection;
use LogicException;
trait AetherTree
{
// 初始化时检查当前类是否继承AetherModel
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
if (!$this instanceof AetherModel) {
throw new LogicException(
"使用AetherTree trait的类必须继承AetherModel当前类: " . get_class($this)
);
}
}
/**
* 抽象方法获取父ID字段名由子类实现
*/
abstract protected function getParentIdField(): string;
/**
* 抽象方法:获取排序字段名(由子类实现)
*/
abstract protected function getSortField(): string;
/**
* 构建树形结构
*/
public static function buildTree($items, int $parentId = 0): array
{
$self = new static();
$parentField = $self->getParentIdField();
$sortField = $self->getSortField();
$items = $items instanceof Collection ? $items->toArray() : $items;
$tree = [];
foreach ($items as $item) {
if ($item[$parentField] == $parentId) {
$children = static::buildTree($items, $item['id']);
if (!empty($children)) {
$item['children'] = $children;
}
$tree[] = $item;
}
}
$self->sortTreeItems($tree, $sortField);
return $tree;
}
/**
* 树形节点排序
*/
protected function sortTreeItems(array &$items, string $sortField): void
{
usort($items, function ($a, $b) use ($sortField) {
$direction = $this->treeSortDirection ?? 'asc';
return $direction === 'desc'
? $b[$sortField] <=> $a[$sortField]
: $a[$sortField] <=> $b[$sortField];
});
}
/**
* 获取指定节点的所有子节点ID
*/
public function getChildIds(int $id): array
{
$parentField = $this->getParentIdField();
// 现在可以安全调用newQuery(),因为已通过继承检查
$allItems = $this->newQuery()->get(['id', $parentField])->toArray();
$ids = [$id];
$this->collectChildIds($allItems, $id, $parentField, $ids);
return $ids;
}
/**
* 递归收集子节点ID
*/
private function collectChildIds(array $items, int $parentId, string $parentField, array &$ids): void
{
foreach ($items as $item) {
if ($item[$parentField] == $parentId) {
$ids[] = $item['id'];
$this->collectChildIds($items, $item['id'], $parentField, $ids);
}
}
}
/**
* 获取节点的完整路径
*/
public function getPath(int $id): array
{
$parentField = $this->getParentIdField();
// 安全调用newQuery()
$node = $this->newQuery()->find($id);
if (!$node) {
return [];
}
$path = [$node->toArray()];
$parentId = $node[$parentField];
while ($parentId > 0) {
$parent = $this->newQuery()->find($parentId);
if (!$parent) {
break;
}
array_unshift($path, $parent->toArray());
$parentId = $parent[$parentField];
}
return $path;
}
}

14
phpstan.neon.dist Normal file
View File

@@ -0,0 +1,14 @@
# Magic behaviour with __get, __set, __call and __callStatic is not exactly static analyser-friendly :)
# Fortunately, You can ignore it by the following config.
#
# vendor/bin/phpstan analyse app --memory-limit 200M -l 0
#
parameters:
level: 0
paths:
- ./app
- ./config
reportUnmatchedIgnoredErrors: false
ignoreErrors:
- '#Static call to instance method Hyperf\\HttpServer\\Router\\Router::[a-zA-Z0-9\\_]+\(\)#'
- '#Static call to instance method Hyperf\\DbConnection\\Db::[a-zA-Z0-9\\_]+\(\)#'

16
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="./test/bootstrap.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
<testsuites>
<testsuite name="Tests">
<directory suffix="Test.php">./test</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing" force="true"/>
</php>
<source>
<include>
<directory suffix=".php">./app</directory>
</include>
</source>
</phpunit>

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace HyperfTest\Cases;
use Hyperf\Testing\TestCase;
/**
* @internal
* @coversNothing
*/
class ExampleTest extends TestCase
{
public function testExample()
{
$this->get('/')->assertOk()->assertSee('Hyperf');
}
}

45
test/HttpTestCase.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace HyperfTest;
use Hyperf\Testing\Client;
use PHPUnit\Framework\TestCase;
use function Hyperf\Support\make;
/**
* Class HttpTestCase.
* @method get($uri, $data = [], $headers = [])
* @method post($uri, $data = [], $headers = [])
* @method json($uri, $data = [], $headers = [])
* @method file($uri, $data = [], $headers = [])
* @method request($method, $path, $options = [])
*/
abstract class HttpTestCase extends TestCase
{
/**
* @var Client
*/
protected $client;
public function __construct($name = null, array $data = [], $dataName = '')
{
parent::__construct($name, $data, $dataName);
$this->client = make(Client::class);
}
public function __call($name, $arguments)
{
return $this->client->{$name}(...$arguments);
}
}

30
test/bootstrap.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
ini_set('display_errors', 'on');
ini_set('display_startup_errors', 'on');
error_reporting(E_ALL);
date_default_timezone_set('Asia/Shanghai');
Swoole\Runtime::enableCoroutine(true);
! defined('BASE_PATH') && define('BASE_PATH', dirname(__DIR__, 1));
require BASE_PATH . '/vendor/autoload.php';
! defined('SWOOLE_HOOK_FLAGS') && define('SWOOLE_HOOK_FLAGS', Hyperf\Engine\DefaultOption::hookFlags());
Hyperf\Di\ClassLoader::init();
$container = require BASE_PATH . '/config/container.php';
$container->get(Hyperf\Contract\ApplicationInterface::class);