初始化本地仓库
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\base;
|
||||
|
||||
abstract class BaseController
|
||||
{
|
||||
|
||||
/**
|
||||
* @var \core\base\BaseService|null
|
||||
*/
|
||||
protected BaseService|null $service = null;
|
||||
|
||||
|
||||
/**
|
||||
* @var \core\base\BaseValidate|null
|
||||
*/
|
||||
protected BaseValidate|null $validate=null;
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
*
|
||||
* @access public
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
abstract protected function initialize(): void;
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\base;
|
||||
|
||||
use app\adminapi\CurrentUser;
|
||||
use app\model\system\RecycleBin;
|
||||
use app\scope\global\AccessPermissionScope;
|
||||
use app\service\admin\system\RecycleBinService;
|
||||
use Carbon\Carbon;
|
||||
use core\exception\handler\AdminException;
|
||||
use core\uuid\Snowflake;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use support\Container;
|
||||
use support\Model;
|
||||
|
||||
class BaseModel extends Model
|
||||
{
|
||||
|
||||
/**
|
||||
* 指明模型的ID是否自动递增。
|
||||
* false = 雪花ID(默认);true = 数据库自增ID
|
||||
* 子类如需自增ID,覆盖为: public $incrementing = true;
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $incrementing = false;
|
||||
|
||||
/**
|
||||
* 主键类型
|
||||
* 雪花ID默认为 'string',自增ID子类应覆盖为: protected $keyType = 'int';
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $keyType = 'string';
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
const UPDATED_AT = 'updated_at';
|
||||
|
||||
const DELETED_AT = 'deleted_at';
|
||||
|
||||
protected $appends = [];
|
||||
|
||||
/**
|
||||
* 隐藏属性
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $hidden = [];
|
||||
|
||||
/**
|
||||
* 模型日期字段的存储格式。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 指示模型是否主动维护时间戳。
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
public function __construct(array $data = [])
|
||||
{
|
||||
parent::__construct($data);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// 添加全局数据权限作用域-这里主要添加数据权限子类重写开启默认关闭
|
||||
// static::addGlobalScope(new AccessPermissionScope);
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
//注册创建事件
|
||||
static::creating(function ($model) {
|
||||
// 仅非自增主键时自动生成雪花ID
|
||||
if (!$model->getIncrementing() && !isset($model->{$model->getKeyName()})) {
|
||||
$model->{$model->getKeyName()} = (string)Snowflake::generate();
|
||||
}
|
||||
self::setCreatedBy($model);
|
||||
});
|
||||
|
||||
// 注册更新事件
|
||||
static::updating(function ($model) {
|
||||
self::setUpdatedBy($model);
|
||||
});
|
||||
|
||||
// 注册删除事件
|
||||
static::deleted(function ($model) {
|
||||
self::onAfterDelete($model);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开启软删
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isSoftDeleteEnabled(): bool
|
||||
{
|
||||
return in_array(SoftDeletes::class, class_uses(static::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取主键名称
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getPk(): string
|
||||
{
|
||||
return $this->getKeyName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型字段数据
|
||||
*
|
||||
* @param string $field
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getData(string $field): mixed
|
||||
{
|
||||
return $this->attributes[$field] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入模型字段数据
|
||||
*
|
||||
* @param string $name
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function set(string $name, mixed $value): void
|
||||
{
|
||||
$this->attributes[$name] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型的字段列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getFields(): array
|
||||
{
|
||||
try {
|
||||
$tableName = $this->getTable();
|
||||
$connection = $this->getConnection();
|
||||
$prefix = $connection->getTablePrefix();
|
||||
$fullTableName = $prefix . $tableName;
|
||||
$fields = $connection->select("SHOW COLUMNS FROM `{$fullTableName}`");
|
||||
return array_map(function ($column) {
|
||||
return $column->Field;
|
||||
}, $fields);
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加创建时间
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getCreatedDateAttribute(): ?string
|
||||
{
|
||||
if ($this->getAttribute($this->getCreatedAtColumn())) {
|
||||
try {
|
||||
$timestamp = $this->getRawOriginal($this->getCreatedAtColumn());
|
||||
if (empty($timestamp)) {
|
||||
return null;
|
||||
}
|
||||
$carbonInstance = Carbon::createFromTimestamp($timestamp);
|
||||
return $carbonInstance->setTimezone(config('app.default_timezone'))->format('Y-m-d H:i:s');
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加更新时间
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getUpdatedDateAttribute(): ?string
|
||||
{
|
||||
if ($this->getAttribute($this->getUpdatedAtColumn())) {
|
||||
try {
|
||||
$timestamp = $this->getRawOriginal($this->getUpdatedAtColumn());
|
||||
if (empty($timestamp)) {
|
||||
return null;
|
||||
}
|
||||
$carbonInstance = Carbon::createFromTimestamp($timestamp);
|
||||
return $carbonInstance->setTimezone(config('app.default_timezone'))->format('Y-m-d H:i:s');
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除事件
|
||||
*
|
||||
* @param \support\Model $model
|
||||
*
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public static function onAfterDelete(Model $model)
|
||||
{
|
||||
try {
|
||||
$table = $model->getTable();
|
||||
/** @var RecycleBinService $recycleService */
|
||||
$service = Container::make(RecycleBinService::class);
|
||||
$config = $service->getTableConfig($table);
|
||||
|
||||
// 检查是否启用回收站
|
||||
if (!$config['enabled'] || $model->isSoftDeleteEnabled() || $config['strategy'] === 'logical') {
|
||||
return;
|
||||
}
|
||||
$prefix = $model->getConnection()->getTablePrefix();
|
||||
|
||||
// 准备数据(排除敏感字段)
|
||||
$tableData = array_except($model->getAttributes(), $config['exclude_fields'] ?? []);
|
||||
$tableData['original_id'] = $model->getAttribute($model->getPk());
|
||||
|
||||
$data = self::prepareRecycleBinData($tableData, $table, $prefix);
|
||||
// 动态选择存储方式
|
||||
$handler = fn() => RecycleBin::centralConnection();
|
||||
$handler()->create($data);
|
||||
} catch (\Exception $e) {
|
||||
throw new AdminException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置创建人
|
||||
*
|
||||
* @param Model $model
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function setCreatedBy(Model $model): void
|
||||
{
|
||||
$uid = Container::make(CurrentUser::class)->id();
|
||||
if ($uid && $model->isFillable('created_by')) {
|
||||
$model->setAttribute('created_by', $uid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置更新人
|
||||
*
|
||||
* @param Model $model
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function setUpdatedBy(Model $model): void
|
||||
{
|
||||
$uid = Container::make(CurrentUser::class)->id();
|
||||
if ($uid && $model->isFillable('updated_by')) {
|
||||
$model->setAttribute('updated_by', $uid);
|
||||
}
|
||||
}
|
||||
|
||||
private static function prepareRecycleBinData($tableData, $table, $prefix): array
|
||||
{
|
||||
return [
|
||||
'data' => $tableData,
|
||||
'original_id' => $tableData['id'] ?? '',
|
||||
'table_name' => $table,
|
||||
'table_prefix' => $prefix,
|
||||
'enabled' => 0,
|
||||
'ip' => request()->getRealIp(),
|
||||
'operate_id' => Container::make(CurrentUser::class)->id(),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\base;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class BasePivot extends Pivot
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\base;
|
||||
|
||||
|
||||
use core\cache\CacheService;
|
||||
use core\trait\ServiceTrait;
|
||||
use support\Db as LaravelDb;
|
||||
|
||||
/**
|
||||
* @method getModel()
|
||||
*/
|
||||
abstract class BaseService
|
||||
{
|
||||
|
||||
use ServiceTrait;
|
||||
|
||||
/**
|
||||
* 模型注入
|
||||
*/
|
||||
protected ?BaseDao $dao;
|
||||
|
||||
/**
|
||||
* 缓存管理
|
||||
*
|
||||
* @return \core\cache\CacheService
|
||||
*/
|
||||
public function cacheDriver(): CacheService
|
||||
{
|
||||
return new CacheService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分页配置
|
||||
*
|
||||
* @param bool $isPage
|
||||
* @param bool $isRelieve
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function getPageValue(bool $isPage = true, bool $isRelieve = true): array
|
||||
{
|
||||
// 获取请求实例
|
||||
$request = request();
|
||||
$page = $limit = 0;
|
||||
if ($isPage) {
|
||||
$page = $request->input(Config('database.page.pageKey', 'page') . '/d', 0);
|
||||
$limit = $request->input(Config('database.page.limitKey', 'limit') . '/d', 0);
|
||||
}
|
||||
$limitMax = Config('database.page.limitMax');
|
||||
$defaultLimit = Config('database.page.defaultLimit', 10);
|
||||
if ($limit > $limitMax && $isRelieve) {
|
||||
$limit = $limitMax;
|
||||
}
|
||||
return [(int)$page, (int)$limit, (int)$defaultLimit, (int)$limitMax];
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行指定框架的事务
|
||||
*
|
||||
* @param callable $closure
|
||||
* @param bool $isTran 是否启用事务
|
||||
* @param string|null $connectionName
|
||||
*
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function transaction(callable $closure, bool $isTran = true, ?string $connectionName = null): mixed
|
||||
{
|
||||
|
||||
$connectionName = !empty($connectionName) ? $connectionName : (!empty(request()->dataSource) ? request()->dataSource : config('database.default', 'default'));
|
||||
return $isTran ? LaravelDb::connection($connectionName)->transaction($closure) : $closure();
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码hash加密
|
||||
*
|
||||
* @param string $password
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function passwordHash(string $password): string
|
||||
{
|
||||
return password_hash($password, PASSWORD_BCRYPT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name
|
||||
* @param $arguments
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call($name, $arguments)
|
||||
{
|
||||
return call_user_func_array([$this->dao, $name], $arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
namespace core\base;
|
||||
|
||||
use core\exception\handler\ValidationException;
|
||||
use Illuminate\Validation\Validator;
|
||||
use think\helper\Str;
|
||||
use Webman\Http\Request;
|
||||
|
||||
abstract class BaseValidate
|
||||
{
|
||||
/** @var bool 是否实例化时自动验证 */
|
||||
public bool $authValidate = false;
|
||||
|
||||
/** @var array 验证规则 */
|
||||
protected array $rules = [];
|
||||
|
||||
/** @var array 错误消息 */
|
||||
protected array $message = [];
|
||||
|
||||
/** @var array 验证场景(场景名 => 需要验证的字段) */
|
||||
protected array $scene = [];
|
||||
|
||||
/** @var array 当前场景需验证的字段 */
|
||||
protected array $only = [];
|
||||
|
||||
/** @var array 验证通过的有效数据 */
|
||||
protected array $data = [];
|
||||
|
||||
/** @var string|null 当前场景名 */
|
||||
protected string|null $currentScene;
|
||||
|
||||
/** @var string|null 验证失败错误信息 */
|
||||
protected ?string $error = null;
|
||||
|
||||
/** @var bool 是否抛出验证异常(默认是) */
|
||||
protected bool $failException = true;
|
||||
|
||||
/** @var int 业务错误码 */
|
||||
protected int $code = -1;
|
||||
|
||||
/** @var int HTTP响应状态码 */
|
||||
protected int $statusCode = 200;
|
||||
|
||||
/**
|
||||
* 构造函数:初始化场景与自动验证
|
||||
*
|
||||
* @param string|null $scene 验证场景名
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __construct(?string $scene = null)
|
||||
{
|
||||
// 设置场景
|
||||
if ($scene) {
|
||||
$this->scene($scene);
|
||||
}
|
||||
|
||||
// 自动验证(若开启)
|
||||
if ($this->authValidate) {
|
||||
$this->check();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 魔术方法:代理Webman请求对象的方法(如input、method)
|
||||
*
|
||||
* @param string $name 方法名
|
||||
* @param array $arguments 参数
|
||||
*
|
||||
* @return mixed
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __call(string $name, array $arguments)
|
||||
{
|
||||
$request = $this->request();
|
||||
if (method_exists($request, $name)) {
|
||||
return $request->{$name}(...$arguments);
|
||||
}
|
||||
throw new ValidationException(__('方法名不能为空', ['class' => get_class($request), 'method' => $name]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并自定义错误消息
|
||||
*
|
||||
* @param array $message 自定义消息
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setMessage(array $message): self
|
||||
{
|
||||
$this->message = array_merge($this->message, $message);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心验证方法:执行规则校验(保持原功能,优化内部逻辑)
|
||||
*
|
||||
* @param array $data 待验证数据(为空则从请求取)
|
||||
* @param array $rules 自定义规则(为空则用类内规则)
|
||||
*
|
||||
* @return bool 验证通过返回true
|
||||
* @throws ValidationException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function check(array $data = [], array $rules = []): bool
|
||||
{
|
||||
// 1. 初始化场景(若有)
|
||||
if ($this->currentScene) {
|
||||
$this->loadSceneRules($this->currentScene);
|
||||
}
|
||||
// 2. 获取最终验证规则(支持方法和属性两种方式,属性优先)
|
||||
if (empty($rules)) {
|
||||
// 优先使用属性定义(如果属性不为空)
|
||||
if (!empty($this->rules)) {
|
||||
$finalRules = $this->rules;
|
||||
}
|
||||
// 其次使用方法定义(如果方法返回非空数组)
|
||||
elseif (method_exists($this, 'rules')) {
|
||||
$methodRules = $this->rules();
|
||||
if (!empty($methodRules)) {
|
||||
$finalRules = $methodRules;
|
||||
} else {
|
||||
$finalRules = [];
|
||||
}
|
||||
} else {
|
||||
$finalRules = [];
|
||||
}
|
||||
} else {
|
||||
$finalRules = $rules;
|
||||
}
|
||||
|
||||
// 3. 获取错误消息(优先类内message方法)
|
||||
if (empty($this->message) && method_exists($this, 'message')) {
|
||||
$this->message = $this->message();
|
||||
}
|
||||
|
||||
// 4. 处理场景字段过滤(only)
|
||||
if (!empty($this->only)) {
|
||||
$sceneRules = [];
|
||||
$method = strtolower($this->request()->method());
|
||||
$method = $method === 'post' ? 'post' : 'get';
|
||||
foreach ($this->only as $field) {
|
||||
if (isset($finalRules[$field])) {
|
||||
$sceneRules[$field] = $finalRules[$field];
|
||||
// 若数据为空,从请求中获取对应字段值
|
||||
if (empty($data)) {
|
||||
$data[$field] = $this->request()->{$method}($field);
|
||||
}
|
||||
}
|
||||
}
|
||||
$finalRules = $sceneRules;
|
||||
} else {
|
||||
// 若未指定only,从请求中获取全部数据(若数据为空)
|
||||
if (empty($data)) {
|
||||
$data = $this->request()->all();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行验证
|
||||
$validator = validator($data, $finalRules, $this->message);
|
||||
|
||||
// 6. 处理验证结果
|
||||
$this->currentScene = null;
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->handleValidationFailure($validator);
|
||||
}
|
||||
|
||||
// 验证通过:保存有效数据
|
||||
$this->data = $validator->validated();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载场景规则
|
||||
*
|
||||
* @param string $scene 场景名
|
||||
*/
|
||||
protected function loadSceneRules(string $scene): void
|
||||
{
|
||||
$this->only = [];
|
||||
$sceneAction = Str::studly($scene);
|
||||
|
||||
// 优先调用sceneXxx方法(如sceneAdd)
|
||||
if (method_exists($this, $sceneAction)) {
|
||||
call_user_func([$this, $sceneAction]);
|
||||
} // 其次读取scene数组配置
|
||||
elseif (isset($this->scene[$scene])) {
|
||||
$this->only = $this->scene[$scene];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理验证失败:修正异常抛出逻辑
|
||||
*
|
||||
* @param Validator $validator 验证器实例
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function handleValidationFailure(Validator $validator): bool
|
||||
{
|
||||
$errorMsg = $validator->errors()->first();
|
||||
if ($this->failException) {
|
||||
// 抛出正确的ValidationException(传递状态码)
|
||||
throw new ValidationException($errorMsg, [
|
||||
'statusCode' => $this->statusCode,
|
||||
'errorCode' => $this->code,
|
||||
'headers' => [],
|
||||
]
|
||||
);
|
||||
}
|
||||
// 不抛异常:保存错误信息
|
||||
$this->error = $errorMsg;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行验证并返回验证通过的数据
|
||||
*
|
||||
* @param array $data 待验证数据(为空则从请求取)
|
||||
* @param array $rules 自定义规则(为空则用类内规则)
|
||||
* @param array $message 自定义错误消息(为空则用类内消息)
|
||||
* @param bool $returnAllData 是否返回全部数据(包括未验证字段)
|
||||
*
|
||||
* @return array 验证通过的数据
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function validate(array $data = [], array $rules = [], array $message = [], bool $returnAllData = false): array
|
||||
{
|
||||
// 保存原始消息以便验证后恢复
|
||||
$originalMessage = $this->message;
|
||||
try {
|
||||
if (!empty($message)) {
|
||||
$this->setMessage($message);
|
||||
}
|
||||
// 执行验证
|
||||
$this->check($data, $rules);
|
||||
|
||||
// 根据参数决定返回验证数据还是全部数据
|
||||
if ($returnAllData && !empty($data)) {
|
||||
return array_merge($data, $this->data);
|
||||
}
|
||||
return $this->data;
|
||||
} finally {
|
||||
$this->message = $originalMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证通过的有效数据
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getData(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证失败错误信息
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getError(): ?string
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前验证场景
|
||||
*
|
||||
* @param string $scene 场景名
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function scene(string $scene): self
|
||||
{
|
||||
$this->currentScene = $scene;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否抛出验证异常
|
||||
*
|
||||
* @param bool $flag 是否抛出
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function failException(bool $flag): self
|
||||
{
|
||||
$this->failException = $flag;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Webman请求对象
|
||||
*
|
||||
* @return Request
|
||||
*/
|
||||
protected function request(): Request
|
||||
{
|
||||
return request();
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义验证规则(子类重写)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Vendored
+147
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\cache;
|
||||
|
||||
use Symfony\Component\Cache\Adapter\AdapterInterface;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
||||
|
||||
class CacheService
|
||||
{
|
||||
private AdapterInterface $cache;
|
||||
private string $namespace; // 命名空间
|
||||
private string $prefix; // 默认前缀
|
||||
private string $adapter; // 缓存适配器类型
|
||||
private ?\Redis $redis; // Redis 实例
|
||||
|
||||
/**
|
||||
* @throws \RedisException
|
||||
*/
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
// 获取框架的 Redis 配置
|
||||
$options = array_merge([
|
||||
'host' => '127.0.0.1',
|
||||
'password' => null,
|
||||
'port' => 6379,
|
||||
'database' => 0,
|
||||
], config('cache.custom.default', []));
|
||||
$this->adapter = config('cache.custom.type', 'file');
|
||||
$this->prefix = config('cache.custom.prefix', '');
|
||||
$this->namespace = config('cache.custom.namespace', '');
|
||||
$this->redis = null;
|
||||
$this->initialize($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \RedisException
|
||||
*/
|
||||
private function initialize(array $options): void
|
||||
{
|
||||
if ($this->adapter === 'redis') {
|
||||
$this->redis = new \Redis();
|
||||
$this->redis->connect($options['host'], $options['port'] ?? 6379); // 默认端口6379
|
||||
if (!empty($options['password'])) {
|
||||
$this->redis->auth($options['password']);
|
||||
}
|
||||
$this->cache = new RedisAdapter($this->redis, $this->namespace);
|
||||
} else {
|
||||
//默认file模式
|
||||
$this->cache = new FilesystemAdapter($this->prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// 读取缓存
|
||||
public function get(string $key, $default = null)
|
||||
{
|
||||
$item = $this->cache->getItem($this->prefix . $key);
|
||||
return $item->isHit() ? $item->get() : $default;
|
||||
}
|
||||
|
||||
// 写入缓存
|
||||
public function set(string $key, $value, int $ttl = 3600): void
|
||||
{
|
||||
$item = $this->cache->getItem($this->prefix . $key);
|
||||
$item->set($value);
|
||||
$item->expiresAfter($ttl);
|
||||
$this->cache->save($item);
|
||||
}
|
||||
|
||||
// 读取缓存没有则回调查询
|
||||
public function remember(string $key, callable $callback, int $ttl = 3600)
|
||||
{
|
||||
$value = $this->get($key);
|
||||
if ($value === null) {
|
||||
$value = $callback();
|
||||
$this->set($key, $value, $ttl);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
// 删除
|
||||
public function delete(string $key)
|
||||
{
|
||||
$this->cache->deleteItem($this->prefix . $key);
|
||||
}
|
||||
|
||||
// 清空缓存
|
||||
public function clear(string $prefix = ''): void
|
||||
{
|
||||
$this->clearByPrefix($prefix);
|
||||
}
|
||||
|
||||
private function clearByPrefix(string $prefix): void
|
||||
{
|
||||
if ($this->adapter === 'redis' && $this->redis) {
|
||||
if (!empty($this->namespace)) {
|
||||
$prefix = $this->namespace . ':' . $prefix;
|
||||
}
|
||||
$keys = $this->redis->keys($prefix . '*'); // 查找所有匹配的键
|
||||
if (!empty($keys)) {
|
||||
$this->redis->del($keys); // 删除匹配的键
|
||||
}
|
||||
} else {
|
||||
$this->cache->clearPrefix($prefix); // 对于文件系统适配器,使用 clearPrefix
|
||||
}
|
||||
}
|
||||
|
||||
// 设置锁
|
||||
public function setLock(string $lockKey, int $ttl = 30): bool
|
||||
{
|
||||
if ($this->redis) {
|
||||
return $this->redis->set($lockKey, 'locked', ['nx', 'ex' => $ttl]);
|
||||
}
|
||||
return false; // Redis 未初始化,返回 false
|
||||
}
|
||||
|
||||
// 释放锁
|
||||
public function releaseLock(string $lockKey): void
|
||||
{
|
||||
if ($this->redis) {
|
||||
$this->redis->del($lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查锁
|
||||
public function checkLock(string $lockKey): bool
|
||||
{
|
||||
return $this->redis && $this->redis->exists($lockKey) === 1;
|
||||
}
|
||||
|
||||
// 检查键是否存在
|
||||
public function keyExists(string $key): bool
|
||||
{
|
||||
$item = $this->cache->getItem($this->prefix . $key);
|
||||
return $item->isHit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\captcha;
|
||||
|
||||
use core\exception\handler\CaptchaException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use support\Redis;
|
||||
use support\Request;
|
||||
use Throwable;
|
||||
use Webman\Captcha\CaptchaBuilder;
|
||||
use Webman\Captcha\PhraseBuilder;
|
||||
|
||||
class Captcha
|
||||
{
|
||||
/**
|
||||
* 生成验证码
|
||||
*
|
||||
* @param \support\Request|null $request
|
||||
* @param string $type
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function captcha(Request|null $request = null, string $type = 'admin-login'): array
|
||||
{
|
||||
if (empty($request)) {
|
||||
$request = \request();
|
||||
}
|
||||
$builder = new PhraseBuilder(4, 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ');
|
||||
|
||||
$captcha = new CaptchaBuilder(null, $builder);
|
||||
$captcha->setBackgroundColor(242, 243, 245);
|
||||
$captcha->build(120, 36);
|
||||
|
||||
$uuid = Uuid::uuid4();
|
||||
$uuid = $uuid->toString();
|
||||
// 获取配置
|
||||
$config = $this->getCaptchaConfig();
|
||||
$code = strtolower($captcha->getPhrase());
|
||||
|
||||
// 存储验证码
|
||||
$this->storeCaptcha($uuid, $code, $config['mode'], $config['expire'], $request);
|
||||
|
||||
// 返回验证码图像
|
||||
$img_content = $captcha->get();
|
||||
|
||||
$base64 = 'data:image/png;base64,' . base64_encode($img_content);
|
||||
|
||||
return compact('uuid', 'base64');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成手机验证码
|
||||
*
|
||||
* @param \support\Request|null $request
|
||||
* @param int $length
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function generateSmsCaptcha(Request|null $request = null, int $length = 6): array
|
||||
{
|
||||
if (empty($request)) {
|
||||
$request = \request();
|
||||
}
|
||||
$uid = $request->input('mobile_phone');
|
||||
$this->validatePhoneNumber($uid);
|
||||
|
||||
// 生成 6 位数字验证码
|
||||
$code = str_pad(rand(0, 999999), $length, '0', STR_PAD_LEFT);
|
||||
|
||||
// 获取配置
|
||||
$config = $this->getCaptchaConfig();
|
||||
|
||||
// 存储验证码
|
||||
$this->storeSmsCaptcha($uid, $code, $config['mode'], $config['expire'], $request);
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'uid' => $uid,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查验证码
|
||||
*
|
||||
* @param string $key
|
||||
* @param string|int $value
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public
|
||||
function check(string $key, string|int $value): bool
|
||||
{
|
||||
$config = $this->getCaptchaConfig();
|
||||
$code = $this->retrieveCaptcha($key, $config['mode']);
|
||||
|
||||
return strtolower($value) === $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码配置
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getCaptchaConfig(): array
|
||||
{
|
||||
return [
|
||||
'mode' => config('core.captcha.app.mode', 'session'),
|
||||
'expire' => config('core.captcha.app.expire', 300),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储验证码
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $code
|
||||
* @param string $mode
|
||||
* @param int $expire
|
||||
* @param Request $request
|
||||
*
|
||||
* @throws CaptchaException
|
||||
*/
|
||||
protected function storeCaptcha(string $key, string $code, string $mode, int $expire, Request $request): void
|
||||
{
|
||||
try {
|
||||
if ($mode === 'redis') {
|
||||
Redis::set($key, $code, 'EX', $expire);
|
||||
} else {
|
||||
$request->session()->set($key, $code);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new CaptchaException('验证码生成失败,请检查配置', -1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储手机验证码
|
||||
*
|
||||
* @param string $uid
|
||||
* @param string $code
|
||||
* @param string $mode
|
||||
* @param int $expire
|
||||
* @param Request $request
|
||||
*
|
||||
* @throws CaptchaException
|
||||
*/
|
||||
protected
|
||||
function storeSmsCaptcha(string $uid, string $code, string $mode, int $expire, Request $request): void
|
||||
{
|
||||
try {
|
||||
if ($mode === 'redis') {
|
||||
Redis::set($uid, $code, 'EX', $expire);
|
||||
} else {
|
||||
$request->session()->set($uid, $code);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new CaptchaException('验证码生成失败,请检查配置', -1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号
|
||||
*
|
||||
* @param string $uid
|
||||
*
|
||||
* @throws CaptchaException
|
||||
*/
|
||||
protected
|
||||
function validatePhoneNumber(string $uid): void
|
||||
{
|
||||
if (!$uid || !preg_match('/^\+?[0-9]{10,15}$/', $uid)) {
|
||||
throw new CaptchaException('无效的手机号');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从存储中检索验证码
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $mode
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
protected
|
||||
function retrieveCaptcha(string $key, string $mode): ?string
|
||||
{
|
||||
try {
|
||||
if ($mode === 'redis') {
|
||||
$code = Redis::get($key);
|
||||
Redis::del($key); // 删除验证码
|
||||
} else {
|
||||
$code = session($key);
|
||||
session()->forget($key); // 删除验证码
|
||||
}
|
||||
return $code;
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
'captcha' => [
|
||||
'mode' => 'session',//session||redis
|
||||
'expire' => 300,//验证码过期时间
|
||||
'default' => [
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\db;
|
||||
|
||||
use core\uuid\Snowflake;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* PDO导入数据服务类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class DataImporterService
|
||||
{
|
||||
|
||||
/**
|
||||
* 获取 PDO 连接的方法
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getPdo($host, $username, $password, $port, $database = null): \PDO
|
||||
{
|
||||
try {
|
||||
$dsn = "mysql:host=$host;port=$port;";
|
||||
if ($database) {
|
||||
$dsn .= "dbname=$database";
|
||||
}
|
||||
$params = [
|
||||
\PDO::MYSQL_ATTR_INIT_COMMAND => "set names utf8mb4",//连接建立后立即执行的SQL命令
|
||||
\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, //查询结果使用缓冲模式
|
||||
\PDO::ATTR_EMULATE_PREPARES => false, //禁用后使用真正的预处理语句,可防御SQL注入;启用(true)时仅做字符串转义(默认行为)
|
||||
\PDO::ATTR_TIMEOUT => 5, //链接超时时间
|
||||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,//错误处理模式
|
||||
];
|
||||
return new \PDO($dsn, $username, $password, $params);
|
||||
} catch (\Throwable $e) {
|
||||
if (stripos($e, 'Access denied for user')) {
|
||||
throw new \Exception('数据库用户名或密码错误');
|
||||
}
|
||||
if (stripos($e, 'Connection refused')) {
|
||||
throw new \Exception('Connection refused. 请确认数据库IP端口是否正确,数据库已经启动');
|
||||
}
|
||||
if (stripos($e, 'timed out')) {
|
||||
throw new \Exception('数据库连接超时,请确认数据库IP端口是否正确,安全组及防火墙已经放行端口');
|
||||
}
|
||||
throw new \Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入通用数据
|
||||
*
|
||||
* @param \PDO $pdo
|
||||
* @param string $tableName
|
||||
* @param array $fields
|
||||
* @param array $data
|
||||
* @param array $options
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function importData(\PDO $pdo, string $tableName, array $fields, array $data, array $options = []): void
|
||||
{
|
||||
$defaultOptions = [
|
||||
'idKey' => 'id',
|
||||
'useSnowflakeId' => true,
|
||||
'useAutoIncrement' => false,
|
||||
'pidKey' => 'pid',
|
||||
];
|
||||
$options = array_merge($defaultOptions, $options);
|
||||
|
||||
// MySQL 保留关键字列表
|
||||
$reservedWords = ['database', 'select', 'insert', 'update', 'delete', 'where'];
|
||||
|
||||
foreach ($data as $row) {
|
||||
if ($options['useSnowflakeId']) {
|
||||
$fields[] = $options['idKey'];
|
||||
if (empty($row[$options['idKey']])) {
|
||||
$row[$options['idKey']] = Snowflake::generate();
|
||||
}
|
||||
} elseif ($options['useAutoIncrement']) {
|
||||
unset($row[$options['idKey']]);
|
||||
}
|
||||
|
||||
$filteredRow = array_intersect_key($row, array_flip($fields));
|
||||
|
||||
// 转义保留关键字
|
||||
$columns = implode(", ", array_map(function ($col) use ($reservedWords) {
|
||||
return in_array(strtolower($col), $reservedWords) ? "`$col`" : $col;
|
||||
}, array_keys($filteredRow)));
|
||||
|
||||
$placeholders = ":" . implode(", :", array_keys($filteredRow));
|
||||
|
||||
$sql = "INSERT INTO `$tableName` ($columns) VALUES ($placeholders)";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
|
||||
foreach ($filteredRow as $key => $value) {
|
||||
$stmt->bindValue(":$key", $value);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入树形数据
|
||||
*
|
||||
* @param \PDO $pdo
|
||||
* @param string $tableName
|
||||
* @param array $fields
|
||||
* @param array $data
|
||||
* @param array $options
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function importTreeData(\PDO $pdo, string $tableName, array $fields, array $data, array $options = []): void
|
||||
{
|
||||
// 默认选项
|
||||
$defaultOptions = [
|
||||
'idKey' => 'id', // ID 字段的键名
|
||||
'useSnowflakeId' => true, // 是否使用雪花ID
|
||||
'useAutoIncrement' => false, // 是否使用自增ID
|
||||
'pidKey' => 'pid', // 父节点ID字段的键名
|
||||
'pidDefault' => 0, //pid默认值
|
||||
];
|
||||
// 合并选项
|
||||
$options = array_merge($defaultOptions, $options);
|
||||
|
||||
$this->recursiveImport($pdo, $tableName, $fields, $data, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入树形数据-递归
|
||||
*
|
||||
* @param \PDO $pdo
|
||||
* @param string $tableName
|
||||
* @param array $fields
|
||||
* @param array $data
|
||||
* @param string|int|null $parentId
|
||||
* @param array $options
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function recursiveImport(\PDO $pdo, string $tableName, array $fields, array $data, null|string|int $parentId, array $options): void
|
||||
{
|
||||
// 默认选项
|
||||
$defaultOptions = [
|
||||
'idKey' => 'id', // ID 字段的键名
|
||||
'useSnowflakeId' => true, // 是否使用雪花ID
|
||||
'useAutoIncrement' => false, // 是否使用自增ID
|
||||
'pidKey' => 'pid', // 父节点ID字段的键名
|
||||
'pidDefault' => 0, //pid默认值
|
||||
];
|
||||
// 合并选项
|
||||
$options = array_merge($defaultOptions, $options);
|
||||
foreach ($data as $key => $item) {
|
||||
// 如果需要使用雪花ID
|
||||
if ($options['useSnowflakeId']) {
|
||||
$item[$options['idKey']] = Snowflake::generate(); // 生成雪花ID
|
||||
} elseif ($options['useAutoIncrement']) {
|
||||
unset($item[$options['idKey']]); // 自增ID由数据库处理
|
||||
}
|
||||
// 设置父节点ID
|
||||
$item[$options['pidKey']] = $parentId;
|
||||
if (empty($parentId)) {
|
||||
$item[$options['pidKey']] = $options['pidDefault'] ?? null;
|
||||
}
|
||||
|
||||
// 导入当前节点
|
||||
$this->importData($pdo, $tableName, $fields, [$item], $options);
|
||||
|
||||
// 获取当前节点的ID
|
||||
$currentId = $item[$options['idKey']];
|
||||
|
||||
// 如果有子节点,递归导入
|
||||
if (isset($item['children']) && is_array($item['children'])) {
|
||||
$this->recursiveImport($pdo, $tableName, $fields, $item['children'], $currentId, $options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入字母表数据
|
||||
*
|
||||
* @param \PDO $pdo
|
||||
* @param string $mainTable
|
||||
* @param array $mainFields
|
||||
* @param string $itemsTable
|
||||
* @param array $itemsFields
|
||||
* @param array $data
|
||||
* @param array $options
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function importWithRelated(\PDO $pdo, string $mainTable, array $mainFields, string $itemsTable, array $itemsFields, array $data, array $options = []): void
|
||||
{
|
||||
// 默认选项
|
||||
$defaultOptions = [
|
||||
'idKey' => 'id', // ID 字段的键名
|
||||
'useSnowflakeId' => true, // 是否使用雪花ID
|
||||
'useAutoIncrement' => false, // 是否使用自增ID
|
||||
'pidKey' => 'pid', // 父节点ID字段的键名
|
||||
];
|
||||
$options = array_merge($defaultOptions, $options);
|
||||
foreach ($data as $item) {
|
||||
// 如果需要使用雪花ID
|
||||
if ($options['useSnowflakeId']) {
|
||||
$item[$options['idKey']] = Snowflake::generate(); // 生成雪花ID
|
||||
} elseif ($options['useAutoIncrement']) {
|
||||
unset($item[$options['idKey']]); // 自增ID由数据库处理
|
||||
}
|
||||
// 导入当前节点
|
||||
$this->importData($pdo, $mainTable, $mainFields, [$item], $options);
|
||||
// 获取当前节点的ID
|
||||
$currentId = $item[$options['idKey']];
|
||||
// 如果有items子节点导入
|
||||
if (isset($item['items']) && is_array($item['items'])) {
|
||||
$items = array_map(function ($item) use ($options, $currentId) {
|
||||
$item[$options['pidKey']] = $currentId;
|
||||
return $item;
|
||||
}, $item['items']);
|
||||
$itemsFields[] = $options['pidKey'];
|
||||
//导入子表数据
|
||||
$this->importData($pdo, $itemsTable, $itemsFields, $items, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装数据库表结构(支持表前缀)
|
||||
*
|
||||
* @param PDO $pdo PDO数据库连接对象
|
||||
* @param array $tablesToInstall 需要安装的表名数组
|
||||
* @param string $database 数据库名
|
||||
* @param bool $overwrite 是否覆盖已存在的表
|
||||
* @param string $tablePrefix 表前缀(可选)
|
||||
* @param string|null $sqlFilePath SQL文件路径(可选)
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function installDatabaseTables(\PDO $pdo, array $tablesToInstall, string $database, bool $overwrite = false, string $tablePrefix = '', ?string $sqlFilePath = null): void
|
||||
{
|
||||
// 设置默认SQL文件路径
|
||||
if ($sqlFilePath === null) {
|
||||
$sqlFilePath = base_path() . '/resource/sql/install.sql';
|
||||
}
|
||||
|
||||
// 获取当前数据库所有表名
|
||||
$tables = $this->getExistingTables($pdo, $database);
|
||||
|
||||
// 检测插入的数据表冲突
|
||||
$tables_exist = [];
|
||||
foreach ($tables as $table) {
|
||||
$tables_exist[] = current($table);
|
||||
}
|
||||
|
||||
// 如果指定了表前缀,需要检查带前缀的表名冲突
|
||||
$tablesConflict = [];
|
||||
if (!empty($tablePrefix)) {
|
||||
$prefixedTablesToInstall = array_map(function ($table) use ($tablePrefix) {
|
||||
return $tablePrefix . $table;
|
||||
}, $tablesToInstall);
|
||||
$tablesConflict = array_intersect($prefixedTablesToInstall, $tables_exist);
|
||||
} else {
|
||||
$tablesConflict = array_intersect($tablesToInstall, $tables_exist);
|
||||
}
|
||||
|
||||
// 处理表冲突
|
||||
if (!$overwrite && !empty($tablesConflict)) {
|
||||
throw new \Exception('以下表已存在且未选择覆盖模式: ' . implode(', ', $tablesConflict));
|
||||
}
|
||||
|
||||
try {
|
||||
// 删除冲突表(覆盖模式时)
|
||||
if ($overwrite) {
|
||||
foreach ($tablesConflict as $table) {
|
||||
$pdo->exec("DROP TABLE IF EXISTS `$table`");
|
||||
}
|
||||
}
|
||||
|
||||
// 执行SQL安装文件
|
||||
if (!file_exists($sqlFilePath)) {
|
||||
throw new \Exception('数据库安装SQL文件不存在: ' . $sqlFilePath);
|
||||
}
|
||||
|
||||
$sqlContent = file_get_contents($sqlFilePath);
|
||||
$sql_query = $this->removeComments($sqlContent);
|
||||
|
||||
// 如果指定了表前缀,替换SQL中的表名
|
||||
if (!empty($tablePrefix)) {
|
||||
$sql_query = $this->addPrefixToSqlTables($sql_query, $tablePrefix);
|
||||
}
|
||||
|
||||
$sqlQueries = $this->splitSqlFile($sql_query, ';');
|
||||
|
||||
foreach ($sqlQueries as $query) {
|
||||
if (!empty(trim($query))) {
|
||||
$pdo->exec($query);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('数据库表安装失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一添加表前缀到SQL语句中的表名
|
||||
* User GQL
|
||||
* Date 2025/12/26 17:18
|
||||
*
|
||||
* @param string $sqlContent
|
||||
* @param string $tablePrefix
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function addPrefixToSqlTables(string $sqlContent, string $tablePrefix): string
|
||||
{
|
||||
// 规范化前缀
|
||||
$tablePrefix = rtrim($tablePrefix, '_');
|
||||
|
||||
// 1. 提取 CREATE TABLE 中的表名(包含已有前缀)
|
||||
$tableNames = [];
|
||||
$createPattern = '/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?' .
|
||||
'(?:`(?P<schema_bt>[^`]+)`|(?P<schema_nb>[A-Za-z0-9_]+))?\s*\.?\s*' .
|
||||
'(?:`(?P<table_bt>[^`]+)`|(?P<table_nb>[A-Za-z0-9_]+))/i';
|
||||
|
||||
if (preg_match_all($createPattern, $sqlContent, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $m) {
|
||||
$tbl = isset($m['table_bt']) && $m['table_bt'] !== '' ? $m['table_bt'] : $m['table_nb'];
|
||||
if ($tbl) $tableNames[$tbl] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tableNames)) {
|
||||
$fallbackPattern = '/(?:DROP|INSERT\s+INTO|REPLACE\s+INTO|ALTER\s+TABLE|TRUNCATE\s+TABLE)\s+' .
|
||||
'(?:`(?P<table_bt>[^`]+)`|(?P<table_nb>[A-Za-z0-9_]+))/i';
|
||||
if (preg_match_all($fallbackPattern, $sqlContent, $fm, PREG_SET_ORDER)) {
|
||||
foreach ($fm as $m) {
|
||||
$tbl = isset($m['table_bt']) && $m['table_bt'] !== '' ? $m['table_bt'] : $m['table_nb'];
|
||||
if ($tbl) $tableNames[$tbl] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tableNames)) return $sqlContent;
|
||||
$tableNames = array_keys($tableNames);
|
||||
|
||||
$contexts = [
|
||||
'CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?',
|
||||
'DROP\s+TABLE(?:\s+IF\s+EXISTS)?',
|
||||
'INSERT\s+(?:IGNORE\s+)?INTO',
|
||||
'REPLACE\s+INTO',
|
||||
'ALTER\s+TABLE',
|
||||
'TRUNCATE\s+TABLE',
|
||||
'UPDATE',
|
||||
'DELETE\s+FROM',
|
||||
'REFERENCES',
|
||||
'FROM',
|
||||
'JOIN',
|
||||
];
|
||||
|
||||
foreach ($tableNames as $tbl) {
|
||||
foreach ($contexts as $ctx) {
|
||||
$pattern = '/(' . $ctx . '\s+)' .
|
||||
'(?:' .
|
||||
'`(?P<schema_bt>[^`]+)`\s*\.\s*' .
|
||||
')?' .
|
||||
'(?:' .
|
||||
'`(?P<table_bt>[^`]+)`' .
|
||||
'|' .
|
||||
'(?P<table_nb>[A-Za-z0-9_]+)' .
|
||||
')/iu';
|
||||
|
||||
$sqlContent = preg_replace_callback($pattern, function ($m) use ($tbl, $tablePrefix) {
|
||||
$schema = '';
|
||||
if (!empty($m['schema_bt'])) $schema = '`' . $m['schema_bt'] . '`.';
|
||||
|
||||
$origTable = '';
|
||||
$hasBacktick = false;
|
||||
if (!empty($m['table_bt'])) {
|
||||
$origTable = $m['table_bt'];
|
||||
$hasBacktick = true;
|
||||
} elseif (!empty($m['table_nb'])) {
|
||||
$origTable = $m['table_nb'];
|
||||
}
|
||||
|
||||
if (strcasecmp($origTable, $tbl) !== 0) return $m[0];
|
||||
|
||||
// 如果规范化后前缀为空,表示不需要加前缀
|
||||
if ($tablePrefix === '') {
|
||||
return $m[1] . $schema . ($hasBacktick ? '`' . $origTable . '`' : $origTable);
|
||||
}
|
||||
|
||||
// 如果已经以目标前缀开头(例如 md_),则保留原样(不重复加)
|
||||
if (preg_match('/^' . preg_quote($tablePrefix, '/') . '_/i', $origTable)) {
|
||||
return $m[1] . $schema . ($hasBacktick ? '`' . $origTable . '`' : $origTable);
|
||||
}
|
||||
|
||||
// 构造新表名:前缀 + '_' + 原表名(保证只有一个下划线)
|
||||
$newNameCore = $tablePrefix . '_' . $origTable;
|
||||
if ($hasBacktick || strpos($schema, '`') !== false) {
|
||||
$newNameCore = '`' . $newNameCore . '`';
|
||||
}
|
||||
|
||||
return $m[1] . $schema . $newNameCore;
|
||||
}, $sqlContent);
|
||||
}
|
||||
|
||||
$pattern2 = '/(?<![A-Za-z0-9_`\.])' .
|
||||
'(?:`(?P<schema_bt>[^`]+)`\s*\.\s*)?' .
|
||||
'(?:`(?P<table_bt>[^`]+)`|(?P<table_nb>[A-Za-z0-9_]+))' .
|
||||
'(?![A-Za-z0-9_`\.])/u';
|
||||
|
||||
$sqlContent = preg_replace_callback($pattern2, function ($m) use ($tbl, $tablePrefix) {
|
||||
$schema = '';
|
||||
if (!empty($m['schema_bt'])) $schema = '`' . $m['schema_bt'] . '`.';
|
||||
|
||||
$origTable = !empty($m['table_bt']) ? $m['table_bt'] : $m['table_nb'] ?? '';
|
||||
if ($origTable === '' || strcasecmp($origTable, $tbl) !== 0) return $m[0];
|
||||
|
||||
if ($tablePrefix === '') return $m[0];
|
||||
|
||||
if (preg_match('/^' . preg_quote($tablePrefix, '/') . '_/i', $origTable)) return $m[0];
|
||||
|
||||
$newName = $tablePrefix . '_' . $origTable;
|
||||
if (!empty($m['table_bt']) || !empty($m['schema_bt'])) {
|
||||
$newName = '`' . $newName . '`';
|
||||
}
|
||||
return $schema . $newName;
|
||||
}, $sqlContent);
|
||||
}
|
||||
|
||||
return $sqlContent;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取当前数据库所有表名
|
||||
*
|
||||
* @param \PDO $pdo
|
||||
* @param string $database
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getExistingTables(\PDO $pdo, string $database): array
|
||||
{
|
||||
$smt = $pdo->query("show databases like '$database'");
|
||||
if (empty($smt->fetchAll())) {
|
||||
$pdo->exec("create database $database");
|
||||
}
|
||||
$pdo->exec("use $database");
|
||||
$smt = $pdo->query("show tables");
|
||||
return $smt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除sql文件中的注释
|
||||
*
|
||||
* @param $sql
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function removeComments($sql): string
|
||||
{
|
||||
return preg_replace("/(\n--[^\n]*)/", "", $sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割sql文件
|
||||
*
|
||||
* @param $sql
|
||||
* @param $delimiter
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
function splitSqlFile($sql, $delimiter): array
|
||||
{
|
||||
$tokens = explode($delimiter, $sql);
|
||||
$output = array();
|
||||
$matches = array();
|
||||
$token_count = count($tokens);
|
||||
for ($i = 0; $i < $token_count; $i++) {
|
||||
if (($i != ($token_count - 1)) || (strlen($tokens[$i] > 0))) {
|
||||
$total_quotes = preg_match_all("/'/", $tokens[$i], $matches);
|
||||
$escaped_quotes = preg_match_all("/(?<!\\\\)(\\\\\\\\)*\\\\'/", $tokens[$i], $matches);
|
||||
$unescaped_quotes = $total_quotes - $escaped_quotes;
|
||||
|
||||
if (($unescaped_quotes % 2) == 0) {
|
||||
$output[] = $tokens[$i];
|
||||
$tokens[$i] = "";
|
||||
} else {
|
||||
$temp = $tokens[$i] . $delimiter;
|
||||
$tokens[$i] = "";
|
||||
|
||||
$complete_stmt = false;
|
||||
for ($j = $i + 1; (!$complete_stmt && ($j < $token_count)); $j++) {
|
||||
$total_quotes = preg_match_all("/'/", $tokens[$j], $matches);
|
||||
$escaped_quotes = preg_match_all("/(?<!\\\\)(\\\\\\\\)*\\\\'/", $tokens[$j], $matches);
|
||||
$unescaped_quotes = $total_quotes - $escaped_quotes;
|
||||
if (($unescaped_quotes % 2) == 1) {
|
||||
$output[] = $temp . $tokens[$j];
|
||||
$tokens[$j] = "";
|
||||
$temp = "";
|
||||
$complete_stmt = true;
|
||||
$i = $j;
|
||||
} else {
|
||||
$temp .= $tokens[$j] . $delimiter;
|
||||
$tokens[$j] = "";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\db;
|
||||
|
||||
use PDO;
|
||||
use Webman\Exception\BusinessException;
|
||||
|
||||
class DbConfigManagerService
|
||||
{
|
||||
private const CACHE_KEY = 'db_config_cache';
|
||||
private const CACHE_TTL = 300; // 5分钟缓存
|
||||
|
||||
private const TABLE_NAME = 'ma_mt_db_setting';//多数据源表
|
||||
|
||||
// 静态调用入口
|
||||
public static function getConfig(array $base = [], string $defaultKey = 'mysql', bool $isDefaultAppend = false): array
|
||||
{
|
||||
try {
|
||||
$installLockPath = base_path() . '/install.lock';
|
||||
$envFilePath = base_path() . '/.env';
|
||||
if (!file_exists($installLockPath) || !file_exists($envFilePath)) {
|
||||
// 系统未安装,返回空数组
|
||||
return [];
|
||||
}
|
||||
|
||||
if (empty($base)) {
|
||||
throw new BusinessException('Missing database name in base config');
|
||||
}
|
||||
$config = self::queryDbConfig($base);
|
||||
if ($isDefaultAppend && !empty($defaultKey)) {
|
||||
$config[$defaultKey] = $base;
|
||||
}
|
||||
//无效缓存配置程序启动载入后不在载入
|
||||
// Cache::set(self::CACHE_KEY, $config, self::CACHE_TTL);
|
||||
return $config;
|
||||
} catch
|
||||
(\PDOException $e) {
|
||||
throw new BusinessException('数据库配置获取失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static function queryDbConfig(array $base): array
|
||||
{
|
||||
$config = [];
|
||||
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s',
|
||||
$base['host'],
|
||||
$base['port'],
|
||||
$base['database']
|
||||
);
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
$base['username'],
|
||||
$base['password'],
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_general_ci'",
|
||||
]
|
||||
);
|
||||
|
||||
$table = self::TABLE_NAME;
|
||||
$sql = "SELECT `database`, `username`, `password`, `prefix`, `driver`, `host`,`port`
|
||||
FROM `{$table}`
|
||||
WHERE enabled = 1";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute();
|
||||
$tenants = $stmt->fetchAll();
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
$config[$tenant['database']] = [
|
||||
'driver' => $tenant['driver'] ?? 'mysql',
|
||||
'host' => $tenant['host'] ?? '127.0.0.1',
|
||||
'port' => (int)$tenant['port'] ?? 3306,
|
||||
'database' => $tenant['database'],
|
||||
'username' => $tenant['username'],
|
||||
'password' => $tenant['password'],
|
||||
'charset' => 'utf8mb4',
|
||||
'unix_socket' => $base['unix_socket'] ?? '',
|
||||
'collation' => 'utf8mb4_general_ci',
|
||||
'prefix' => $tenant['prefix'] ?? '',
|
||||
'strict' => $base['strict'] ?? false,
|
||||
'engine' => null,
|
||||
'pool' => $base['pool'] ?? [],
|
||||
];
|
||||
}
|
||||
$pdo = null;
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace core\email;
|
||||
|
||||
use app\service\admin\system\ConfigService;
|
||||
use core\exception\handler\AdminException;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* 邮件服务
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class MailService
|
||||
{
|
||||
|
||||
/**
|
||||
* config表的group_cod字段值
|
||||
*/
|
||||
const SETTING_GROUP_CODE = 'email';
|
||||
|
||||
protected ?PHPMailer $mailer;
|
||||
|
||||
public function __construct($host = null, $username = null, $password = null, $port = null, $encryption = null)
|
||||
{
|
||||
if (!extension_loaded('openssl')) {
|
||||
throw new \RuntimeException('PHP OpenSSL extension is not installed or enabled.');
|
||||
}
|
||||
if (!class_exists('PHPMailer\PHPMailer\PHPMailer')) {
|
||||
throw new \RuntimeException('请执行 composer require phpmailer/phpmailer');
|
||||
}
|
||||
$this->mailer = new PHPMailer(true); // 使用异常处理
|
||||
$this->configure($host, $username, $password, $port, $encryption);
|
||||
}
|
||||
|
||||
protected function configure(?string $host = null, ?string $username = null, ?string $password = null, ?string $port = null, ?string $encryption = null): void
|
||||
{
|
||||
$systemConfigService = Container::make(ConfigService::class);
|
||||
$config = $systemConfigService->config(self::SETTING_GROUP_CODE);
|
||||
$this->mailer->isSMTP(); // 使用 SMTP
|
||||
$this->mailer->Host = $host ?? $config['Host']; // 默认 SMTP 服务器地址
|
||||
$this->mailer->SMTPAuth = true; // 启用 SMTP 身份验证
|
||||
$this->mailer->Username = $username ?? $config['Username']; // 默认 SMTP 用户名
|
||||
$this->mailer->Password = $password ?? $config['Password']; // 默认 SMTP 密码
|
||||
$this->mailer->SMTPSecure = $encryption ?? $config['SMTPSecure']; // 默认加密方式
|
||||
$this->mailer->Port = $port ?? $config['Port']; // 默认 TCP 端口号
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $to 收件人邮箱
|
||||
* @param string $subject 主题
|
||||
* @param string $content 内容
|
||||
* @param string $fromName 发件人显示名称
|
||||
*
|
||||
* @return true|array
|
||||
* @throws \core\exception\handler\AdminException
|
||||
*/
|
||||
public function send(string $to, string $subject, string $content, string $fromName = ''): true|array
|
||||
{
|
||||
try {
|
||||
// 设置发件人 - 使用SMTP用户名作为发件人地址,$fromName作为显示名称
|
||||
$this->mailer->setFrom($this->mailer->Username, $fromName);
|
||||
|
||||
// 收件人
|
||||
$this->mailer->addAddress($to);
|
||||
|
||||
// 邮件内容
|
||||
$this->mailer->isHTML(true); // 设置邮件格式为 HTML
|
||||
$this->mailer->CharSet = 'UTF-8'; // 设置字符编码为 UTF-8
|
||||
$this->mailer->Subject = $subject;
|
||||
$this->mailer->Body = $content;
|
||||
|
||||
// 发送邮件
|
||||
$this->mailer->send();
|
||||
return true; // 发送成功
|
||||
} catch (Exception $e) {
|
||||
return throw new AdminException($e->getMessage());// 发送失败,返回错误信息
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\excel;
|
||||
|
||||
use app\service\admin\system\ConfigService;
|
||||
use Carbon\Carbon;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Csv;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use support\Container;
|
||||
|
||||
/**
|
||||
* Excel-vxe-table导出服务
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0.
|
||||
*/
|
||||
class ExcelExportService
|
||||
{
|
||||
|
||||
/**
|
||||
* @param array $params
|
||||
* @param callable $dataCallback
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function export(array $params, callable $dataCallback): array
|
||||
{
|
||||
try {
|
||||
// 验证字段配置
|
||||
$validFields = self::validateFields($params);
|
||||
|
||||
// 初始化工作表
|
||||
[$spreadsheet, $sheet, $fieldMap] = self::initSpreadsheet($params, $validFields);
|
||||
|
||||
// 生成表头
|
||||
$currentRow = self::generateHeader($sheet, $params, $fieldMap);
|
||||
|
||||
// 分块写入数据
|
||||
$currentRow = self::writeData($spreadsheet, $sheet, $currentRow, $fieldMap, $dataCallback);
|
||||
|
||||
// 保存文件
|
||||
$fileInfo = self::saveToDisk($spreadsheet, $params);
|
||||
|
||||
// 生成下载链接
|
||||
$downloadInfo = self::generateDownloadInfo($fileInfo['path'], $params);
|
||||
$downloadInfo['params']['file_path'] = $fileInfo['download_path'];
|
||||
return [
|
||||
'url' => $downloadInfo['url'] . '?file_path=' . $downloadInfo['params']['file_path'] . '&file=' . $downloadInfo['params']['file'],
|
||||
'expire' => $downloadInfo['expire'],
|
||||
'params' => $downloadInfo['params'],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
throw new \Exception('导出失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 传入字段验证
|
||||
*
|
||||
* @param array $params
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
private static function validateFields(array $params): array
|
||||
{
|
||||
$validFields = array_filter($params['fields'] ?? [], function ($field) {
|
||||
return !empty($field['field']) && !empty($field['title']);
|
||||
});
|
||||
if (empty($validFields)) {
|
||||
throw new \Exception("导出的字段配置无效:必须包含field和title");
|
||||
}
|
||||
return $validFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化工作表
|
||||
*
|
||||
* @param array $params
|
||||
* @param array $validFields
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function initSpreadsheet(array $params, array $validFields): array
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle(substr($params['sheetName'] ?? 'Sheet1', 0, 31));
|
||||
|
||||
$fieldMap = [];
|
||||
$columnIndex = 0;
|
||||
foreach ($validFields as $field) {
|
||||
$dbField = $field['field'];
|
||||
$fieldMap[$dbField] = [
|
||||
'title' => $field['title'],
|
||||
'column' => Coordinate::stringFromColumnIndex($columnIndex + 1),
|
||||
'format' => $field['format'] ?? null,
|
||||
];
|
||||
$columnIndex++;
|
||||
}
|
||||
|
||||
return [$spreadsheet, $sheet, $fieldMap];
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态表头
|
||||
*
|
||||
* @param $sheet
|
||||
* @param $params
|
||||
* @param $fieldMap
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function generateHeader($sheet, $params, $fieldMap): int
|
||||
{
|
||||
$currentRow = 1;
|
||||
if ($params['isHeader'] ?? true) {
|
||||
foreach ($fieldMap as $config) {
|
||||
$sheet->setCellValue($config['column'] . '1', $config['title']);
|
||||
self::setColumnStyle($sheet, $config); // 列宽及样式设置
|
||||
}
|
||||
$currentRow = 2;
|
||||
}
|
||||
return $currentRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态样式列
|
||||
*
|
||||
* @param $sheet
|
||||
* @param array $config
|
||||
*/
|
||||
private static function setColumnStyle($sheet, array $config): void
|
||||
{
|
||||
switch ($config['format']['type'] ?? 'text') {
|
||||
case 'date':
|
||||
$sheet->getColumnDimension($config['column'])->setWidth(20);
|
||||
$sheet->getStyle($config['column'])
|
||||
->getNumberFormat()
|
||||
->setFormatCode('yyyy-mm-dd hh:mm:ss');
|
||||
break;
|
||||
case 'json':
|
||||
$sheet->getColumnDimension($config['column'])->setWidth(40);
|
||||
$sheet->getStyle($config['column'])
|
||||
->getAlignment()
|
||||
->setWrapText(true);
|
||||
break;
|
||||
case 'multiline':
|
||||
$sheet->getColumnDimension($config['column'])->setWidth($config['format']['width'] ?? 24);
|
||||
$sheet->getStyle($config['column'])
|
||||
->getAlignment()
|
||||
->setWrapText(true);
|
||||
break;
|
||||
default:
|
||||
$sheet->getColumnDimension($config['column'])->setAutoSize(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分块写入数据
|
||||
*
|
||||
* @param $spreadsheet
|
||||
* @param $sheet
|
||||
* @param $currentRow
|
||||
* @param $fieldMap
|
||||
* @param callable $dataCallback
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function writeData($spreadsheet, $sheet, &$currentRow, $fieldMap, callable $dataCallback
|
||||
): int
|
||||
{
|
||||
$dataCallback(function ($dataChunk) use (&$currentRow, $sheet, $fieldMap, $spreadsheet) {
|
||||
foreach ($dataChunk as $item) {
|
||||
foreach ($fieldMap as $dbField => $config) {
|
||||
//这里可以扩展数据格式化以及公共枚举实现
|
||||
$rawValue = data_get($item, $dbField);
|
||||
if (is_array($rawValue) || is_object($rawValue)) {
|
||||
$rawValue = json_encode($rawValue);
|
||||
}
|
||||
$sheet->setCellValue($config['column'] . $currentRow, $rawValue);
|
||||
}
|
||||
$currentRow++;
|
||||
|
||||
// 内存优化:每处理500行释放内存
|
||||
if ($currentRow % 500 === 0) {
|
||||
$spreadsheet->garbageCollect();
|
||||
}
|
||||
}
|
||||
});
|
||||
return $currentRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件名安全过滤
|
||||
*/
|
||||
private static function sanitizeFilename(string $name): string
|
||||
{
|
||||
return preg_replace('/[\/\\\?\*\[\]]/', '', $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到磁盘
|
||||
*
|
||||
* @param \PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet
|
||||
* @param array $params
|
||||
*
|
||||
* @return array {path: string, filename: string}
|
||||
* @throws \Exception
|
||||
*/
|
||||
private static function saveToDisk(
|
||||
\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet,
|
||||
array $params
|
||||
): array
|
||||
{
|
||||
$fileName = self::sanitizeFilename($params['filename'] ?? 'export_' . date('YmdHis'));
|
||||
$format = strtolower((string)($params['format'] ?? 'xlsx'));
|
||||
$format = in_array($format, ['xlsx', 'csv'], true) ? $format : 'xlsx';
|
||||
// $path = '/exports/' . date('Ym') . '/';
|
||||
$path = '/exports/';//临时文件取消月份用完就删除
|
||||
$storagePath = runtime_path() . $path;
|
||||
|
||||
// 目录创建带重试机制(增强健壮性)
|
||||
if (!is_dir($storagePath) && !mkdir($storagePath, 0755, true)) {
|
||||
$retry = 0;
|
||||
while ($retry++ < 3 && !mkdir($storagePath, 0755, true)) {
|
||||
usleep(100000); // 等待100ms重试
|
||||
}
|
||||
if (!is_dir($storagePath)) {
|
||||
throw new \Exception("目录创建失败: {$storagePath}");
|
||||
}
|
||||
}
|
||||
$writer = $format === 'csv' ? new Csv($spreadsheet) : new Xlsx($spreadsheet);
|
||||
if ($writer instanceof Csv) {
|
||||
$writer->setUseBOM(true);
|
||||
$writer->setLineEnding("\r\n");
|
||||
}
|
||||
$filePath = $storagePath . $fileName . '.' . $format;
|
||||
$writer->save($filePath);
|
||||
unset($spreadsheet); // 显式释放内存
|
||||
|
||||
return [
|
||||
'path' => $filePath,
|
||||
'filename' => $fileName,
|
||||
'download_path' => $path . $fileName . '.' . $format,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下载信息
|
||||
*
|
||||
* @param string $filePath
|
||||
* @param array $params
|
||||
*
|
||||
* @return array {url: string, expire: string}
|
||||
*/
|
||||
private static function generateDownloadInfo(string $filePath, array $params): array
|
||||
{
|
||||
/** @var ConfigService $systemConfig */
|
||||
$systemConfig = Container::make(ConfigService::class);
|
||||
$baseUrl = rtrim((string) $systemConfig->config('site_url', '', ['group_code' => 'site_setting']), '/');
|
||||
// 文件信息解析
|
||||
$fileInfo = pathinfo($filePath);
|
||||
$expire = Carbon::today()->endOfDay()->timestamp;
|
||||
|
||||
return [
|
||||
'url' => "{$baseUrl}/export/download",
|
||||
'params' => [
|
||||
'file' => $fileInfo['basename'],
|
||||
'expire' => $expire,
|
||||
],
|
||||
'expire' => Carbon::createFromTimestamp($expire)->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace core\exception;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use core\jwt\ex\JwtRefreshTokenExpiredException;
|
||||
use core\jwt\ex\JwtTokenExpiredException;
|
||||
use core\jwt\ex\JwtTokenInBlacklistException;
|
||||
use core\jwt\ex\JwtTokenInvalidException;
|
||||
use FastRoute\BadRouteException;
|
||||
use core\exception\handler\BaseException;
|
||||
use core\exception\handler\ServerErrorHttpException;
|
||||
use think\exception\ValidateException;
|
||||
use Throwable;
|
||||
use Webman\Exception\ExceptionHandler;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
public $dontReport = [];
|
||||
protected int $statusCode = 200;
|
||||
protected array $header = [];
|
||||
protected int $errorCode = -1;
|
||||
protected string $errorMessage = 'no error';
|
||||
protected array $responseData = [];
|
||||
protected array $config = [];
|
||||
protected string $error = 'no error';
|
||||
|
||||
public function report(Throwable $exception)
|
||||
{
|
||||
$this->dontReport = config('core.exception.app.handler.dont_report', []);
|
||||
parent::report($exception);
|
||||
}
|
||||
|
||||
public function render(Request $request, Throwable $exception): Response
|
||||
{
|
||||
// Apidoc异常处理响应
|
||||
// if ($exception instanceof \hg\apidoc\exception\HttpException) {
|
||||
// return response(json_encode([
|
||||
// "code" => $exception->getCode(),
|
||||
// "message" => $exception->getMessage(),
|
||||
// ], JSON_UNESCAPED_UNICODE), $exception->getStatusCode());
|
||||
// }
|
||||
|
||||
// 处理Laravel验证异常
|
||||
if ($exception instanceof ValidationException) {
|
||||
$errors = $exception->errors();
|
||||
return json([
|
||||
'code' => 422,
|
||||
'message' => 'Validation failed',
|
||||
'data' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->initializeConfig();
|
||||
$this->addRequestInfoToResponse($request);
|
||||
$this->processException($exception);
|
||||
$this->addDebugInfoToResponse($exception);
|
||||
$this->triggerEvents($exception);
|
||||
|
||||
return $this->buildResponse();
|
||||
}
|
||||
|
||||
protected function initializeConfig(): void
|
||||
{
|
||||
$this->config = array_merge($this->config, config('core.exception.app.handler', []));
|
||||
}
|
||||
|
||||
protected function addRequestInfoToResponse(Request $request): void
|
||||
{
|
||||
$this->responseData = array_merge($this->responseData, [
|
||||
'domain' => $request->host(),
|
||||
'method' => $request->method(),
|
||||
'request_url' => $request->method() . ' ' . $request->uri(),
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'client_ip' => $request->getRealIp(),
|
||||
'request_param' => $request->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function processException(Throwable $e): void
|
||||
{
|
||||
if ($e instanceof BaseException) {
|
||||
$this->handleBaseException($e);
|
||||
} else {
|
||||
$this->handleOtherExceptions($e);
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleBaseException(BaseException $e): void
|
||||
{
|
||||
$this->statusCode = $e->statusCode;
|
||||
$this->header = $e->header;
|
||||
$this->errorCode = $e->errorCode;
|
||||
$this->errorMessage = $e->errorMessage;
|
||||
$this->error = $e->error;
|
||||
|
||||
if (isset($e->data)) {
|
||||
$this->responseData = array_merge($this->responseData, $e->data);
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleOtherExceptions(Throwable $e): void
|
||||
{
|
||||
$status = $this->config['status'];
|
||||
$this->errorMessage = $e->getMessage();
|
||||
switch (true) {
|
||||
case $e instanceof BadRouteException:
|
||||
$this->statusCode = $status['route'] ?? 404;
|
||||
break;
|
||||
case $e instanceof ValidateException:
|
||||
$this->statusCode = $status['validate'] ?? 400;
|
||||
break;
|
||||
case $e instanceof JwtTokenInBlacklistException:
|
||||
case $e instanceof JwtTokenInvalidException:
|
||||
$this->statusCode = $status['jwt_token'] ?? 401;
|
||||
break;
|
||||
case $e instanceof JwtTokenExpiredException:
|
||||
$this->statusCode = $status['jwt_token_expired'] ?? 401;
|
||||
break;
|
||||
case $e instanceof JwtRefreshTokenExpiredException:
|
||||
$this->statusCode = $status['jwt_refresh_token_expired'] ?? 401;
|
||||
break;
|
||||
case $e instanceof \InvalidArgumentException:
|
||||
$this->statusCode = $status['invalid_argument'] ?? 415;
|
||||
$this->errorMessage = '预期参数配置异常:' . $e->getMessage();
|
||||
break;
|
||||
case $e instanceof ServerErrorHttpException:
|
||||
$this->statusCode = 500;
|
||||
$this->errorCode=-1;
|
||||
break;
|
||||
default:
|
||||
$this->statusCode = $status['server_error'] ?? 500;
|
||||
$this->errorMessage = 'Internal Server Error';
|
||||
$this->error = $e->getMessage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected function addDebugInfoToResponse(Throwable $e): void
|
||||
{
|
||||
if (config('app.debug', false)) {
|
||||
$this->responseData['error_message'] = $e->getMessage();
|
||||
$this->responseData['error_trace'] = explode("\n", $e->getTraceAsString());
|
||||
$this->responseData['file'] = $e->getFile();
|
||||
$this->responseData['line'] = $e->getLine();
|
||||
}
|
||||
}
|
||||
|
||||
protected function triggerEvents(Throwable $e): void
|
||||
{
|
||||
$this->triggerNotifyEvent($e);
|
||||
$this->triggerTraceEvent($e);
|
||||
}
|
||||
|
||||
protected function triggerNotifyEvent(Throwable $e): void
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
protected function triggerTraceEvent(Throwable $e): void
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
protected function buildResponse(): Response
|
||||
{
|
||||
$bodyKey = array_keys($this->config['body']);
|
||||
$bodyValue = array_values($this->config['body']);
|
||||
|
||||
// 当HTTP状态码为错误状态时,确保code不为0
|
||||
$code = $this->errorCode ?: ($bodyValue[0] ?? 0);
|
||||
if (($this->statusCode >= 400 || $this->statusCode < 100) && $code === 0) {
|
||||
$code = -1;
|
||||
}
|
||||
|
||||
$responseBody = [
|
||||
$bodyKey[0] ?? 'code' => $code,
|
||||
$bodyKey[1] ?? 'msg' => $this->errorMessage,
|
||||
$bodyKey[2] ?? 'data' => $this->responseData,
|
||||
];
|
||||
|
||||
$header = array_merge(['Content-Type' => 'application/json;charset=utf-8'], $this->header);
|
||||
return new Response($this->statusCode, $header, json_encode($responseBody));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception;
|
||||
|
||||
|
||||
class Logger extends \support\Log
|
||||
{
|
||||
/**
|
||||
* 处理静态方法调用
|
||||
*
|
||||
* @param string $name 方法名
|
||||
* @param array $arguments 参数数组
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public static function __callStatic(string $name, array $arguments)
|
||||
{
|
||||
if ($name === 'error') {
|
||||
return self::handleErrorLog($arguments);
|
||||
}
|
||||
|
||||
return parent::__callStatic($name, $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误日志记录
|
||||
*
|
||||
* @param array $arguments 原始参数
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected static function handleErrorLog(array $arguments): mixed
|
||||
{
|
||||
$config = config('core.exception.app.exception_handler', []);
|
||||
$original = $arguments[1] ?? [];
|
||||
|
||||
$logData = self::prepareLogData($original);
|
||||
$logData['message'] = current($arguments);
|
||||
|
||||
if (!empty(request())) {
|
||||
self::enrichRequestData($logData);
|
||||
}
|
||||
|
||||
$logData['environment'] = self::determineEnvironment($logData['domain'] ?? '', $config);
|
||||
|
||||
return parent::__callStatic('error', [$logData['message'], $logData]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备日志数据
|
||||
*
|
||||
* @param array $original 原始数据
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function prepareLogData(array $original): array
|
||||
{
|
||||
return [
|
||||
'error' => $original['error'] ?? '--',
|
||||
'domain' => $original['domain'] ?? '--',
|
||||
'request_url' => $original['request_url'] ?? '--',
|
||||
'client_ip' => $original['client_ip'] ?? '127.0.0.1',
|
||||
'timestamp' => $original['timestamp'] ?? date('Y-m-d H:i:s'),
|
||||
'request_param' => $original['request_param'] ?? [],
|
||||
'file' => $original['file'] ?? '--',
|
||||
'line' => $original['line'] ?? '--',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 丰富请求相关数据
|
||||
*
|
||||
* @param array $logData 日志数据引用
|
||||
*/
|
||||
protected static function enrichRequestData(array &$logData): void
|
||||
{
|
||||
$logData['domain'] = request()->host();
|
||||
$logData['request_url'] = request()->uri();
|
||||
$logData['client_ip'] = request()->getRealIp();
|
||||
$logData['request_param'] = request()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定当前环境
|
||||
*
|
||||
* @param string $domain 当前域名
|
||||
* @param array $config 配置数组
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function determineEnvironment(string $domain, array $config): string
|
||||
{
|
||||
$environments = [
|
||||
'dev' => '测试环境',
|
||||
'pre' => '预发环境',
|
||||
'prod' => '正式环境',
|
||||
];
|
||||
|
||||
foreach ($environments as $key => $title) {
|
||||
if (!empty($config['domain'][$key]) && str_contains($domain, $config['domain'][$key])) {
|
||||
return $title;
|
||||
}
|
||||
}
|
||||
|
||||
return '开发环境';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
// 异常配置
|
||||
'handler' => [
|
||||
// 不需要记录错误日志
|
||||
'dont_report' => [
|
||||
\core\exception\handler\AccessDeniedHttpException::class,
|
||||
\core\exception\handler\BadRequestHttpException::class,
|
||||
\core\exception\handler\ForbiddenHttpException::class,
|
||||
\core\exception\handler\NotFoundHttpException::class,
|
||||
\core\exception\handler\ServerErrorHttpException::class,
|
||||
\core\exception\handler\TooManyRequestsHttpException::class,
|
||||
\core\exception\handler\UnauthorizedHttpException::class,
|
||||
],
|
||||
// 自定义HTTP状态码
|
||||
'status' => [
|
||||
'validate' => 400, // 验证器异常
|
||||
'jwt_token' => 401, // 认证失败
|
||||
'jwt_token_expired' => 401, // 访问令牌过期
|
||||
'jwt_refresh_token_expired' => 402, // 刷新令牌过期
|
||||
'server_error' => 500, // 服务器内部错误
|
||||
],
|
||||
// 自定义响应消息
|
||||
'body' => [
|
||||
'code' => 0,
|
||||
'msg' => '服务器内部异常',
|
||||
'data' => null,
|
||||
],
|
||||
/** 异常报警域名标题 */
|
||||
'domain' => [
|
||||
'dev' => 'dev-api.madong.tech', // 开发环境
|
||||
'dev' => 'dev-api.madong.tech', // 测试环境
|
||||
'pre' => 'pre-api.madong.tech', // 预发环境
|
||||
'prod' => 'api.madong.tech', // 生产环境
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
class AccessDeniedHttpException extends BaseException
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 403;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public string $errorMessage = '当前操作没有权限执行,请检查您的账户权限';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
/**
|
||||
* 自定义异常类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class AdminException extends BaseException
|
||||
{
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = -1;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $errorCode = -1;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
class BadRequestHttpException extends BaseException
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 400;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public string $errorMessage = '请求参数错误,请检查后重试';
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
class BaseException extends \Exception
|
||||
{
|
||||
/**
|
||||
* HTTP Response Status Code.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 400;
|
||||
|
||||
/**
|
||||
* HTTP Response Header.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public array $header = [
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Allow-Headers' => 'Authorization,Content-Type,If-Match,If-Modified-Since,If-None-Match,If-Unmodified-Since,X-Requested-With,Origin',
|
||||
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS',
|
||||
];
|
||||
|
||||
/**
|
||||
* Business Error code.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public int $errorCode = 0;
|
||||
|
||||
/**
|
||||
* Business Error message.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public string $errorMessage = 'The requested resource is not available or not exists';
|
||||
|
||||
/**
|
||||
* Business data.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public mixed $data = [];
|
||||
|
||||
/**
|
||||
* Detail Log Error message.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public string $error = '';
|
||||
|
||||
/**
|
||||
* BaseException constructor.
|
||||
*
|
||||
* @param string $errorMessage
|
||||
* @param array $params
|
||||
* @param string $error
|
||||
*/
|
||||
public function __construct(string $errorMessage = '', array $params = [], string $error = '')
|
||||
{
|
||||
$errorMessage = __($errorMessage ?: $this->errorMessage);// 翻译错误信息
|
||||
$this->errorMessage = $errorMessage;
|
||||
$this->error = $error;
|
||||
if (!empty($params)) {
|
||||
$this->statusCode = $params['statusCode'] ?? $this->statusCode;
|
||||
$this->header = array_merge($this->header, $params['header'] ?? []);
|
||||
$this->errorCode = $params['errorCode'] ?? $this->errorCode;
|
||||
$this->data = $params['data'] ?? $this->data;
|
||||
}
|
||||
parent::__construct($errorMessage, $this->errorCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
|
||||
class CaptchaException extends \RuntimeException
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
/**
|
||||
* 公共异常类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class CommonException extends BaseException
|
||||
{
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = -1;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
class ForbiddenHttpException extends BaseException
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 403;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public string $errorMessage = '对不起,您没有该接口访问权限,请联系管理员';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
class NotFoundHttpException extends BaseException
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 404;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public string $errorMessage = '请求的资源不存在或已被删除';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
/**
|
||||
* 插件异常类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class PluginException extends BaseException
|
||||
{
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = -1;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
class ServerErrorHttpException extends BaseException
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 500;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public string $errorMessage = '服务器内部错误,请稍后再试';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
class TooManyRequestsHttpException extends BaseException
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 429;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public string $errorMessage = '请求过于频繁,请稍后再试';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
class UnauthorizedHttpException extends BaseException
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 401;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public string $errorMessage = '未经授权的访问,请先登录';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
/**
|
||||
* 上传异常类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class UploadException extends BaseException
|
||||
{
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = -1;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\exception\handler;
|
||||
|
||||
use core\exception\handler\BaseException;
|
||||
|
||||
/**
|
||||
* 验证异常类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class ValidationException extends BaseException
|
||||
{
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public int $statusCode = 400;
|
||||
|
||||
|
||||
public int $errorCode=422;
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
<?php
|
||||
/**
|
||||
* Here is your custom functions.
|
||||
*/
|
||||
|
||||
use core\exception\handler\CommonException;
|
||||
use Webman\Route;
|
||||
|
||||
if (!function_exists('full_url')) {
|
||||
/**
|
||||
* 获取资源完整url地址;若安装了云存储或 config/madong/upload/app.php 配置了cdn_url,则自动使用对应的cdn_url
|
||||
*
|
||||
* @param string $relativeUrl 资源相对地址 不传入则获取域名
|
||||
* @param bool|string $domain 是否携带域名 或者直接传入域名
|
||||
* @param string $default 默认值
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function full_url(string $relativeUrl = '', bool|string $domain = true, string $default = ''): string
|
||||
{
|
||||
// 从配置获取 CDN URL
|
||||
$cdnUrl = config('madong.upload.app.cdn_url', '');
|
||||
|
||||
// 如果 CDN URL 为空,则使用默认的主机名
|
||||
if (empty($cdnUrl)) {
|
||||
$cdnUrl = '//' . request()->host();
|
||||
}
|
||||
|
||||
// 处理域名
|
||||
if ($domain === true) {
|
||||
$domain = $cdnUrl;
|
||||
} elseif ($domain === false) {
|
||||
$domain = '';
|
||||
}
|
||||
|
||||
// 如果没有相对 URL,使用默认值
|
||||
$relativeUrl = $relativeUrl ?: $default;
|
||||
if (!$relativeUrl) {
|
||||
return $domain;
|
||||
}
|
||||
|
||||
// 检查是否为绝对 URL 或数据 URL
|
||||
$isAbsoluteUrl = preg_match('/^http(s)?:\/\//', $relativeUrl) || preg_match("/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i", $relativeUrl);
|
||||
if ($isAbsoluteUrl || $domain === false) {
|
||||
return $relativeUrl;
|
||||
}
|
||||
|
||||
// 拼接最终 URL
|
||||
$url = $domain . $relativeUrl;
|
||||
|
||||
// 添加 CDN URL 参数
|
||||
$cdnUrlParams = config('madong.upload.app.cdn_url_params');
|
||||
if ($domain === $cdnUrl && $cdnUrlParams) {
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
$url .= $separator . $cdnUrlParams;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (!function_exists('array_except')) {
|
||||
function array_except(array $array, array $keys): array
|
||||
{
|
||||
return array_diff_key($array, array_flip($keys));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 文件/目录 是否可写
|
||||
*
|
||||
* @param string $file 文件/目录
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
if (!function_exists('is_write')) {
|
||||
function is_write(string $file): bool
|
||||
{
|
||||
if (is_dir($file)) {
|
||||
$dir = $file;
|
||||
if ($fp = @fopen("$dir/test.txt", 'wb')) {
|
||||
@fclose($fp);
|
||||
@unlink("$dir/test.txt");
|
||||
$writeable = true;
|
||||
} else {
|
||||
$writeable = false;
|
||||
}
|
||||
} else {
|
||||
if ($fp = @fopen($file, 'ab+')) {
|
||||
@fclose($fp);
|
||||
$writeable = true;
|
||||
} else {
|
||||
$writeable = false;
|
||||
}
|
||||
}
|
||||
return $writeable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路由地址
|
||||
*
|
||||
* @param string $nameOrPath 路由 name 或 path
|
||||
* @param array $params
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function route_url(string $nameOrPath, array $params = []): string
|
||||
{
|
||||
$route = Route::getByName($nameOrPath);
|
||||
if (!$route) {
|
||||
$route = new Route\Route([], $nameOrPath, function () {
|
||||
});
|
||||
}
|
||||
|
||||
return \request()->pathPrefix() . $route->url($params);
|
||||
}
|
||||
|
||||
if (!function_exists('create_directory')) {
|
||||
|
||||
/**
|
||||
* 创建文件夹
|
||||
*
|
||||
* @param string $path 文件夹路径
|
||||
* @param int $mode 访问权限
|
||||
* @param bool $recursive 是否递归创建
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
function create_directory(string $path = '', int $mode = 0777, bool $recursive = true): bool
|
||||
{
|
||||
clearstatcache();
|
||||
|
||||
// 如果路径为空,抛出异常
|
||||
if (empty($path)) {
|
||||
throw new CommonException("目录路径不能为空");
|
||||
}
|
||||
|
||||
if (!is_dir($path)) {
|
||||
if (mkdir($path, $mode, $recursive)) {
|
||||
return chmod($path, $mode);
|
||||
} else {
|
||||
throw new CommonException("目录{$path}创建失败请检查是否有足够的权限");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* @param string $dst
|
||||
* @param array $dirs
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
if (!function_exists('remove_directory')) {
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* @param string $dst
|
||||
* @param array $dirs
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
function remove_directory(string $dst = '', array $dirs = []): bool
|
||||
{
|
||||
if (empty($dirs) || empty($dst)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($dirs as $v) {
|
||||
@unlink($dst . $v);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('remove_target_directory')) {
|
||||
/**
|
||||
* 递归删除目录及其内容
|
||||
*
|
||||
* @param string $path 要删除的目录或文件路径
|
||||
* @param bool $removeDir 是否删除目录本身
|
||||
*
|
||||
* @return bool 成功返回 true,失败返回 false
|
||||
*/
|
||||
function remove_target_directory(string $path, bool $removeDir = true): bool
|
||||
{
|
||||
// 路径不存在,直接返回 false
|
||||
if (!file_exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果是文件,直接删除
|
||||
if (is_file($path) || is_link($path)) {
|
||||
return unlink($path);
|
||||
}
|
||||
|
||||
// 如果是目录,递归删除其内容
|
||||
$items = scandir($path);
|
||||
if ($items === false) {
|
||||
// 无法打开目录
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
// 跳过当前目录和上级目录
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建完整路径
|
||||
$itemPath = $path . DIRECTORY_SEPARATOR . $item;
|
||||
|
||||
// 递归删除子项
|
||||
if (!remove_target_directory($itemPath, true)) {
|
||||
// 如果子项删除失败,返回 false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果需要删除目录本身
|
||||
if ($removeDir) {
|
||||
return rmdir($path);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹文件拷贝
|
||||
*
|
||||
* @param string $src 来源文件夹
|
||||
* @param string $dst 目的地文件夹
|
||||
* @param array $files 文件夹集合
|
||||
* @param array $exclude_dirs 排除无需拷贝的文件夹
|
||||
* @param array $exclude_files 排除无需拷贝的文件
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
function copy_directory(string $src = '', string $dst = '', array &$files = [], array $exclude_dirs = [], array $exclude_files = []): bool
|
||||
{
|
||||
if (empty($src) || empty($dst)) {
|
||||
return false;
|
||||
}
|
||||
if (!file_exists($src)) {
|
||||
return false;
|
||||
}
|
||||
$dir = opendir($src);
|
||||
create_directory($dst);
|
||||
while (false !== ($file = readdir($dir))) {
|
||||
if (($file != '.') && ($file != '..')) {
|
||||
if (is_dir($src . '/' . $file)) {
|
||||
// 排除目录
|
||||
if (count($exclude_dirs) && in_array($file, $exclude_dirs)) continue;
|
||||
copy_directory($src . '/' . $file, $dst . '/' . $file, $files, $exclude_dirs, $exclude_files);
|
||||
} else {
|
||||
// 排除文件
|
||||
if (count($exclude_files) && in_array($file, $exclude_files)) continue;
|
||||
$copyResult = copy($src . '/' . $file, $dst . '/' . $file);
|
||||
$files[] = $dst . '/' . $file;
|
||||
if (!$copyResult) {
|
||||
closedir($dir);
|
||||
throw new CommonException("文件{$file}拷贝失败请检查是否有足够的权限");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir($dir);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!function_exists('get_subdirectories')) {
|
||||
/**
|
||||
* 获取目录下的子目录结构
|
||||
*
|
||||
* @param string $directory_path 要扫描的目录路径
|
||||
*
|
||||
* @return array 子目录名称列表
|
||||
*/
|
||||
function get_subdirectories(string $directory_path): array
|
||||
{
|
||||
// 验证目录有效性
|
||||
if (!is_dir($directory_path)) {
|
||||
trigger_error("Invalid directory path: {$directory_path}", E_USER_WARNING);
|
||||
return [];
|
||||
}
|
||||
|
||||
$directory_handle = @opendir($directory_path);
|
||||
if ($directory_handle === false) {
|
||||
trigger_error("Failed to open directory: {$directory_path}", E_USER_WARNING);
|
||||
return [];
|
||||
}
|
||||
|
||||
$subdirectories = [];
|
||||
|
||||
while (($entry_name = readdir($directory_handle)) !== false) {
|
||||
// 跳过特殊目录
|
||||
if ($entry_name === '.' || $entry_name === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$full_path = $directory_path . DIRECTORY_SEPARATOR . $entry_name;
|
||||
|
||||
// 仅收集目录类型
|
||||
if (is_dir($full_path)) {
|
||||
$subdirectories[] = $entry_name;
|
||||
}
|
||||
}
|
||||
|
||||
closedir($directory_handle);
|
||||
return $subdirectories;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割SQL语句并清理注释
|
||||
*
|
||||
* @param string $sqlContent SQL内容
|
||||
* @param bool $returnAsSingleString 是否返回单条SQL字符串(默认返回数组)
|
||||
* @param array $tablePrefixReplacements 表前缀替换规则 [原前缀 => 新前缀]
|
||||
*
|
||||
* @return array|string 处理后的SQL语句数组或单条语句
|
||||
*/
|
||||
if (!function_exists('split_sql_statements')) {
|
||||
function split_sql_statements(
|
||||
string $sqlContent = '',
|
||||
bool $returnAsSingleString = false,
|
||||
array $tablePrefixReplacements = []
|
||||
): array|string
|
||||
{
|
||||
// 存储清理后的SQL行
|
||||
$cleanedLines = [];
|
||||
// 获取表前缀替换规则(只取第一组替换规则)
|
||||
$originalPrefix = '';
|
||||
$newPrefix = '';
|
||||
if (!empty($tablePrefixReplacements)) {
|
||||
$originalPrefix = key($tablePrefixReplacements);
|
||||
$newPrefix = reset($tablePrefixReplacements);
|
||||
}
|
||||
|
||||
// 处理空内容情况
|
||||
if ($sqlContent === '') {
|
||||
return $returnAsSingleString ? '' : [];
|
||||
}
|
||||
|
||||
// 标准化换行符并分割为行数组
|
||||
$normalizedContent = str_replace(["\r\n", "\r"], "\n", $sqlContent);
|
||||
$lines = explode("\n", trim($normalizedContent));
|
||||
|
||||
// 处理多行注释状态
|
||||
$inMultilineComment = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmedLine = trim($line);
|
||||
|
||||
// 跳过空行
|
||||
if ($trimmedLine === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理多行注释开始
|
||||
if (str_starts_with($trimmedLine, '/*')) {
|
||||
$inMultilineComment = true;
|
||||
|
||||
// 检查同一行是否包含注释结束
|
||||
if (str_contains($trimmedLine, '*/')) {
|
||||
$inMultilineComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理多行注释结束
|
||||
if (str_ends_with($trimmedLine, '*/')) {
|
||||
$inMultilineComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过多行注释内容
|
||||
if ($inMultilineComment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过单行注释 (# 或 -- 开头)
|
||||
if (preg_match('/^(#|--)/', $trimmedLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过 /* */ 单行注释
|
||||
if (preg_match('/^\/\*.*?\*\/$/', $trimmedLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 应用表前缀替换
|
||||
if ($originalPrefix !== '' && $newPrefix !== '') {
|
||||
$line = str_replace(
|
||||
'`' . $originalPrefix,
|
||||
'`' . $newPrefix,
|
||||
$line
|
||||
);
|
||||
}
|
||||
|
||||
// 保存清理后的行
|
||||
$cleanedLines[] = $line;
|
||||
}
|
||||
|
||||
// 返回单条SQL字符串
|
||||
if ($returnAsSingleString) {
|
||||
return implode(' ', $cleanedLines);
|
||||
}
|
||||
|
||||
// 组合为完整SQL并分割语句
|
||||
$combinedSql = implode("\n", $cleanedLines);
|
||||
$statements = explode(";\n", $combinedSql);
|
||||
|
||||
// 移除空语句
|
||||
return array_filter($statements, function ($stmt) {
|
||||
return trim($stmt) !== '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('build_detailed_file_map')) {
|
||||
/**
|
||||
* 递归构建目录文件详细映射(包含目录大小)
|
||||
*
|
||||
* @param string $directory_path 目录路径
|
||||
* @param array $file_map 累计文件映射(递归调用时传递)
|
||||
*
|
||||
* @return array 详细的文件映射数组(路径 => 详细信息)
|
||||
*/
|
||||
function build_detailed_file_map(string $directory_path, array $file_map = []): array
|
||||
{
|
||||
if (!is_dir($directory_path)) {
|
||||
return $file_map;
|
||||
}
|
||||
|
||||
$items = scandir($directory_path);
|
||||
foreach ($items as $item_name) {
|
||||
if ($item_name === '.' || $item_name === '..') continue;
|
||||
|
||||
$item_path = $directory_path . DIRECTORY_SEPARATOR . $item_name;
|
||||
$item_type = is_dir($item_path) ? 'directory' : 'file';
|
||||
|
||||
$file_map[$item_path] = [
|
||||
'name' => $item_name,
|
||||
'type' => $item_type,
|
||||
'size' => is_file($item_path) ? filesize($item_path) : 0,
|
||||
];
|
||||
|
||||
if ($item_type === 'directory') {
|
||||
$file_map = build_detailed_file_map($item_path, $file_map);
|
||||
}
|
||||
}
|
||||
|
||||
return $file_map;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('project_path')) {
|
||||
/**
|
||||
* 获取项目根目录(项目目录)
|
||||
* User GQL
|
||||
* Date 2026/1/4 15:21
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function project_path(): string
|
||||
{
|
||||
return dirname(base_path()) . DIRECTORY_SEPARATOR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归收集目录下所有文件路径
|
||||
*
|
||||
* @param string $currentPath 当前处理的路径
|
||||
* @param array &$resultArray 存储结果的数组
|
||||
* @param string $stripPrefix 可选的路径前缀移除字符串
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
if (!function_exists('collect_files_recursive')) {
|
||||
function collect_files_recursive(string $currentPath, array &$resultArray, string $stripPrefix = ''): void
|
||||
{
|
||||
if (is_dir($currentPath)) {
|
||||
$normalizedDir = rtrim($currentPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$dirHandle = opendir($normalizedDir);
|
||||
while (($entry = readdir($dirHandle)) !== false) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
// 递归处理子路径
|
||||
collect_files_recursive(
|
||||
$normalizedDir . $entry,
|
||||
$resultArray,
|
||||
$stripPrefix
|
||||
);
|
||||
}
|
||||
closedir($dirHandle);
|
||||
} elseif (is_file($currentPath)) {
|
||||
$processedPath = $currentPath;
|
||||
if ($stripPrefix !== '') {
|
||||
$processedPath = str_replace($stripPrefix, '', $processedPath);
|
||||
}
|
||||
$resultArray[] = $processedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('check_directory_permissions')) {
|
||||
/**
|
||||
* 检查目录及其子目录的读写权限
|
||||
*
|
||||
* @param string $directory_path 要检查的目录路径
|
||||
* @param array $result_data 结果数据 [unreadable => [], not_writable => []]
|
||||
* @param array $excluded_directories 排除检查的目录列表
|
||||
*
|
||||
* @return array 包含不可读和不可写路径的结果集
|
||||
* @throws RuntimeException 当指定路径不是有效目录时
|
||||
*/
|
||||
function check_directory_permissions(
|
||||
string $directory_path,
|
||||
array $result_data = [],
|
||||
array $excluded_directories = []
|
||||
): array
|
||||
{
|
||||
// 验证目录有效性
|
||||
if (!is_dir($directory_path)) {
|
||||
throw new RuntimeException(
|
||||
sprintf('指定的路径 "%s" 不是一个有效的目录', $directory_path)
|
||||
);
|
||||
}
|
||||
|
||||
// 初始化结果数据结构
|
||||
if (empty($result_data)) {
|
||||
$result_data = [
|
||||
'unreadable_paths' => [],
|
||||
'not_writable_paths' => [],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查当前目录权限
|
||||
if (!is_readable($directory_path)) {
|
||||
$result_data['unreadable_paths'][] = $directory_path;
|
||||
}
|
||||
if (!is_writable($directory_path)) {
|
||||
$result_data['not_writable_paths'][] = $directory_path;
|
||||
}
|
||||
|
||||
// 仅当目录可读时继续检查内容
|
||||
if (is_readable($directory_path)) {
|
||||
$directory_handle = opendir($directory_path);
|
||||
|
||||
while (($entry_name = readdir($directory_handle)) !== false) {
|
||||
// 跳过特殊目录项
|
||||
if ($entry_name === '.' || $entry_name === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$full_path = $directory_path . DIRECTORY_SEPARATOR . $entry_name;
|
||||
|
||||
// 检查是否在排除目录列表中
|
||||
$should_skip = false;
|
||||
foreach ($excluded_directories as $excluded_dir) {
|
||||
if (str_contains($full_path, $excluded_dir)) {
|
||||
$should_skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($should_skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 递归处理子目录
|
||||
if (is_dir($full_path)) {
|
||||
$result_data = check_directory_permissions(
|
||||
$full_path,
|
||||
$result_data,
|
||||
$excluded_directories
|
||||
);
|
||||
} // 处理文件权限
|
||||
else {
|
||||
if (!is_readable($full_path)) {
|
||||
$result_data['unreadable_paths'][] = $full_path;
|
||||
}
|
||||
if (!is_writable($full_path)) {
|
||||
$result_data['not_writable_paths'][] = $full_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closedir($directory_handle);
|
||||
}
|
||||
|
||||
return $result_data;
|
||||
|
||||
} catch (Exception $exception) {
|
||||
// 异常情况下标记当前目录为不可访问
|
||||
$result_data['unreadable_paths'][] = $directory_path;
|
||||
$result_data['not_writable_paths'][] = $directory_path;
|
||||
return $result_data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('merge_arrays_recursively')) {
|
||||
/**
|
||||
* 深度合并两个多维数组(递归合并)
|
||||
* 规则:
|
||||
* 1. 如果键存在且都是数组,则递归合并
|
||||
* 2. 如果键存在但类型不同,则用第二个数组的值覆盖
|
||||
* 3. 如果键只在第二个数组中存在,则添加新键值对
|
||||
*
|
||||
* @param array $first_array 基础数组
|
||||
* @param array $second_array 要合并的数组
|
||||
*
|
||||
* @return array 合并后的新数组
|
||||
*/
|
||||
function merge_arrays_recursively(array $first_array, array $second_array): array
|
||||
{
|
||||
// 创建结果数组的副本以避免修改原始数据
|
||||
$merged_result = $first_array;
|
||||
|
||||
foreach ($second_array as $key => $value) {
|
||||
// 键存在于两个数组中
|
||||
if (array_key_exists($key, $merged_result)) {
|
||||
// 两个值都是数组 - 递归合并
|
||||
if (is_array($merged_result[$key]) && is_array($value)) {
|
||||
$merged_result[$key] = merge_arrays_recursively(
|
||||
$merged_result[$key],
|
||||
$value
|
||||
);
|
||||
} // 值类型不同或不是数组 - 覆盖
|
||||
else {
|
||||
$merged_result[$key] = $value;
|
||||
}
|
||||
} // 键只存在于第二个数组中 - 添加新键值对
|
||||
else {
|
||||
$merged_result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $merged_result;
|
||||
}
|
||||
}
|
||||
if (!function_exists('resource_path')) {
|
||||
/**
|
||||
* 获取资源目录路径
|
||||
*
|
||||
* @param string $path 可选子路径
|
||||
* @return string 完整文件系统路径
|
||||
*/
|
||||
function resource_path(string $path = ''): string
|
||||
{
|
||||
// 规范化路径分隔符
|
||||
$subPath = $path ? DIRECTORY_SEPARATOR . trim($path, '/\\') : '';
|
||||
|
||||
// 拼接基础路径和资源目录
|
||||
return base_path('resources' . $subPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('str_starts_with')) {
|
||||
/**
|
||||
* 检查字符串是否以指定前缀开始
|
||||
*
|
||||
* @param string $haystack 要检查的字符串
|
||||
* @param string $needle 要查找的前缀
|
||||
* @return bool
|
||||
*/
|
||||
function str_starts_with(string $haystack, string $needle): bool
|
||||
{
|
||||
return substr($haystack, 0, strlen($needle)) === $needle;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('str_ends_with')) {
|
||||
/**
|
||||
* 检查字符串是否以指定后缀结束
|
||||
*
|
||||
* @param string $haystack 要检查的字符串
|
||||
* @param string $needle 要查找的后缀
|
||||
* @return bool
|
||||
*/
|
||||
function str_ends_with(string $haystack, string $needle): bool
|
||||
{
|
||||
$length = strlen($needle);
|
||||
if ($length === 0) {
|
||||
return true;
|
||||
}
|
||||
return substr($haystack, -$length) === $needle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator;
|
||||
|
||||
use core\generator\factory\GeneratorFactory;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
use core\generator\utils\PathResolver;
|
||||
use support\Log;
|
||||
|
||||
/**
|
||||
* 代码生成器引擎
|
||||
* 负责协调各个生成器,处理整体生成流程
|
||||
*/
|
||||
class GeneratorEngine
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var GeneratorFactory 生成器工厂
|
||||
*/
|
||||
private GeneratorFactory $generatorFactory;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* @var PathResolver 路径解析器
|
||||
*/
|
||||
private PathResolver $pathResolver;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array|object $config 配置信息(支持数组或对象)
|
||||
*/
|
||||
public function __construct($config)
|
||||
{
|
||||
// 解析配置数据
|
||||
$this->config = $this->parseConfig($config);
|
||||
|
||||
// 合并默认场景配置
|
||||
$this->mergeDefaultScenes();
|
||||
|
||||
$this->generatorFactory = new GeneratorFactory();
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
$this->pathResolver = new PathResolver();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置数据
|
||||
* @param array|object $config 配置信息
|
||||
* @return array 解析后的配置数组
|
||||
*/
|
||||
private function parseConfig($config): array
|
||||
{
|
||||
if (is_object($config)) {
|
||||
// 如果是对象,转换为数组
|
||||
$parsedConfig = (array) $config;
|
||||
} elseif (is_array($config)) {
|
||||
// 如果是数组,直接使用
|
||||
$parsedConfig = $config;
|
||||
} else {
|
||||
// 其他类型,返回空数组
|
||||
return [];
|
||||
}
|
||||
|
||||
// 如果有 basic 字段,使用其中的值
|
||||
if (isset($parsedConfig['basic']) && is_array($parsedConfig['basic'])) {
|
||||
$basic = $parsedConfig['basic'];
|
||||
if (isset($basic['table_name']) && !empty($basic['table_name'])) {
|
||||
$parsedConfig['package_name'] = $basic['table_name'];
|
||||
} elseif (isset($basic['module_name']) && !empty($basic['module_name'])) {
|
||||
$parsedConfig['package_name'] = $basic['module_name'];
|
||||
}
|
||||
if (isset($basic['class_name']) && !empty($basic['class_name'])) {
|
||||
$parsedConfig['class_name'] = $basic['class_name'];
|
||||
} elseif (isset($basic['table_name']) && !empty($basic['table_name'])) {
|
||||
$parsedConfig['class_name'] = $basic['table_name'];
|
||||
}
|
||||
if (isset($basic['table_comment']) && !empty($basic['table_comment'])) {
|
||||
$parsedConfig['table_comment'] = $basic['table_comment'];
|
||||
}
|
||||
if (isset($basic['class_name_comment']) && !empty($basic['class_name_comment'])) {
|
||||
$parsedConfig['class_name_comment'] = $basic['class_name_comment'];
|
||||
}
|
||||
if (isset($basic['plugin_name']) && !empty($basic['plugin_name'])) {
|
||||
$parsedConfig['plugin_name'] = $basic['plugin_name'];
|
||||
$parsedConfig['namespace'] = $basic['plugin_name'];
|
||||
}
|
||||
}
|
||||
|
||||
// 如果顶层有 table_name 字段,使用它作为 package_name
|
||||
if (isset($parsedConfig['table_name']) && !empty($parsedConfig['table_name']) && !isset($parsedConfig['package_name'])) {
|
||||
$parsedConfig['package_name'] = $parsedConfig['table_name'];
|
||||
} elseif (isset($parsedConfig['module_name']) && !empty($parsedConfig['module_name']) && !isset($parsedConfig['package_name'])) {
|
||||
$parsedConfig['package_name'] = $parsedConfig['module_name'];
|
||||
}
|
||||
|
||||
// 如果顶层有 table_name 字段,使用它作为 class_name(如果没有设置 class_name)
|
||||
if (isset($parsedConfig['table_name']) && !empty($parsedConfig['table_name']) && !isset($parsedConfig['class_name'])) {
|
||||
$parsedConfig['class_name'] = $parsedConfig['table_name'];
|
||||
}
|
||||
|
||||
// 处理 template 参数:如果没有传入 template,但传入了 plugin_name,则使用 plugin_name 作为 template 值
|
||||
if (!isset($parsedConfig['template']) && isset($parsedConfig['plugin_name']) && !empty($parsedConfig['plugin_name'])) {
|
||||
$parsedConfig['template'] = $parsedConfig['plugin_name'];
|
||||
$parsedConfig['namespace'] = $parsedConfig['plugin_name'];
|
||||
} elseif (!isset($parsedConfig['template'])) {
|
||||
// 如果都没有传入,则使用默认值 app
|
||||
$parsedConfig['template'] = 'app';
|
||||
} elseif ($parsedConfig['template'] !== 'app') {
|
||||
// 如果传入了 template 且不是 app,则使用 template 作为 namespace
|
||||
$parsedConfig['namespace'] = $parsedConfig['template'];
|
||||
}
|
||||
// 如果传入了 template,则直接使用它,优先级高于 plugin_name
|
||||
|
||||
// 确保 class_name 是大驼峰格式
|
||||
if (isset($parsedConfig['class_name']) && !empty($parsedConfig['class_name'])) {
|
||||
$parsedConfig['class_name'] = str_replace(' ', '', ucwords(str_replace('_', ' ', $parsedConfig['class_name'])));
|
||||
}
|
||||
|
||||
// 验证必须参数
|
||||
$this->validateRequiredParams($parsedConfig);
|
||||
|
||||
return $parsedConfig;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 验证必须参数
|
||||
* @param array $config 配置数组
|
||||
* @throws Exception 如果缺少必须参数
|
||||
*/
|
||||
private function validateRequiredParams(array &$config): void
|
||||
{
|
||||
// 验证 package_name(模块名)
|
||||
if (!isset($config['package_name']) || empty($config['package_name'])) {
|
||||
throw new \Exception('Missing required parameter: package_name (table_name)');
|
||||
}
|
||||
|
||||
// 验证 class_name(类名)
|
||||
if (!isset($config['class_name']) || empty($config['class_name'])) {
|
||||
throw new \Exception('Missing required parameter: class_name');
|
||||
}
|
||||
|
||||
// 验证 template
|
||||
if (!isset($config['template']) || empty($config['template'])) {
|
||||
$config['template'] = 'app';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并默认场景配置
|
||||
* @return void
|
||||
*/
|
||||
private function mergeDefaultScenes(): void
|
||||
{
|
||||
// 如果未设置场景类型,默认同时生成前端和后端代码
|
||||
if (!isset($this->config['scene_types']) && !isset($this->config['scene_type'])) {
|
||||
$this->config['scene_types'] = ['backend', 'admin'];
|
||||
}
|
||||
|
||||
// 如果未设置文件类型映射,设置默认值
|
||||
if (!isset($this->config['file_types_map'])) {
|
||||
$this->config['file_types_map'] = [
|
||||
'backend' => ['controller', 'model', 'service', 'dao', 'validate', 'request_form', 'request_query', 'response'],
|
||||
'admin' => ['api', 'api_model', 'view', 'view_schema', 'lang'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览代码生成
|
||||
* @return array 预览结果
|
||||
*/
|
||||
public function preview(): array
|
||||
{
|
||||
$result = [];
|
||||
$sceneTypes = $this->config['scene_types'] ?? [$this->config['scene_type'] ?? 'backend'];
|
||||
$fileTypesMap = $this->config['file_types_map'] ?? [
|
||||
'backend' => ['controller', 'model', 'service', 'dao', 'validate', 'request_form', 'request_query', 'response'],
|
||||
'admin' => ['api', 'api_model', 'view', 'view_schema', 'lang'],
|
||||
];
|
||||
|
||||
// 生成每种场景类型
|
||||
foreach ($sceneTypes as $sceneType) {
|
||||
try {
|
||||
// 获取场景生成器
|
||||
$sceneGenerator = $this->generatorFactory->createSceneGenerator($sceneType, $this->config);
|
||||
|
||||
// 获取当前场景的文件类型
|
||||
$fileTypes = $fileTypesMap[$sceneType] ?? $this->config['file_types'] ?? ['controller', 'model', 'service', 'dao', 'validate', 'request_form', 'request_query','response'];
|
||||
|
||||
// 生成每种文件类型
|
||||
foreach ($fileTypes as $fileType) {
|
||||
try {
|
||||
// 获取文件类型生成器
|
||||
$fileGenerator = $this->generatorFactory->createFileGenerator($fileType, $this->config);
|
||||
|
||||
// 生成文件内容
|
||||
$content = $fileGenerator->generateContent();
|
||||
|
||||
// 获取文件扩展名
|
||||
$extension = $fileGenerator->getFileExtension();
|
||||
|
||||
// 生成文件路径
|
||||
$filePath = $sceneGenerator->generateFilePath($fileType, $extension);
|
||||
|
||||
// 添加到结果中
|
||||
$result[] = [
|
||||
'name' => basename($filePath),
|
||||
'type' => $this->getFileType($filePath),
|
||||
'content' => $content,
|
||||
'file_dir' => $this->getRelativePath(dirname($filePath), $sceneType),
|
||||
'scene_type' => $sceneType,
|
||||
];
|
||||
|
||||
// 处理 DTO 特殊情况:同时预览 QueryRequest
|
||||
// if ($fileType === 'request_query' && method_exists($fileGenerator, 'getQueryRequestContent')) {
|
||||
// try {
|
||||
// // 获取 QueryRequest 内容
|
||||
// $queryContent = $fileGenerator->getQueryRequestContent();
|
||||
//
|
||||
// if (!empty($queryContent)) {
|
||||
// // 生成 QueryRequest 文件路径(与 FormRequest 在同一目录)
|
||||
// $formDir = dirname($filePath);
|
||||
// $fileName = basename($filePath);
|
||||
// $queryFileName = str_replace('FormRequest', 'QueryRequest', $fileName);
|
||||
// $queryFilePath = $formDir . DS . $queryFileName;
|
||||
//
|
||||
// // 添加到结果中
|
||||
// $result[] = [
|
||||
// 'name' => basename($queryFilePath),
|
||||
// 'type' => $this->getFileType($queryFilePath),
|
||||
// 'content' => $queryContent,
|
||||
// 'file_dir' => $this->getRelativePath(dirname($queryFilePath), $sceneType),
|
||||
// 'scene_type' => $sceneType,
|
||||
// ];
|
||||
// }
|
||||
// } catch (\Exception $e) {
|
||||
// // 记录错误但继续执行
|
||||
// $result[] = [
|
||||
// 'name' => 'request_query',
|
||||
// 'type' => 'error',
|
||||
// 'content' => 'Error generating QueryRequest: ' . $e->getMessage(),
|
||||
// 'file_dir' => '',
|
||||
// 'scene_type' => $sceneType,
|
||||
// ];
|
||||
// }
|
||||
// }
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但继续执行
|
||||
$result[] = [
|
||||
'name' => $fileType,
|
||||
'type' => 'error',
|
||||
'content' => 'Error: ' . $e->getMessage(),
|
||||
'file_dir' => '',
|
||||
'scene_type' => $sceneType,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 记录场景错误
|
||||
$result[] = [
|
||||
'name' => $sceneType,
|
||||
'type' => 'error',
|
||||
'content' => 'Scene Error: ' . $e->getMessage(),
|
||||
'file_dir' => '',
|
||||
'scene_type' => $sceneType,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署代码生成
|
||||
* @return array 部署结果
|
||||
*/
|
||||
public function deploy(): array
|
||||
{
|
||||
$result = [];
|
||||
$sceneTypes = $this->config['scene_types'] ?? [$this->config['scene_type'] ?? 'backend'];
|
||||
$fileTypesMap = $this->config['file_types_map'] ?? [
|
||||
'backend' => ['controller', 'model', 'service', 'dao', 'validate', 'response', 'request_form','request_query'],
|
||||
'admin' => ['api', 'api_model', 'view', 'view_schema', 'lang'],
|
||||
];
|
||||
|
||||
// 生成每种场景类型
|
||||
foreach ($sceneTypes as $sceneType) {
|
||||
try {
|
||||
// 获取场景生成器
|
||||
$sceneGenerator = $this->generatorFactory->createSceneGenerator($sceneType, $this->config);
|
||||
|
||||
// 获取当前场景的文件类型
|
||||
$fileTypes = $fileTypesMap[$sceneType] ?? $this->config['file_types'] ?? ['controller', 'model', 'service', 'dao', 'validate', 'schema', 'dto'];
|
||||
|
||||
// 生成每种文件类型
|
||||
foreach ($fileTypes as $fileType) {
|
||||
try {
|
||||
// 获取文件类型生成器
|
||||
$fileGenerator = $this->generatorFactory->createFileGenerator($fileType, $this->config);
|
||||
|
||||
// 生成文件内容
|
||||
$content = $fileGenerator->generateContent();
|
||||
|
||||
// 验证内容是否为空
|
||||
if (empty($content)) {
|
||||
throw new \Exception('Generated content is empty for file type: ' . $fileType);
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
$extension = $fileGenerator->getFileExtension();
|
||||
|
||||
// 生成文件路径
|
||||
$filePath = $sceneGenerator->generateFilePath($fileType, $extension);
|
||||
|
||||
// 确保目录存在
|
||||
$dirPath = dirname($filePath);
|
||||
if (!is_dir($dirPath)) {
|
||||
mkdir($dirPath, 0777, true);
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
$written = file_put_contents($filePath, $content);
|
||||
if ($written === false) {
|
||||
throw new \Exception('Failed to write file: ' . $filePath);
|
||||
}
|
||||
|
||||
// 添加到结果中
|
||||
$result[] = [
|
||||
'file_type' => $fileType,
|
||||
'file_path' => $filePath,
|
||||
'status' => 'success',
|
||||
'scene_type' => $sceneType,
|
||||
];
|
||||
|
||||
// 处理 DTO 特殊情况:同时生成 QueryRequest
|
||||
// if ($fileType === 'dto' && method_exists($fileGenerator, 'getQueryRequestContent')) {
|
||||
// try {
|
||||
// // 获取 QueryRequest 内容
|
||||
// $queryContent = $fileGenerator->getQueryRequestContent();
|
||||
//
|
||||
// if (!empty($queryContent)) {
|
||||
// // 生成 QueryRequest 文件路径(与 FormRequest 在同一目录)
|
||||
// $formDir = dirname($filePath);
|
||||
// $fileName = basename($filePath);
|
||||
// $queryFileName = str_replace('FormRequest', 'QueryRequest', $fileName);
|
||||
// $queryFilePath = $formDir . DS . $queryFileName;
|
||||
//
|
||||
// // 确保目录存在
|
||||
// if (!is_dir($formDir)) {
|
||||
// mkdir($formDir, 0777, true);
|
||||
// }
|
||||
//
|
||||
// // 写入文件
|
||||
// $queryWritten = file_put_contents($queryFilePath, $queryContent);
|
||||
// if ($queryWritten !== false) {
|
||||
// $result[] = [
|
||||
// 'file_type' => 'dto_query',
|
||||
// 'file_path' => $queryFilePath,
|
||||
// 'status' => 'success',
|
||||
// 'scene_type' => $sceneType,
|
||||
// ];
|
||||
// }
|
||||
// }
|
||||
// } catch (\Exception $e) {
|
||||
// // 记录错误但继续执行
|
||||
// $result[] = [
|
||||
// 'file_type' => 'dto_query',
|
||||
// 'status' => 'error',
|
||||
// 'message' => 'Error generating QueryRequest: ' . $e->getMessage(),
|
||||
// 'scene_type' => $sceneType,
|
||||
// ];
|
||||
// }
|
||||
// }
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但继续执行
|
||||
$result[] = [
|
||||
'file_type' => $fileType,
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
'scene_type' => $sceneType,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 记录场景错误
|
||||
$result[] = [
|
||||
'file_type' => $sceneType,
|
||||
'status' => 'error',
|
||||
'message' => 'Scene Error: ' . $e->getMessage(),
|
||||
'scene_type' => $sceneType,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型
|
||||
* @param string $filePath 文件路径
|
||||
* @return string 文件类型
|
||||
*/
|
||||
private function getFileType(string $filePath): string
|
||||
{
|
||||
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
|
||||
return $extension ?: 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相对路径
|
||||
* @param string $fullPath 完整路径
|
||||
* @param string $sceneType 场景类型
|
||||
* @return string 相对路径
|
||||
*/
|
||||
private function getRelativePath(string $fullPath, string $sceneType): string
|
||||
{
|
||||
if ($sceneType === 'admin') {
|
||||
// 前端场景
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
// 无论是 app 模式还是插件模式,前端代码都统一生成在 frontend/admin 目录下
|
||||
$frontendRoot = dirname(base_path()) . DS . 'frontend' . DS . 'admin';
|
||||
$path = 'frontend/admin' . str_replace($frontendRoot, '', $fullPath);
|
||||
} else {
|
||||
// 后端场景,使用当前后端根目录名称作为前缀
|
||||
$backendRootName = basename(base_path());
|
||||
$path = $backendRootName . str_replace(base_path(), '', $fullPath);
|
||||
}
|
||||
|
||||
// 调整斜杠为 `/`
|
||||
$path = str_replace('\\', '/', $path);
|
||||
// 确保路径末尾有 `/`
|
||||
if (!empty($path) && substr($path, -1) !== '/') {
|
||||
$path .= '/';
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件下载包
|
||||
*
|
||||
* @return array 下载包信息
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function download(): array
|
||||
{
|
||||
// 验证模块命名
|
||||
$this->validateModuleName($this->config);
|
||||
|
||||
$result = [];
|
||||
$sceneTypes = $this->config['scene_types'] ?? [$this->config['scene_type'] ?? 'backend'];
|
||||
$fileTypesMap = $this->config['file_types_map'] ?? [
|
||||
'backend' => ['controller', 'model', 'service', 'dao', 'validate', 'request_form', 'request_query','response'],
|
||||
'admin' => ['api', 'api_model', 'view', 'view_schema', 'lang'],
|
||||
];
|
||||
|
||||
// 创建临时目录
|
||||
$tempDir = sys_get_temp_dir() . DS . 'generator_' . uniqid();
|
||||
if (!mkdir($tempDir, 0777, true)) {
|
||||
throw new \Exception('Failed to create temporary directory');
|
||||
}
|
||||
|
||||
// 生成每种场景类型的文件
|
||||
foreach ($sceneTypes as $sceneType) {
|
||||
try {
|
||||
// 获取场景生成器
|
||||
$sceneGenerator = $this->generatorFactory->createSceneGenerator($sceneType, $this->config);
|
||||
|
||||
// 获取当前场景的文件类型
|
||||
$fileTypes = $fileTypesMap[$sceneType] ?? $this->config['file_types'] ?? ['controller', 'model', 'service', 'dao', 'validate', 'schema', 'dto'];
|
||||
|
||||
// 生成每种文件类型
|
||||
foreach ($fileTypes as $fileType) {
|
||||
try {
|
||||
// 获取文件类型生成器
|
||||
$fileGenerator = $this->generatorFactory->createFileGenerator($fileType, $this->config);
|
||||
|
||||
// 生成文件内容
|
||||
$content = $fileGenerator->generateContent();
|
||||
|
||||
// 验证内容是否为空
|
||||
if (empty($content)) {
|
||||
throw new \Exception('Generated content is empty for file type: ' . $fileType);
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
$extension = $fileGenerator->getFileExtension();
|
||||
|
||||
// 生成文件路径
|
||||
$filePath = $sceneGenerator->generateFilePath($fileType, $extension);
|
||||
|
||||
// 构建临时文件路径
|
||||
$relativePath = str_replace(dirname(base_path()), '', $filePath);
|
||||
$relativePath = ltrim($relativePath, DS);
|
||||
$tempFilePath = $tempDir . DS . $relativePath;
|
||||
|
||||
// 确保目录存在
|
||||
$tempDirPath = dirname($tempFilePath);
|
||||
if (!is_dir($tempDirPath)) {
|
||||
mkdir($tempDirPath, 0777, true);
|
||||
}
|
||||
|
||||
// 写入临时文件
|
||||
if (file_put_contents($tempFilePath, $content) === false) {
|
||||
throw new \Exception('Failed to write temporary file: ' . $tempFilePath);
|
||||
}
|
||||
|
||||
// 处理 DTO 特殊情况:同时生成 QueryRequest
|
||||
if ($fileType === 'dto' && method_exists($fileGenerator, 'getQueryRequestContent')) {
|
||||
try {
|
||||
// 获取 QueryRequest 内容
|
||||
$queryContent = $fileGenerator->getQueryRequestContent();
|
||||
|
||||
if (!empty($queryContent)) {
|
||||
// 生成 QueryRequest 文件路径
|
||||
$formDir = dirname($filePath);
|
||||
$fileName = basename($filePath);
|
||||
$queryFileName = str_replace('FormRequest', 'QueryRequest', $fileName);
|
||||
$queryFilePath = $formDir . DS . $queryFileName;
|
||||
|
||||
// 构建临时文件路径(与 FormRequest 处理方式一致)
|
||||
$queryRelativePath = str_replace(dirname(base_path()), '', $queryFilePath);
|
||||
$queryRelativePath = ltrim($queryRelativePath, DS);
|
||||
$tempQueryFilePath = $tempDir . DS . $queryRelativePath;
|
||||
|
||||
// 确保目录存在
|
||||
$tempQueryDir = dirname($tempQueryFilePath);
|
||||
if (!is_dir($tempQueryDir)) {
|
||||
mkdir($tempQueryDir, 0777, true);
|
||||
}
|
||||
|
||||
// 写入临时文件
|
||||
if (file_put_contents($tempQueryFilePath, $queryContent) === false) {
|
||||
throw new \Exception('Failed to write temporary QueryRequest file: ' . $tempQueryFilePath);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error generating QueryRequest: ' . $e->getMessage(), [
|
||||
'file_type' => $fileType,
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error generating file: ' . $e->getMessage(), [
|
||||
'file_type' => $fileType,
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in scene generation: ' . $e->getMessage(), [
|
||||
'scene_type' => $sceneType,
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 zip 文件
|
||||
$zipFileName = 'generator_' . uniqid() . '.zip';
|
||||
$zipFilePath = sys_get_temp_dir() . DS . $zipFileName;
|
||||
|
||||
if ($this->createZip($tempDir, $zipFilePath)) {
|
||||
// 清理临时目录
|
||||
$this->cleanupDir($tempDir);
|
||||
|
||||
// 确保返回的路径是绝对路径且格式正确
|
||||
$zipFilePath = realpath($zipFilePath);
|
||||
if (!$zipFilePath) {
|
||||
throw new \Exception('Failed to get real path for zip file');
|
||||
}
|
||||
|
||||
// 检查zip文件大小,确保不是空文件
|
||||
$fileSize = filesize($zipFilePath);
|
||||
if ($fileSize < 22) { // 空zip文件的最小大小约为22字节
|
||||
throw new \Exception('Generated zip file is empty');
|
||||
}
|
||||
|
||||
// 确保路径使用Windows兼容的反斜杠格式
|
||||
$zipFilePath = str_replace('/', '\\', $zipFilePath);
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'file_path' => $zipFilePath,
|
||||
'file_name' => $zipFileName,
|
||||
'file_size' => $fileSize,
|
||||
];
|
||||
} else {
|
||||
// 清理临时目录
|
||||
$this->cleanupDir($tempDir);
|
||||
|
||||
throw new \Exception('Failed to create zip file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 zip 文件
|
||||
* @param string $sourceDir 源目录
|
||||
* @param string $zipFilePath zip 文件路径
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
private function createZip(string $sourceDir, string $zipFilePath): bool
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$files = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($sourceDir),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!$file->isDir()) {
|
||||
$filePath = $file->getRealPath();
|
||||
$relativePath = substr($filePath, strlen($sourceDir) + 1);
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return $zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理目录
|
||||
* @param string $dir 目录路径
|
||||
*/
|
||||
private function cleanupDir(string $dir): void
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . DS . $file;
|
||||
if (is_dir($path)) {
|
||||
$this->cleanupDir($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模块命名
|
||||
* @param array $config 生成配置
|
||||
* @throws \Exception 验证失败时抛出异常
|
||||
*/
|
||||
private function validateModuleName(array $config): void
|
||||
{
|
||||
// 直接读取配置文件
|
||||
$configFile = __DIR__ . '/config/app.php';
|
||||
$restrictions = file_exists($configFile) ? include $configFile : [];
|
||||
// 获取模块命名限制配置
|
||||
$restrictions = $restrictions['module_name_restrictions'] ?? [];
|
||||
// 如果未启用验证,直接返回
|
||||
if (!isset($restrictions['enabled']) || !$restrictions['enabled']) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取模块名
|
||||
$moduleName = $config['package_name'] ?? null;
|
||||
|
||||
if (!$moduleName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否为保留名称
|
||||
if (isset($restrictions['reserved_names']) && is_array($restrictions['reserved_names'])) {
|
||||
if (in_array(strtolower($moduleName), array_map('strtolower', $restrictions['reserved_names']))) {
|
||||
throw new \Exception(sprintf('Module name "%s" is reserved and cannot be used', $moduleName));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查命名格式
|
||||
if (isset($restrictions['pattern']) && $restrictions['pattern']) {
|
||||
if (!preg_match($restrictions['pattern'], $moduleName)) {
|
||||
throw new \Exception(sprintf('Module name "%s" is invalid. It should match pattern: %s', $moduleName, $restrictions['pattern']));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
return [
|
||||
'enable' => true,
|
||||
// 模块命名限制
|
||||
'module_name_restrictions' => [
|
||||
'enabled' => true,
|
||||
'reserved_names' => [
|
||||
'system',
|
||||
'role',
|
||||
'admin',
|
||||
'auth',
|
||||
'user',
|
||||
'permission',
|
||||
'menu',
|
||||
'dept',
|
||||
'post',
|
||||
'dict',
|
||||
'config',
|
||||
'log',
|
||||
'monitor',
|
||||
'plugin',
|
||||
'dev',
|
||||
'test',
|
||||
'generator'
|
||||
],
|
||||
'pattern' => '/^[a-z0-9_-]+$/'
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"id": "272716236746915840",
|
||||
"table_name": "sys_admin_dept",
|
||||
"table_content": "用户与部门关联表",
|
||||
"module_name": "sys_admin_dept",
|
||||
"class_name": "sys_admin_dept",
|
||||
"edit_type": 1,
|
||||
"plugin_name": "",
|
||||
"order_type": 1,
|
||||
"parent_menu": "",
|
||||
"relations": [],
|
||||
"push_sync_count": 0,
|
||||
"created_at": "2026-01-22T05:16:57.000000Z",
|
||||
"updated_at": "2026-01-22T05:16:57.000000Z",
|
||||
"basic": {
|
||||
"table_name": "sys_admin_dept",
|
||||
"table_content": "用户与部门关联表",
|
||||
"plugin_name": "",
|
||||
"module_name": "sys_admin_dept",
|
||||
"class_name": "sys_admin_dept"
|
||||
},
|
||||
"config": {
|
||||
"is_delete": 1,
|
||||
"delete_column_name": "dept_id",
|
||||
"edit_type": 1,
|
||||
"order_column_name": "dept_id",
|
||||
"order_type": 1,
|
||||
"parent_menu": "",
|
||||
"plugin_name": "",
|
||||
"relations": [],
|
||||
"is_detail": 0
|
||||
},
|
||||
"created_date": "2026-01-22 13:16:57",
|
||||
"updated_date": "2026-01-22 13:16:57",
|
||||
"columns": [
|
||||
{
|
||||
"id": "272716237116014592",
|
||||
"table_id": "272716236746915840",
|
||||
"column_name": "dept_id",
|
||||
"column_comment": "角色主键",
|
||||
"column_type": "int",
|
||||
"is_required": 0,
|
||||
"is_pk": 1,
|
||||
"is_insert": 1,
|
||||
"is_update": 1,
|
||||
"is_lists": 1,
|
||||
"is_query": 1,
|
||||
"is_search": 1,
|
||||
"query_type": "=",
|
||||
"view_type": "input",
|
||||
"dict_type": "",
|
||||
"plugin": "",
|
||||
"model": "",
|
||||
"label_key": "",
|
||||
"value_key": "",
|
||||
"is_delete": 1,
|
||||
"is_order": 1,
|
||||
"validate_type": "",
|
||||
"created_at": "2026-01-22T05:16:57.000000Z",
|
||||
"updated_at": "2026-01-22T05:16:57.000000Z",
|
||||
"sort": 0,
|
||||
"created_date": "2026-01-22 13:16:57",
|
||||
"updated_date": "2026-01-22 13:16:57"
|
||||
},
|
||||
{
|
||||
"id": "272716236931465216",
|
||||
"table_id": "272716236746915840",
|
||||
"column_name": "admin_id",
|
||||
"column_comment": "用户主键",
|
||||
"column_type": "int",
|
||||
"is_required": 0,
|
||||
"is_pk": 1,
|
||||
"is_insert": 1,
|
||||
"is_update": 1,
|
||||
"is_lists": 1,
|
||||
"is_query": 1,
|
||||
"is_search": 1,
|
||||
"query_type": "=",
|
||||
"view_type": "input",
|
||||
"dict_type": "",
|
||||
"plugin": "",
|
||||
"model": "",
|
||||
"label_key": "",
|
||||
"value_key": "",
|
||||
"is_delete": 0,
|
||||
"is_order": 0,
|
||||
"validate_type": "",
|
||||
"created_at": "2026-01-22T05:16:57.000000Z",
|
||||
"updated_at": "2026-01-22T05:16:57.000000Z",
|
||||
"sort": 0,
|
||||
"created_date": "2026-01-22 13:16:57",
|
||||
"updated_date": "2026-01-22 13:16:57"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\factory;
|
||||
|
||||
use core\generator\interfaces\SceneGeneratorInterface;
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
|
||||
/**
|
||||
* 生成器工厂
|
||||
* 负责创建不同类型的生成器
|
||||
*/
|
||||
class GeneratorFactory
|
||||
{
|
||||
/**
|
||||
* 创建场景生成器
|
||||
*
|
||||
* @param string $sceneType 场景类型
|
||||
* @param array $config 配置信息
|
||||
*
|
||||
* @return SceneGeneratorInterface 场景生成器
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createSceneGenerator(string $sceneType, array $config): SceneGeneratorInterface
|
||||
{
|
||||
$className = "core\\generator\\scene\\" . ucfirst($sceneType) . "SceneGenerator";
|
||||
if (!class_exists($className)) {
|
||||
throw new \Exception('Scene generator not found: ' . $sceneType);
|
||||
}
|
||||
|
||||
return new $className($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件类型生成器
|
||||
*
|
||||
* @param string $fileType 文件类型
|
||||
* @param array $config 配置信息
|
||||
*
|
||||
* @return FileGeneratorInterface 文件类型生成器
|
||||
*/
|
||||
public function createFileGenerator(string $fileType, array $config): FileGeneratorInterface
|
||||
{
|
||||
// 处理特殊文件类型
|
||||
$fileTypeMap = [
|
||||
'api_model' => 'ApiModel',
|
||||
'view_schema' => 'ViewSchema',
|
||||
'request_form' => 'RequestForm',
|
||||
'request_query' => 'RequestQuery',
|
||||
|
||||
];
|
||||
|
||||
if (isset($fileTypeMap[$fileType])) {
|
||||
$className = "core\\generator\\file\\" . $fileTypeMap[$fileType] . "Generator";
|
||||
} else {
|
||||
$className = "core\\generator\\file\\" . ucfirst($fileType) . "Generator";
|
||||
}
|
||||
|
||||
if (!class_exists($className)) {
|
||||
throw new \Exception('File generator not found: ' . $fileType);
|
||||
}
|
||||
|
||||
return new $className($config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* API 生成器
|
||||
* 负责生成前端 API 文件内容
|
||||
*/
|
||||
class ApiGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$className = $this->config['class_name'] ?? 'DefaultModel';
|
||||
$moduleName = $this->config['package_name'] ?? 'default';
|
||||
|
||||
// 生成带类名的接口类型名称
|
||||
$rowTypeName = $className . 'Row';
|
||||
|
||||
// 生成baseUrl,使用连字符(-)而不是下划线(_)
|
||||
$basePath = strtolower(str_replace('_', '-', $moduleName));
|
||||
$classPath = strtolower(str_replace('_', '-', $className));
|
||||
$baseUrl = $this->config['base_url'] ?? '/' . $basePath . '/' . $classPath;
|
||||
|
||||
$content = $this->templateRenderer->render('admin/api/index.stub', [
|
||||
'class_name' => $className,
|
||||
'module_name' => $moduleName,
|
||||
'base_url' => $baseUrl,
|
||||
'row_type_name' => $rowTypeName,
|
||||
]);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'ts';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* API 模型生成器
|
||||
* 负责生成前端 API 模型文件内容
|
||||
*/
|
||||
class ApiModelGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$className = $this->config['class_name'] ?? 'DefaultModel';
|
||||
$moduleName = $this->config['package_name'] ?? 'default';
|
||||
|
||||
// 生成带类名的接口类型名称
|
||||
$rowTypeName = $className . 'Row';
|
||||
|
||||
// 生成字段类型映射
|
||||
$fields = '';
|
||||
if (isset($this->config['columns']) && is_array($this->config['columns'])) {
|
||||
foreach ($this->config['columns'] as $column) {
|
||||
if (isset($column['column_name']) && isset($column['column_type'])) {
|
||||
$fieldName = $column['column_name'];
|
||||
$fieldType = $this->getColumnType($column['column_type']);
|
||||
$fieldComment = isset($column['column_comment']) ? $column['column_comment'] : '';
|
||||
|
||||
// 如果有注释,添加注释行
|
||||
if (!empty($fieldComment)) {
|
||||
$fields .= " /** {$fieldComment} */\n";
|
||||
}
|
||||
|
||||
$fields .= " {$fieldName}: {$fieldType};\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$content = $this->templateRenderer->render('admin/api/types.stub', [
|
||||
'class_name' => $className,
|
||||
'module_name' => $moduleName,
|
||||
'row_type_name' => $rowTypeName,
|
||||
'fields' => $fields,
|
||||
]);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据数据库字段类型获取 TypeScript 类型
|
||||
* @param string $columnType 数据库字段类型
|
||||
* @return string TypeScript 类型
|
||||
*/
|
||||
private function getColumnType(string $columnType): string
|
||||
{
|
||||
$typeMap = [
|
||||
'int' => 'number',
|
||||
'integer' => 'number',
|
||||
'tinyint' => 'number',
|
||||
'smallint' => 'number',
|
||||
'mediumint' => 'number',
|
||||
'bigint' => 'number',
|
||||
'float' => 'number',
|
||||
'double' => 'number',
|
||||
'decimal' => 'number',
|
||||
'string' => 'string',
|
||||
'varchar' => 'string',
|
||||
'char' => 'string',
|
||||
'text' => 'string',
|
||||
'mediumtext' => 'string',
|
||||
'longtext' => 'string',
|
||||
'date' => 'string',
|
||||
'datetime' => 'string',
|
||||
'timestamp' => 'string',
|
||||
'time' => 'string',
|
||||
'year' => 'number',
|
||||
'boolean' => 'boolean',
|
||||
'bool' => 'boolean',
|
||||
'json' => 'any',
|
||||
'array' => 'any[]',
|
||||
];
|
||||
|
||||
// 转换为小写并获取基础类型
|
||||
$columnType = strtolower($columnType);
|
||||
foreach ($typeMap as $key => $value) {
|
||||
if (strpos($columnType, $key) === 0) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认类型
|
||||
return 'any';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'ts';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* 控制器生成器
|
||||
* 负责生成控制器文件内容
|
||||
*/
|
||||
class ControllerGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
*
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
$className = $this->config['class_name'] ?? 'DefaultModel';
|
||||
$packageName = $this->config['package_name'] ?? 'default';
|
||||
|
||||
// 生成连字符格式的名称(用于路由path)
|
||||
$packageNameDash = str_replace('_', '-', $packageName);
|
||||
$classNameDash = str_replace('_', '-', strtolower($className));
|
||||
|
||||
// 生成小写下划线格式的名称(用于权限码)
|
||||
$packageNameLower = strtolower($packageName);
|
||||
$classNameLower = strtolower(str_replace('_', '', $className));
|
||||
|
||||
// 生成命名空间
|
||||
if ($isPlugin) {
|
||||
// 插件模式:plugin\{plugin_name}\app\controller\{package_name}
|
||||
$namespace = "plugin\\{$pluginName}\\app\\controller\\{$packageName}";
|
||||
$schemaRequestNamespace = "plugin\\{$pluginName}\\app\\schema\\request\\{$packageName}";
|
||||
$validateNamespace = "plugin\\{$pluginName}\\app\\validate\\{$packageName}";
|
||||
$schemaResponseNamespace = "plugin\\{$pluginName}\\app\\schema\\response\\{$packageName}";
|
||||
$serviceNamespace = "plugin\\{$pluginName}\\app\\service\\{$packageName}";
|
||||
} else {
|
||||
// App 模式
|
||||
$namespace = "app\\adminapi\\controller\\{$packageName}";
|
||||
$schemaRequestNamespace = "app\\adminapi\\schema\\request\\{$packageName}";
|
||||
$validateNamespace = "app\\adminapi\\validate\\{$packageName}";
|
||||
$schemaResponseNamespace = "app\\adminapi\\schema\\response\\{$packageName}";
|
||||
$serviceNamespace = "app\\service\\admin\\{$packageName}";
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class_name' => $className,
|
||||
'package_name' => $packageName,
|
||||
'package_name_dash' => $packageNameDash,
|
||||
'class_name_dash' => $classNameDash,
|
||||
'package_name_lower' => $packageNameLower,
|
||||
'class_name_lower' => $classNameLower,
|
||||
'table_content' => $this->config['table_content'] ?? '默认模型',
|
||||
'camel_class_name' => lcfirst($className),
|
||||
'namespace' => $namespace,
|
||||
'schema_request_namespace' => $schemaRequestNamespace,
|
||||
'validate_namespace' => $validateNamespace,
|
||||
'schema_response_namespace' => $schemaResponseNamespace,
|
||||
'service_namespace' => $serviceNamespace,
|
||||
];
|
||||
|
||||
return $this->templateRenderer->render('server/controller/controller.stub', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* DAO 生成器
|
||||
* 负责生成 DAO 文件内容
|
||||
*/
|
||||
class DaoGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成命名空间
|
||||
$packageName = $this->config['package_name'] ?? 'default';
|
||||
if ($isPlugin) {
|
||||
// 插件模式:plugin\{plugin_name}\app\dao\{package_name}
|
||||
$namespace = "plugin\\{$pluginName}\\app\\dao\\{$packageName}";
|
||||
$modelNamespace = "plugin\\{$pluginName}\\app\\model\\{$packageName}";
|
||||
} else {
|
||||
// App 模式:app\dao\{package_name}
|
||||
$namespace = "app\\dao\\{$packageName}";
|
||||
$modelNamespace = "app\\model\\{$packageName}";
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class_name' => $this->config['class_name'] ?? 'DefaultModel',
|
||||
'package_name' => $this->config['package_name'] ?? 'default',
|
||||
'table_name' => $this->config['table_name'] ?? 'default_table',
|
||||
'camel_class_name' => lcfirst($this->config['class_name'] ?? 'DefaultModel'),
|
||||
'namespace' => $namespace,
|
||||
'model_namespace' => $modelNamespace,
|
||||
];
|
||||
|
||||
return $this->templateRenderer->render('server/dao/dao.stub', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* 语言包生成器
|
||||
* 负责生成前端语言包文件内容
|
||||
*/
|
||||
class LangGenerator implements FileGeneratorInterface
|
||||
{
|
||||
private array $config;
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
public function generateContent(): string
|
||||
{
|
||||
$className = $this->config['class_name'] ?? 'DefaultModel';
|
||||
$moduleName = $this->config['package_name'] ?? 'default';
|
||||
$columns = $this->config['columns'] ?? [];
|
||||
|
||||
$langKey = $this->generateLangKey($moduleName, $className);
|
||||
$fields = $this->generateFieldsTranslations($columns, $langKey);
|
||||
$classComment = $this->getClassComment($columns);
|
||||
|
||||
$content = $this->templateRenderer->render('admin/lang/zh-cn/index.stub', [
|
||||
'module_name' => $moduleName,
|
||||
'class_name' => $className,
|
||||
'lang_key' => $langKey,
|
||||
'class_name_comment' => $classComment,
|
||||
'fields' => $fields,
|
||||
]);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function generateLangKey(string $moduleName, string $className): string
|
||||
{
|
||||
$classKey = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $className));
|
||||
return "{$classKey}";
|
||||
}
|
||||
|
||||
private function generateFieldsTranslations(array $columns, string $langKey): string
|
||||
{
|
||||
$listFields = [];
|
||||
$formFields = [];
|
||||
$searchFields = [];
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$fieldName = $column['column_name'];
|
||||
$comment = $column['column_comment'] ?? $fieldName;
|
||||
|
||||
if (isset($column['is_lists']) && $column['is_lists'] == 1) {
|
||||
$listFields[] = " \"{$fieldName}\": \"{$comment}\"";
|
||||
}
|
||||
|
||||
if ((isset($column['is_insert']) && $column['is_insert'] == 1) ||
|
||||
(isset($column['is_update']) && $column['is_update'] == 1)) {
|
||||
$formFields[] = " \"{$fieldName}\": \"{$comment}\"";
|
||||
}
|
||||
|
||||
if (isset($column['is_search']) && $column['is_search'] == 1) {
|
||||
$searchFields[] = " \"{$fieldName}\": \"{$comment}\"";
|
||||
}
|
||||
}
|
||||
|
||||
$result = '';
|
||||
|
||||
if (!empty($listFields)) {
|
||||
$result .= " \"list\": {\n" . implode(",\n", $listFields) . "\n },\n";
|
||||
}
|
||||
|
||||
if (!empty($formFields)) {
|
||||
$result .= " \"form\": {\n" . implode(",\n", $formFields) . "\n },\n";
|
||||
}
|
||||
|
||||
if (!empty($searchFields)) {
|
||||
$result .= " \"search\": {\n" . implode(",\n", $searchFields) . "\n }";
|
||||
}
|
||||
|
||||
return rtrim($result, ",\n");
|
||||
}
|
||||
|
||||
private function getClassComment(array $columns): string
|
||||
{
|
||||
return $this->config['class_name_comment'] ?? $this->config['table_comment'] ?? $this->config['class_name'];
|
||||
}
|
||||
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'json';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* 模型生成器
|
||||
* 负责生成模型文件内容
|
||||
*/
|
||||
class ModelGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
*
|
||||
* @return string 文件内容
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成命名空间
|
||||
$packageName = $this->config['package_name'] ?? 'default';
|
||||
if ($isPlugin) {
|
||||
// 插件模式:plugin\{plugin_name}\app\model\{package_name}
|
||||
$namespace = "plugin\\{$pluginName}\\app\\model\\{$packageName}";
|
||||
} else {
|
||||
// App 模式:app\model\{package_name}
|
||||
$namespace = "app\\model\\{$packageName}";
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class_name' => $this->config['class_name'] ?? 'DefaultModel',
|
||||
'package_name' => $this->config['package_name'] ?? 'default',
|
||||
'table_name' => $this->config['table_name'] ?? 'default_table',
|
||||
'table_content' => $this->config['table_content'] ?? '默认模型',
|
||||
'camel_class_name' => lcfirst($this->config['class_name'] ?? 'DefaultModel'),
|
||||
'namespace' => $namespace,
|
||||
];
|
||||
|
||||
// 查找主键字段
|
||||
$primaryKey = 'id';
|
||||
if (isset($this->config['columns']) && is_array($this->config['columns'])) {
|
||||
foreach ($this->config['columns'] as $column) {
|
||||
if (isset($column['column_name']) && isset($column['is_pk']) && $column['is_pk']) {
|
||||
$primaryKey = $column['column_name'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加主键字段到数据中
|
||||
$data['primary_key'] = $primaryKey;
|
||||
|
||||
// 生成首字母大写的主键字段名,处理下划线的情况
|
||||
$ucfirstPrimaryKey = ucfirst($primaryKey);
|
||||
$ucfirstPrimaryKey = str_replace('_', '', ucwords($ucfirstPrimaryKey, '_'));
|
||||
$data['ucfirst_primary_key'] = $ucfirstPrimaryKey;
|
||||
|
||||
// 生成可填充字段
|
||||
$fillableFields = '';
|
||||
if (isset($this->config['columns']) && is_array($this->config['columns'])) {
|
||||
foreach ($this->config['columns'] as $column) {
|
||||
if (isset($column['column_name'])) {
|
||||
$fillableFields .= " '{$column['column_name']}',\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到数据中
|
||||
$data['fillable_fields'] = $fillableFields;
|
||||
|
||||
// 生成基于is_search字段的搜索器
|
||||
$searchers = '';
|
||||
if (isset($this->config['columns']) && is_array($this->config['columns'])) {
|
||||
foreach ($this->config['columns'] as $column) {
|
||||
if (isset($column['column_name']) && isset($column['is_search']) && $column['is_search']) {
|
||||
$columnName = $column['column_name'];
|
||||
// 生成首字母大写的字段名,处理下划线的情况
|
||||
$ucfirstColumnName = ucfirst($columnName);
|
||||
$ucfirstColumnName = str_replace('_', '', ucwords($ucfirstColumnName, '_'));
|
||||
|
||||
// 确定搜索运算符
|
||||
$operator = 'like';
|
||||
if (isset($column['query_type']) && !empty($column['query_type'])) {
|
||||
$operator = strtoupper($column['query_type']);
|
||||
}
|
||||
|
||||
// 生成搜索器方法
|
||||
$searchers .= " /**\n";
|
||||
$searchers .= " * 搜索器:{$columnName}\n";
|
||||
$searchers .= " */\n";
|
||||
$searchers .= " public function scope{$ucfirstColumnName}(\$query, \$value)\n";
|
||||
$searchers .= " {\n";
|
||||
$searchers .= " if (\$value) {\n";
|
||||
|
||||
// 根据不同的运算符生成不同的搜索逻辑
|
||||
switch ($operator) {
|
||||
case '=':
|
||||
case '!=':
|
||||
case '>':
|
||||
case '>=':
|
||||
case '<':
|
||||
case '<=':
|
||||
$searchers .= " \$query->where('{$columnName}', '{$operator}', \$value);\n";
|
||||
break;
|
||||
case 'LIKE':
|
||||
case 'NOT LIKE':
|
||||
$searchers .= " \$query->where('{$columnName}', '{$operator}', '%' . \$value . '%');\n";
|
||||
break;
|
||||
case 'IN':
|
||||
$searchers .= " if (is_array(\$value)) {\n";
|
||||
$searchers .= " \$query->whereIn('{$columnName}', \$value);\n";
|
||||
$searchers .= " }\n";
|
||||
break;
|
||||
case 'NOT IN':
|
||||
$searchers .= " if (is_array(\$value)) {\n";
|
||||
$searchers .= " \$query->whereNotIn('{$columnName}', \$value);\n";
|
||||
$searchers .= " }\n";
|
||||
break;
|
||||
case 'BETWEEN':
|
||||
$searchers .= " if (is_array(\$value) && count(\$value) == 2) {\n";
|
||||
$searchers .= " \$query->whereBetween('{$columnName}', \$value);\n";
|
||||
$searchers .= " }\n";
|
||||
break;
|
||||
case 'NOT BETWEEN':
|
||||
$searchers .= " if (is_array(\$value) && count(\$value) == 2) {\n";
|
||||
$searchers .= " \$query->whereNotBetween('{$columnName}', \$value);\n";
|
||||
$searchers .= " }\n";
|
||||
break;
|
||||
default:
|
||||
$searchers .= " \$query->where('{$columnName}', 'LIKE', '%' . \$value . '%');\n";
|
||||
break;
|
||||
}
|
||||
|
||||
$searchers .= " }\n";
|
||||
$searchers .= " return \$query;\n";
|
||||
$searchers .= " }\n\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到数据中
|
||||
$data['searchers'] = $searchers;
|
||||
|
||||
// 生成关联方法
|
||||
$relations = '';
|
||||
if (isset($this->config['relations']) && !empty($this->config['relations'])) {
|
||||
foreach ($this->config['relations'] as $relation) {
|
||||
if (isset($relation['type']) && isset($relation['model']) && isset($relation['foreign_key']) && isset($relation['local_key'])) {
|
||||
$relationType = $relation['type'];
|
||||
$relationModel = $relation['model'];
|
||||
$foreignKey = $relation['foreign_key'];
|
||||
$localKey = $relation['local_key'];
|
||||
// 优化关联方法名称,只使用模型类名的小写形式
|
||||
$modelParts = explode('\\', $relationModel);
|
||||
$methodName = lcfirst(end($modelParts));
|
||||
|
||||
// 如果是hasMany关系,添加s后缀
|
||||
if ($relationType === 'hasMany') {
|
||||
$methodName .= 's';
|
||||
}
|
||||
|
||||
// 生成关联方法
|
||||
$relations .= " /**\n";
|
||||
$relations .= " * 关联 {$relationModel}\n";
|
||||
$relations .= " */\n";
|
||||
$relations .= " public function {$methodName}()\n";
|
||||
$relations .= " {\n";
|
||||
$relations .= " return \$this->{$relationType}('{$relationModel}', '{$foreignKey}', '{$localKey}');\n";
|
||||
$relations .= " }\n\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成软删除功能
|
||||
$softDelete = '';
|
||||
$softDeleteUse = '';
|
||||
$softDeleteTrait = '';
|
||||
$config = $this->config['config'] ?? [];
|
||||
if (isset($config['is_delete']) && $config['is_delete'] == 1) {
|
||||
$deleteColumn = $config['delete_column_name'] ?? 'deleted_at';
|
||||
$softDeleteUse = "use Illuminate\\Database\\Eloquent\\SoftDeletes;\n";
|
||||
$softDeleteTrait = " /**\n";
|
||||
$softDeleteTrait .= " * 启用软删除\n";
|
||||
$softDeleteTrait .= " */\n";
|
||||
$softDeleteTrait .= " use SoftDeletes;\n\n";
|
||||
$softDelete .= " /**\n";
|
||||
$softDelete .= " * 软删除字段\n";
|
||||
$softDelete .= " */\n";
|
||||
$softDelete .= " const DELETED_AT = '{$deleteColumn}';\n\n";
|
||||
}
|
||||
|
||||
// 添加到数据中
|
||||
$data['soft_delete_use'] = $softDeleteUse;
|
||||
$data['soft_delete_trait'] = $softDeleteTrait;
|
||||
$data['soft_delete'] = $softDelete;
|
||||
$data['relations'] = $relations;
|
||||
|
||||
return $this->templateRenderer->render('server/model/model.stub', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* DTO 生成器
|
||||
* 负责生成 DTO 文件内容
|
||||
*/
|
||||
class RequestFormGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
*
|
||||
* @return string 文件内容
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成命名空间
|
||||
$packageName = $this->config['package_name'] ?? 'default';
|
||||
if ($isPlugin) {
|
||||
// 插件模式:plugin\{plugin_name}\app\schema\request\{package_name}
|
||||
$namespace = "plugin\\{$pluginName}\\app\\schema\\request\\{$packageName}";
|
||||
} else {
|
||||
// App 模式:app\adminapi\schema\\request\{package_name}
|
||||
$namespace = "app\\adminapi\\schema\\request\\{$packageName}";
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class_name' => $this->config['class_name'] ?? 'DefaultModel',
|
||||
'package_name' => $this->config['package_name'] ?? 'default',
|
||||
'columns' => $this->config['columns'] ?? [],
|
||||
'namespace' => $namespace,
|
||||
];
|
||||
|
||||
// 生成字段注解
|
||||
$fields = '';
|
||||
if (isset($data['columns']) && is_array($data['columns'])) {
|
||||
foreach ($data['columns'] as $column) {
|
||||
// 跳过主键字段
|
||||
if (isset($column['is_pk']) && $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成字段注解
|
||||
$fields .= $this->generateFieldAnnotation($column);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到数据中
|
||||
$data['fields'] = $fields;
|
||||
|
||||
return $this->templateRenderer->render('server/schema/request/form.stub', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成字段注解
|
||||
* @param array $column 字段信息
|
||||
* @return string 字段注解代码
|
||||
*/
|
||||
private function generateFieldAnnotation(array $column): string
|
||||
{
|
||||
$columnName = $column['column_name'];
|
||||
$columnComment = $column['column_comment'];
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
|
||||
// 确定字段类型
|
||||
$phpType = $this->getPhpType($columnType);
|
||||
$nullableType = !$isRequired ? '?' . $phpType : $phpType;
|
||||
$defaultValue = !$isRequired ? ' = null' : '';
|
||||
|
||||
// 生成 OA\Property 注解
|
||||
$annotation = " #[OA\\Property(\n";
|
||||
$annotation .= " description: '{$columnComment}',\n";
|
||||
$annotation .= " type: '{$this->getOpenApiType($columnType)}',\n";
|
||||
$annotation .= " example: '{$this->getExampleValue($columnType)}',\n";
|
||||
$annotation .= " nullable: " . ($isRequired ? 'false' : 'true') . "\n";
|
||||
$annotation .= " )]\n";
|
||||
|
||||
// 生成 ValidationRules 注解
|
||||
$rules = $this->generateValidationRules($column);
|
||||
$annotation .= " #[ValidationRules(rules: '{$rules}')]\n";
|
||||
|
||||
// 生成字段声明
|
||||
$annotation .= " public {$nullableType} $" . "{$columnName}{$defaultValue};\n\n";
|
||||
|
||||
return $annotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PHP 类型
|
||||
* @param string $columnType 数据库字段类型
|
||||
* @return string PHP 类型
|
||||
*/
|
||||
private function getPhpType(string $columnType): string
|
||||
{
|
||||
$typeMap = [
|
||||
'int' => 'int',
|
||||
'integer' => 'int',
|
||||
'string' => 'string',
|
||||
'varchar' => 'string',
|
||||
'text' => 'string',
|
||||
'boolean' => 'bool',
|
||||
'bool' => 'bool',
|
||||
'float' => 'float',
|
||||
'double' => 'float',
|
||||
'array' => 'array',
|
||||
'json' => 'array',
|
||||
];
|
||||
|
||||
return $typeMap[strtolower($columnType)] ?? 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OpenAPI 类型
|
||||
* @param string $columnType 数据库字段类型
|
||||
* @return string OpenAPI 类型
|
||||
*/
|
||||
private function getOpenApiType(string $columnType): string
|
||||
{
|
||||
$typeMap = [
|
||||
'int' => 'integer',
|
||||
'integer' => 'integer',
|
||||
'string' => 'string',
|
||||
'varchar' => 'string',
|
||||
'text' => 'string',
|
||||
'boolean' => 'boolean',
|
||||
'bool' => 'boolean',
|
||||
'float' => 'number',
|
||||
'double' => 'number',
|
||||
'array' => 'array',
|
||||
'json' => 'object',
|
||||
];
|
||||
|
||||
return $typeMap[strtolower($columnType)] ?? 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取示例值
|
||||
* @param string $columnType 数据库字段类型
|
||||
* @return string 示例值
|
||||
*/
|
||||
private function getExampleValue(string $columnType): string
|
||||
{
|
||||
$exampleMap = [
|
||||
'int' => '1',
|
||||
'integer' => '1',
|
||||
'string' => '示例值',
|
||||
'varchar' => '示例值',
|
||||
'text' => '示例文本',
|
||||
'boolean' => 'true',
|
||||
'bool' => 'true',
|
||||
'float' => '1.0',
|
||||
'double' => '1.0',
|
||||
'array' => '[]',
|
||||
'json' => '{"key": "value"}',
|
||||
];
|
||||
|
||||
return $exampleMap[strtolower($columnType)] ?? '示例值';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证规则
|
||||
* @param array $column 字段信息
|
||||
* @return string 验证规则
|
||||
*/
|
||||
private function generateValidationRules(array $column): string
|
||||
{
|
||||
$columnName = $column['column_name'];
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
|
||||
$rules = [];
|
||||
|
||||
// 必填规则
|
||||
if ($isRequired) {
|
||||
$rules[] = 'required';
|
||||
} else {
|
||||
$rules[] = 'nullable';
|
||||
}
|
||||
|
||||
// 类型规则
|
||||
switch (strtolower($columnType)) {
|
||||
case 'int':
|
||||
case 'integer':
|
||||
$rules[] = 'integer';
|
||||
break;
|
||||
case 'float':
|
||||
case 'double':
|
||||
$rules[] = 'numeric';
|
||||
break;
|
||||
case 'boolean':
|
||||
case 'bool':
|
||||
$rules[] = 'boolean';
|
||||
break;
|
||||
case 'array':
|
||||
$rules[] = 'array';
|
||||
break;
|
||||
default:
|
||||
$rules[] = 'string';
|
||||
break;
|
||||
}
|
||||
|
||||
return implode('|', $rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* DTO 生成器
|
||||
* 负责生成 DTO 文件内容
|
||||
*/
|
||||
class RequestQueryGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
*
|
||||
* @return string 文件内容
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成命名空间
|
||||
$packageName = $this->config['package_name'] ?? 'default';
|
||||
if ($isPlugin) {
|
||||
// 插件模式:plugin\{plugin_name}\app\schema\request\{package_name}
|
||||
$namespace = "plugin\\{$pluginName}\\app\\schema\\request\\{$packageName}";
|
||||
} else {
|
||||
// App 模式:app\adminapi\schema\request\{package_name}
|
||||
$namespace = "app\\adminapi\\schema\\request\\{$packageName}";
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class_name' => $this->config['class_name'] ?? 'DefaultModel',
|
||||
'package_name' => $this->config['package_name'] ?? 'default',
|
||||
'columns' => $this->config['columns'] ?? [],
|
||||
'namespace' => $namespace,
|
||||
];
|
||||
|
||||
// 生成字段注解
|
||||
$fields = '';
|
||||
if (isset($data['columns']) && is_array($data['columns'])) {
|
||||
foreach ($data['columns'] as $column) {
|
||||
// 跳过主键字段
|
||||
if (isset($column['is_pk']) && $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成字段注解
|
||||
$fields .= $this->generateFieldAnnotation($column);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到数据中
|
||||
$data['fields'] = $fields;
|
||||
|
||||
return $this->templateRenderer->render('server/schema/request/query.stub', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成字段注解
|
||||
*
|
||||
* @param array $column 字段信息
|
||||
*
|
||||
* @return string 字段注解代码
|
||||
*/
|
||||
private function generateFieldAnnotation(array $column): string
|
||||
{
|
||||
$columnName = $column['column_name'];
|
||||
$columnComment = $column['column_comment'];
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
|
||||
// 确定字段类型
|
||||
$phpType = $this->getPhpType($columnType);
|
||||
$nullableType = !$isRequired ? '?' . $phpType : $phpType;
|
||||
$defaultValue = !$isRequired ? ' = null' : '';
|
||||
|
||||
// 生成 OA\Property 注解
|
||||
$annotation = " #[OA\\Property(\n";
|
||||
$annotation .= " description: '{$columnComment}',\n";
|
||||
$annotation .= " type: '{$this->getOpenApiType($columnType)}',\n";
|
||||
$annotation .= " example: '{$this->getExampleValue($columnType)}',\n";
|
||||
$annotation .= " nullable: " . ($isRequired ? 'false' : 'true') . "\n";
|
||||
$annotation .= " )]\n";
|
||||
|
||||
// 生成 ValidationRules 注解
|
||||
$rules = $this->generateValidationRules($column);
|
||||
$annotation .= " #[ValidationRules(rules: '{$rules}')]\n";
|
||||
|
||||
// 生成字段声明
|
||||
$annotation .= " public {$nullableType} $" . "{$columnName}{$defaultValue};\n\n";
|
||||
|
||||
return $annotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PHP 类型
|
||||
*
|
||||
* @param string $columnType 数据库字段类型
|
||||
*
|
||||
* @return string PHP 类型
|
||||
*/
|
||||
private function getPhpType(string $columnType): string
|
||||
{
|
||||
$typeMap = [
|
||||
'int' => 'int',
|
||||
'integer' => 'int',
|
||||
'string' => 'string',
|
||||
'varchar' => 'string',
|
||||
'text' => 'string',
|
||||
'boolean' => 'bool',
|
||||
'bool' => 'bool',
|
||||
'float' => 'float',
|
||||
'double' => 'float',
|
||||
'array' => 'array',
|
||||
'json' => 'array',
|
||||
];
|
||||
|
||||
return $typeMap[strtolower($columnType)] ?? 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OpenAPI 类型
|
||||
*
|
||||
* @param string $columnType 数据库字段类型
|
||||
*
|
||||
* @return string OpenAPI 类型
|
||||
*/
|
||||
private function getOpenApiType(string $columnType): string
|
||||
{
|
||||
$typeMap = [
|
||||
'int' => 'integer',
|
||||
'integer' => 'integer',
|
||||
'string' => 'string',
|
||||
'varchar' => 'string',
|
||||
'text' => 'string',
|
||||
'boolean' => 'boolean',
|
||||
'bool' => 'boolean',
|
||||
'float' => 'number',
|
||||
'double' => 'number',
|
||||
'array' => 'array',
|
||||
'json' => 'object',
|
||||
];
|
||||
|
||||
return $typeMap[strtolower($columnType)] ?? 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取示例值
|
||||
*
|
||||
* @param string $columnType 数据库字段类型
|
||||
*
|
||||
* @return string 示例值
|
||||
*/
|
||||
private function getExampleValue(string $columnType): string
|
||||
{
|
||||
$exampleMap = [
|
||||
'int' => '1',
|
||||
'integer' => '1',
|
||||
'string' => '示例值',
|
||||
'varchar' => '示例值',
|
||||
'text' => '示例文本',
|
||||
'boolean' => 'true',
|
||||
'bool' => 'true',
|
||||
'float' => '1.0',
|
||||
'double' => '1.0',
|
||||
'array' => '[]',
|
||||
'json' => '{"key": "value"}',
|
||||
];
|
||||
|
||||
return $exampleMap[strtolower($columnType)] ?? '示例值';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证规则
|
||||
*
|
||||
* @param array $column 字段信息
|
||||
*
|
||||
* @return string 验证规则
|
||||
*/
|
||||
private function generateValidationRules(array $column): string
|
||||
{
|
||||
$columnName = $column['column_name'];
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
|
||||
$rules = [];
|
||||
|
||||
// 必填规则
|
||||
if ($isRequired) {
|
||||
$rules[] = 'required';
|
||||
} else {
|
||||
$rules[] = 'nullable';
|
||||
}
|
||||
|
||||
// 类型规则
|
||||
switch (strtolower($columnType)) {
|
||||
case 'int':
|
||||
case 'integer':
|
||||
$rules[] = 'integer';
|
||||
break;
|
||||
case 'float':
|
||||
case 'double':
|
||||
$rules[] = 'numeric';
|
||||
break;
|
||||
case 'boolean':
|
||||
case 'bool':
|
||||
$rules[] = 'boolean';
|
||||
break;
|
||||
case 'array':
|
||||
$rules[] = 'array';
|
||||
break;
|
||||
default:
|
||||
$rules[] = 'string';
|
||||
break;
|
||||
}
|
||||
|
||||
return implode('|', $rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* Schema 生成器
|
||||
* 负责生成 Schema 文件内容
|
||||
*/
|
||||
class ResponseGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成命名空间
|
||||
$packageName = $this->config['package_name'] ?? 'default';
|
||||
if ($isPlugin) {
|
||||
// 插件模式:plugin\{plugin_name}\app\schema\response\{package_name}
|
||||
$namespace = "plugin\\{$pluginName}\\app\\schema\\response\\{$packageName}";
|
||||
} else {
|
||||
// App 模式:app\adminapi\\schema\{package_name}
|
||||
$namespace = "app\\adminapi\\schema\\response\\{$packageName}";
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class_name' => $this->config['class_name'] ?? 'DefaultModel',
|
||||
'package_name' => $this->config['package_name'] ?? 'default',
|
||||
'columns' => $this->config['columns'] ?? [],
|
||||
'namespace' => $namespace,
|
||||
];
|
||||
|
||||
// 生成字段注解
|
||||
$fields = '';
|
||||
if (isset($data['columns']) && is_array($data['columns'])) {
|
||||
foreach ($data['columns'] as $column) {
|
||||
// 跳过主键字段
|
||||
if (isset($column['is_pk']) && $column['is_pk']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成字段注解
|
||||
$fields .= $this->generateFieldAnnotation($column);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到数据中
|
||||
$data['fields'] = $fields;
|
||||
|
||||
return $this->templateRenderer->render('server/schema/response/schema.stub', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成字段注解
|
||||
* @param array $column 字段信息
|
||||
* @return string 字段注解代码
|
||||
*/
|
||||
private function generateFieldAnnotation(array $column): string
|
||||
{
|
||||
$columnName = $column['column_name'];
|
||||
$columnComment = $column['column_comment'];
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
|
||||
// 确定字段类型
|
||||
$phpType = $this->getPhpType($columnType);
|
||||
$nullableType = !$isRequired ? '?' . $phpType : $phpType;
|
||||
$defaultValue = !$isRequired ? ' = null' : '';
|
||||
|
||||
// 生成 OA\Property 注解
|
||||
$annotation = " #[OA\Property(\n";
|
||||
$annotation .= " description: '{$columnComment}',\n";
|
||||
$annotation .= " type: '{$this->getOpenApiType($columnType)}',\n";
|
||||
$annotation .= " example: '{$this->getExampleValue($columnType)}',\n";
|
||||
$annotation .= " nullable: " . ($isRequired ? 'false' : 'true') . "\n";
|
||||
$annotation .= " )]\n";
|
||||
|
||||
// 生成字段声明(Schema定义不需要验证规则)
|
||||
$annotation .= " public {$nullableType} $" . "{$columnName}{$defaultValue};\n\n";
|
||||
|
||||
return $annotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PHP 类型
|
||||
* @param string $columnType 数据库字段类型
|
||||
* @return string PHP 类型
|
||||
*/
|
||||
private function getPhpType(string $columnType): string
|
||||
{
|
||||
$typeMap = [
|
||||
'int' => 'int',
|
||||
'integer' => 'int',
|
||||
'string' => 'string',
|
||||
'varchar' => 'string',
|
||||
'text' => 'string',
|
||||
'boolean' => 'bool',
|
||||
'bool' => 'bool',
|
||||
'float' => 'float',
|
||||
'double' => 'float',
|
||||
'array' => 'array',
|
||||
'json' => 'array',
|
||||
];
|
||||
|
||||
return $typeMap[strtolower($columnType)] ?? 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OpenAPI 类型
|
||||
* @param string $columnType 数据库字段类型
|
||||
* @return string OpenAPI 类型
|
||||
*/
|
||||
private function getOpenApiType(string $columnType): string
|
||||
{
|
||||
$typeMap = [
|
||||
'int' => 'integer',
|
||||
'integer' => 'integer',
|
||||
'string' => 'string',
|
||||
'varchar' => 'string',
|
||||
'text' => 'string',
|
||||
'boolean' => 'boolean',
|
||||
'bool' => 'boolean',
|
||||
'float' => 'number',
|
||||
'double' => 'number',
|
||||
'array' => 'array',
|
||||
'json' => 'object',
|
||||
];
|
||||
|
||||
return $typeMap[strtolower($columnType)] ?? 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取示例值
|
||||
* @param string $columnType 数据库字段类型
|
||||
* @return string 示例值
|
||||
*/
|
||||
private function getExampleValue(string $columnType): string
|
||||
{
|
||||
$exampleMap = [
|
||||
'int' => '1',
|
||||
'integer' => '1',
|
||||
'string' => '示例值',
|
||||
'varchar' => '示例值',
|
||||
'text' => '示例文本',
|
||||
'boolean' => 'true',
|
||||
'bool' => 'true',
|
||||
'float' => '1.0',
|
||||
'double' => '1.0',
|
||||
'array' => '[]',
|
||||
'json' => '{"key": "value"}',
|
||||
];
|
||||
|
||||
return $exampleMap[strtolower($columnType)] ?? '示例值';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证规则
|
||||
* @param array $column 字段信息
|
||||
* @return string 验证规则
|
||||
*/
|
||||
private function generateValidationRules(array $column): string
|
||||
{
|
||||
$columnName = $column['column_name'];
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
|
||||
$rules = [];
|
||||
|
||||
// 必填规则
|
||||
if ($isRequired) {
|
||||
$rules[] = 'required';
|
||||
} else {
|
||||
$rules[] = 'nullable';
|
||||
}
|
||||
|
||||
// 类型规则
|
||||
switch (strtolower($columnType)) {
|
||||
case 'int':
|
||||
case 'integer':
|
||||
$rules[] = 'integer';
|
||||
break;
|
||||
case 'float':
|
||||
case 'double':
|
||||
$rules[] = 'numeric';
|
||||
break;
|
||||
case 'boolean':
|
||||
case 'bool':
|
||||
$rules[] = 'boolean';
|
||||
break;
|
||||
case 'array':
|
||||
$rules[] = 'array';
|
||||
break;
|
||||
default:
|
||||
$rules[] = 'string';
|
||||
break;
|
||||
}
|
||||
|
||||
return implode('|', $rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* 服务生成器
|
||||
* 负责生成服务文件内容
|
||||
*/
|
||||
class ServiceGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成命名空间
|
||||
$packageName = $this->config['package_name'] ?? 'default';
|
||||
if ($isPlugin) {
|
||||
// 插件模式:plugin\{plugin_name}\app\service\{package_name}
|
||||
$namespace = "plugin\\{$pluginName}\\app\\service\\{$packageName}";
|
||||
} else {
|
||||
// App 模式:app\service\admin\{package_name}
|
||||
$namespace = "app\\service\\admin\\{$packageName}";
|
||||
}
|
||||
|
||||
// 生成 DAO 命名空间
|
||||
if ($isPlugin) {
|
||||
$daoNamespace = "plugin\\{$pluginName}\\app\\dao\\{$packageName}";
|
||||
} else {
|
||||
$daoNamespace = "app\\dao\\{$packageName}";
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class_name' => $this->config['class_name'] ?? 'DefaultModel',
|
||||
'package_name' => $this->config['package_name'] ?? 'default',
|
||||
'table_content' => $this->config['table_content'] ?? '默认模型',
|
||||
'camel_class_name' => lcfirst($this->config['class_name'] ?? 'DefaultModel'),
|
||||
'namespace' => $namespace,
|
||||
'dao_namespace' => $daoNamespace,
|
||||
];
|
||||
|
||||
return $this->templateRenderer->render('server/service/service.stub', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* 验证器生成器
|
||||
* 负责生成验证器文件内容
|
||||
*/
|
||||
class ValidateGenerator implements FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var TemplateRenderer 模板渲染器
|
||||
*/
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成命名空间
|
||||
$packageName = $this->config['package_name'] ?? 'default';
|
||||
if ($isPlugin) {
|
||||
// 插件模式:plugin\{plugin_name}\app\validate\{package_name}
|
||||
$namespace = "plugin\\{$pluginName}\\app\\validate\\{$packageName}";
|
||||
} else {
|
||||
// App 模式:app\adminapi\validate\{package_name}
|
||||
$namespace = "app\\adminapi\\validate\\{$packageName}";
|
||||
}
|
||||
|
||||
$data = [
|
||||
'class_name' => $this->config['class_name'] ?? 'DefaultModel',
|
||||
'package_name' => $this->config['package_name'] ?? 'default',
|
||||
'columns' => $this->config['columns'] ?? [],
|
||||
'namespace' => $namespace,
|
||||
];
|
||||
|
||||
// 生成场景字段
|
||||
$storeFields = [];
|
||||
$updateFields = [];
|
||||
$rules = [];
|
||||
$messages = [];
|
||||
|
||||
if (isset($data['columns']) && is_array($data['columns'])) {
|
||||
foreach ($data['columns'] as $column) {
|
||||
$columnName = $column['column_name'];
|
||||
$columnComment = $column['column_comment'];
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$isInsert = isset($column['is_insert']) && $column['is_insert'];
|
||||
$isUpdate = isset($column['is_update']) && $column['is_update'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
|
||||
// 生成验证规则
|
||||
$validationRule = $this->generateValidationRule($column);
|
||||
if (!empty($validationRule)) {
|
||||
$rules[] = "'{$columnName}' => '{$validationRule}'";
|
||||
|
||||
// 生成错误信息
|
||||
$errorMessages = $this->generateErrorMessage($column);
|
||||
foreach ($errorMessages as $rule => $message) {
|
||||
$messages[] = "'{$columnName}.{$rule}' => '{$message}'";
|
||||
}
|
||||
|
||||
// 添加到场景
|
||||
$isPk = isset($column['is_pk']) && $column['is_pk'];
|
||||
if ($isInsert && !$isPk) {
|
||||
$storeFields[] = "'{$columnName}'";
|
||||
}
|
||||
if ($isUpdate) {
|
||||
$updateFields[] = "'{$columnName}'";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到数据中
|
||||
$data['store_fields'] = implode(",\n ", $storeFields);
|
||||
$data['update_fields'] = implode(",\n ", $updateFields);
|
||||
$data['rules'] = implode(",\n ", $rules);
|
||||
$data['messages'] = implode(",\n ", $messages);
|
||||
|
||||
return $this->templateRenderer->render('server/validate/validation.stub', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证规则
|
||||
* @param array $column 字段信息
|
||||
* @return string 验证规则
|
||||
*/
|
||||
private function generateValidationRule(array $column): string
|
||||
{
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
$isPk = isset($column['is_pk']) && $column['is_pk'];
|
||||
|
||||
$rules = [];
|
||||
|
||||
// 必填规则
|
||||
if ($isRequired) {
|
||||
$rules[] = 'required';
|
||||
}
|
||||
|
||||
// 类型规则
|
||||
switch (strtolower($columnType)) {
|
||||
case 'int':
|
||||
case 'integer':
|
||||
// 主键字段支持int和string两种类型(如雪花ID)
|
||||
if ($isPk) {
|
||||
$rules[] = 'integer|string';
|
||||
} else {
|
||||
$rules[] = 'integer';
|
||||
}
|
||||
break;
|
||||
case 'float':
|
||||
case 'double':
|
||||
$rules[] = 'numeric';
|
||||
break;
|
||||
case 'boolean':
|
||||
case 'bool':
|
||||
$rules[] = 'boolean';
|
||||
break;
|
||||
case 'array':
|
||||
$rules[] = 'array';
|
||||
break;
|
||||
case 'email':
|
||||
$rules[] = 'email';
|
||||
break;
|
||||
default:
|
||||
$rules[] = 'string';
|
||||
break;
|
||||
}
|
||||
|
||||
return implode('|', $rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成错误信息
|
||||
* @param array $column 字段信息
|
||||
* @return array 错误信息
|
||||
*/
|
||||
private function generateErrorMessage(array $column): array
|
||||
{
|
||||
$columnName = $column['column_name'];
|
||||
$columnComment = $column['column_comment'];
|
||||
$isRequired = isset($column['is_required']) && $column['is_required'];
|
||||
$columnType = $column['column_type'] ?? 'string';
|
||||
|
||||
$messages = [];
|
||||
|
||||
// 必填错误信息
|
||||
if ($isRequired) {
|
||||
$messages['required'] = "{$columnComment}必须填写";
|
||||
}
|
||||
|
||||
$isPk = isset($column['is_pk']) && $column['is_pk'];
|
||||
|
||||
// 类型错误信息
|
||||
switch (strtolower($columnType)) {
|
||||
case 'int':
|
||||
case 'integer':
|
||||
// 主键字段支持int和string两种类型(如雪花ID)
|
||||
if ($isPk) {
|
||||
$messages['integer'] = "{$columnComment}必须是整数或字符串";
|
||||
$messages['string'] = "{$columnComment}必须是整数或字符串";
|
||||
} else {
|
||||
$messages['integer'] = "{$columnComment}必须是整数";
|
||||
}
|
||||
break;
|
||||
case 'float':
|
||||
case 'double':
|
||||
$messages['numeric'] = "{$columnComment}必须是数字";
|
||||
break;
|
||||
case 'boolean':
|
||||
case 'bool':
|
||||
$messages['boolean'] = "{$columnComment}必须是布尔值";
|
||||
break;
|
||||
case 'array':
|
||||
$messages['array'] = "{$columnComment}必须是数组";
|
||||
break;
|
||||
case 'email':
|
||||
$messages['email'] = "{$columnComment}格式不正确";
|
||||
break;
|
||||
default:
|
||||
$messages['string'] = "{$columnComment}必须是字符串";
|
||||
break;
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* 视图生成器
|
||||
* 负责生成前端视图文件内容
|
||||
*/
|
||||
class ViewGenerator implements FileGeneratorInterface
|
||||
{
|
||||
private array $config;
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
public function generateContent(): string
|
||||
{
|
||||
$className = $this->config['class_name'] ?? 'DefaultModel';
|
||||
$moduleName = $this->config['package_name'] ?? 'default';
|
||||
$dummyClassName = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $className));
|
||||
$serviceName = $className . 'Service';
|
||||
$kebabModuleName = str_replace('_', '-', $moduleName);
|
||||
$kebabClassName = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $className));
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成 API import 路径
|
||||
if ($isPlugin) {
|
||||
// 插件模式: @/apps/{plugin}/api/{kebab_module_name}
|
||||
$apiImportPath = '@/apps/' . $pluginName . '/api/' . $kebabModuleName;
|
||||
} else {
|
||||
// 应用模式: @/api/{kebab_module_name}
|
||||
$apiImportPath = '@/api/' . $kebabModuleName;
|
||||
}
|
||||
|
||||
$langKey = $this->generateLangKey($moduleName, $className);
|
||||
$langImportPath = $this->generateLangImportPath($moduleName);
|
||||
|
||||
$content = $this->templateRenderer->render('admin/views/index.stub', [
|
||||
'module_name' => $moduleName,
|
||||
'class_name' => $className,
|
||||
'dummy_class_name' => $dummyClassName,
|
||||
'service_name' => $serviceName,
|
||||
'kebab_module_name' => $kebabModuleName,
|
||||
'kebab_class_name' => $kebabClassName,
|
||||
'api_import_path' => $apiImportPath,
|
||||
'lang_key' => $langKey,
|
||||
'lang_import_path' => $langImportPath,
|
||||
]);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function generateLangKey(string $moduleName, string $className): string
|
||||
{
|
||||
$moduleKey = strtolower(str_replace('-', '_', $moduleName));
|
||||
$classKey = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $className));
|
||||
return "{$moduleKey}_{$classKey}";
|
||||
}
|
||||
|
||||
private function generateLangImportPath(string $moduleName): string
|
||||
{
|
||||
return "@/lang/{$moduleName}";
|
||||
}
|
||||
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'vue';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\file;
|
||||
|
||||
use core\generator\interfaces\FileGeneratorInterface;
|
||||
use core\generator\utils\TemplateRenderer;
|
||||
|
||||
/**
|
||||
* 视图 Schema 生成器
|
||||
* 负责生成前端视图 Schema 文件内容
|
||||
*/
|
||||
class ViewSchemaGenerator implements FileGeneratorInterface
|
||||
{
|
||||
private array $config;
|
||||
private TemplateRenderer $templateRenderer;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
public function generateContent(): string
|
||||
{
|
||||
$className = $this->config['class_name'] ?? 'DefaultModel';
|
||||
$moduleName = $this->config['package_name'] ?? 'default';
|
||||
$dummyClassName = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $className));
|
||||
$serviceName = $className . 'Service';
|
||||
$kebabModuleName = str_replace('_', '-', $moduleName);
|
||||
$columns = $this->config['columns'] ?? [];
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$isPlugin = $template !== 'app';
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
|
||||
// 生成 API import 路径
|
||||
if ($isPlugin) {
|
||||
// 插件模式: @/apps/{plugin}/api/{kebab_module_name}
|
||||
$apiImportPath = '@/apps/' . $pluginName . '/api/' . $kebabModuleName;
|
||||
} else {
|
||||
// 应用模式: @/api/{kebab_module_name}
|
||||
$apiImportPath = '@/api/' . $kebabModuleName;
|
||||
}
|
||||
|
||||
$langKey = $this->generateLangKey($moduleName, $className);
|
||||
$langImportPath = $this->generateLangImportPath($moduleName);
|
||||
|
||||
$tableColumns = $this->processTableColumns($columns, $langKey);
|
||||
$searchFormSchema = $this->processSearchFormSchema($columns, $langKey);
|
||||
$formSchema = $this->processFormSchema($columns, $langKey);
|
||||
|
||||
$dialogType = '';
|
||||
if (isset($this->config['config']['edit_type']) && $this->config['config']['edit_type'] == 2) {
|
||||
$dialogType = 'dialogType: "drawer"';
|
||||
}
|
||||
|
||||
$dialogWidth = $this->config['config']['dialog_width'] ?? '50%';
|
||||
|
||||
$content = $this->templateRenderer->render('admin/views/schemas/index.stub', [
|
||||
'class_name' => $className,
|
||||
'module_name' => $moduleName,
|
||||
'dummy_class_name' => $dummyClassName,
|
||||
'service_name' => $serviceName,
|
||||
'kebab_module_name' => $kebabModuleName,
|
||||
'api_import_path' => $apiImportPath,
|
||||
'lang_key' => $langKey,
|
||||
'lang_import_path' => $langImportPath,
|
||||
'table_columns' => $tableColumns,
|
||||
'search_form_schema' => $searchFormSchema,
|
||||
'form_schema' => $formSchema,
|
||||
'use_crud' => isset($this->config['config']['use_crud']) ? (bool)$this->config['config']['use_crud'] : true,
|
||||
'has_add' => isset($this->config['config']['has_add']) ? (bool)$this->config['config']['has_add'] : true,
|
||||
'has_remove' => isset($this->config['config']['has_remove']) ? (bool)$this->config['config']['has_remove'] : true,
|
||||
'has_edit' => isset($this->config['config']['has_edit']) ? (bool)$this->config['config']['has_edit'] : true,
|
||||
'has_view' => isset($this->config['config']['has_view']) ? (bool)$this->config['config']['has_view'] : false,
|
||||
'dialog_type' => $dialogType,
|
||||
'dialog_width' => $dialogWidth,
|
||||
]);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function generateLangKey(string $moduleName, string $className): string
|
||||
{
|
||||
$classKey = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $className));
|
||||
$kebabModuleName = strtolower(str_replace('_', '-', $moduleName));
|
||||
return "{$kebabModuleName}.{$classKey}";
|
||||
}
|
||||
|
||||
private function generateLangImportPath(string $moduleName): string
|
||||
{
|
||||
return "@/lang/{$moduleName}";
|
||||
}
|
||||
|
||||
private function processTableColumns(array $columns, string $langKey): string
|
||||
{
|
||||
$result = [];
|
||||
foreach ($columns as $column) {
|
||||
if (isset($column['is_lists']) && $column['is_lists'] == 1) {
|
||||
$tableItem = [
|
||||
'prop' => $column['column_name'],
|
||||
'label' => '$t(\'' . $langKey . '.list.' . $column['column_name'] . '\')'
|
||||
];
|
||||
if(isset($column['view_type']) && $column['view_type'] == 'ApiDict') {
|
||||
$tableItem['component'] = 'ApiDict';
|
||||
$tableItem['componentProps'] = [
|
||||
'code' => $column['dict_type'],
|
||||
];
|
||||
}
|
||||
|
||||
$result[] = $this->arrayToCode($tableItem);
|
||||
}
|
||||
}
|
||||
return implode(",\n ", $result);
|
||||
}
|
||||
|
||||
private function arrayToCode(array $array): string
|
||||
{
|
||||
$items = [];
|
||||
foreach ($array as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$items[] = $key . ': ' . $this->arrayToCode($value);
|
||||
} elseif (is_bool($value)) {
|
||||
$items[] = $key . ': ' . ($value ? 'true' : 'false');
|
||||
} elseif (is_int($value) || is_float($value)) {
|
||||
$items[] = $key . ': ' . $value;
|
||||
} elseif (strpos($value, '$t(') === 0 || strpos($value, '[') === 0) {
|
||||
$items[] = $key . ': ' . $value;
|
||||
} else {
|
||||
$items[] = $key . ': "' . $value . '"';
|
||||
}
|
||||
}
|
||||
return '{' . implode(', ', $items) . '}';
|
||||
}
|
||||
|
||||
private function processSearchFormSchema(array $columns, string $langKey): string
|
||||
{
|
||||
$result = [];
|
||||
foreach ($columns as $column) {
|
||||
if (isset($column['is_search']) && $column['is_search'] == 1) {
|
||||
$prop = $column['column_name'];
|
||||
if (strpos(strtoupper($prop), 'LIKE_') === 0) {
|
||||
$prop = substr($prop, 5);
|
||||
} elseif (strpos(strtoupper($prop), 'EQ_') === 0) {
|
||||
$prop = substr($prop, 3);
|
||||
}
|
||||
|
||||
$searchItem = [
|
||||
'component' => $this->getComponent($column),
|
||||
'prop' => $prop,
|
||||
'label' => '$t(\'' . $langKey . '.search.' . $prop . '\')',
|
||||
'componentProps' => [
|
||||
'placeholder' => '$t(\'' . $langKey . '.search.' . $prop . '\')'
|
||||
],
|
||||
'colSpan' => 6
|
||||
];
|
||||
|
||||
if (isset($column['label_key']) && $column['label_key']) {
|
||||
$searchItem['labelKey'] = $column['label_key'];
|
||||
}
|
||||
if (isset($column['value_key']) && $column['value_key']) {
|
||||
$searchItem['valueKey'] = $column['value_key'];
|
||||
}
|
||||
|
||||
if (isset($column['api']) && $column['api']) {
|
||||
$searchItem['componentProps']['api'] = $column['api'];
|
||||
}
|
||||
|
||||
if (isset($column['dict_type']) && $column['dict_type']) {
|
||||
$searchItem['componentProps']['code'] = $column['dict_type'];
|
||||
}
|
||||
|
||||
$result[] = $this->arrayToCode($searchItem);
|
||||
}
|
||||
}
|
||||
return implode(",\n ", $result);
|
||||
}
|
||||
|
||||
private function processFormSchema(array $columns, string $langKey): string
|
||||
{
|
||||
$result = [];
|
||||
foreach ($columns as $column) {
|
||||
if (isset($column['is_pk']) && $column['is_pk'] == 1) {
|
||||
$item = [
|
||||
'label' => '$t(\'' . $langKey . '.form.' . $column['column_name'] . '\')',
|
||||
'prop' => $column['column_name'],
|
||||
'component' => $this->getComponent($column),
|
||||
'colSpan' => 24,
|
||||
'show' => false
|
||||
];
|
||||
$result[] = $this->arrayToCode($item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((isset($column['is_insert']) && $column['is_insert'] == 1) || (isset($column['is_update']) && $column['is_update'] == 1)) {
|
||||
$item = [
|
||||
'label' => '$t(\'' . $langKey . '.form.' . $column['column_name'] . '\')',
|
||||
'prop' => $column['column_name'],
|
||||
'component' => $this->getComponent($column),
|
||||
'colSpan' => 24
|
||||
];
|
||||
|
||||
if (isset($column['show']) && $column['show'] == 1) {
|
||||
$item['show'] = true;
|
||||
}
|
||||
|
||||
if (isset($column['ifDetail']) && $column['ifDetail'] == 1) {
|
||||
$item['ifDetail'] = true;
|
||||
}
|
||||
|
||||
if (isset($column['validate_type']) && $column['validate_type']) {
|
||||
$item['rules'] = $this->getValidationRules($column['validate_type']);
|
||||
}
|
||||
|
||||
if (isset($column['label_key']) && $column['label_key']) {
|
||||
$item['labelKey'] = $column['label_key'];
|
||||
}
|
||||
if (isset($column['value_key']) && $column['value_key']) {
|
||||
$item['valueKey'] = $column['value_key'];
|
||||
}
|
||||
|
||||
if (isset($column['api']) && $column['api']) {
|
||||
$item['componentProps'] = [
|
||||
'api' => $column['api']
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($column['dict_type']) && $column['dict_type']) {
|
||||
if (!isset($item['componentProps'])) {
|
||||
$item['componentProps'] = [];
|
||||
}
|
||||
$item['componentProps']['code'] = $column['dict_type'];
|
||||
}
|
||||
|
||||
$result[] = $this->arrayToCode($item);
|
||||
}
|
||||
}
|
||||
return implode(",\n ", $result);
|
||||
}
|
||||
|
||||
private function getComponent(array $column): string
|
||||
{
|
||||
if (isset($column['dict_type']) && $column['dict_type']) {
|
||||
return 'ApiDict';
|
||||
}
|
||||
|
||||
if (isset($column['view_type']) && $column['view_type']) {
|
||||
$viewType = $column['view_type'];
|
||||
return $viewType;
|
||||
}
|
||||
return 'Input';
|
||||
}
|
||||
|
||||
private function getValidationRules(string $validateType): string
|
||||
{
|
||||
$rules = [];
|
||||
$validateTypes = explode(',', $validateType);
|
||||
foreach ($validateTypes as $type) {
|
||||
switch (trim($type)) {
|
||||
case 'required':
|
||||
$rules[] = '{"required":true,"message":"此项为必填项","trigger":"blur"}';
|
||||
break;
|
||||
case 'email':
|
||||
$rules[] = '{"type":"email","message":"请输入有效的邮箱地址","trigger":"blur"}';
|
||||
break;
|
||||
case 'url':
|
||||
$rules[] = '{"type":"url","message":"请输入有效的URL地址","trigger":"blur"}';
|
||||
break;
|
||||
case 'number':
|
||||
$rules[] = '{"type":"number","message":"请输入数字","trigger":"blur"}';
|
||||
break;
|
||||
}
|
||||
}
|
||||
return '[' . implode(',', $rules) . ']';
|
||||
}
|
||||
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return 'tsx';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\interfaces;
|
||||
|
||||
/**
|
||||
* 文件类型生成器接口
|
||||
* 负责生成特定类型的文件内容
|
||||
*/
|
||||
interface FileGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(): string;
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @return string 文件扩展名
|
||||
*/
|
||||
public function getFileExtension(): string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\interfaces;
|
||||
|
||||
/**
|
||||
* 场景生成器接口
|
||||
* 负责生成特定场景的文件路径
|
||||
*/
|
||||
interface SceneGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* 生成文件路径
|
||||
* @param string $fileType 文件类型
|
||||
* @param string $extension 文件扩展名
|
||||
* @return string 文件路径
|
||||
*/
|
||||
public function generateFilePath(string $fileType, string $extension = 'php'): string;
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @param string $fileType 文件类型
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(string $fileType): string;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\scene;
|
||||
|
||||
use core\generator\interfaces\SceneGeneratorInterface;
|
||||
use core\generator\utils\PathResolver;
|
||||
|
||||
/**
|
||||
* 前端场景生成器
|
||||
* 负责生成前端文件路径
|
||||
*/
|
||||
class AdminSceneGenerator implements SceneGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var PathResolver 路径解析器
|
||||
*/
|
||||
private PathResolver $pathResolver;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->pathResolver = new PathResolver();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件路径
|
||||
* @param string $fileType 文件类型
|
||||
* @param string $extension 文件扩展名
|
||||
* @return string 文件路径
|
||||
*/
|
||||
public function generateFilePath(string $fileType, string $extension = 'php'): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$moduleName = $this->config['package_name'] ?? 'default';
|
||||
$className = $this->config['class_name'] ?? 'DefaultModel';
|
||||
|
||||
// 根据模板类型选择不同的基础路径和路径生成逻辑
|
||||
if ($template === 'app') {
|
||||
$basePath = $this->getBasePath() . DS . 'src';
|
||||
$path = $this->pathResolver->generatePath($basePath, $moduleName, $className, $fileType, $extension);
|
||||
} else {
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
$basePath = $this->getBasePath() . DS . 'src' . DS . 'apps' . DS . $pluginName;
|
||||
$path = $this->pathResolver->generatePath($basePath, $moduleName, $className, $fileType, $extension);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
* @param string $fileType 文件类型
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(string $fileType): string
|
||||
{
|
||||
// 内容生成由文件类型生成器负责
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础路径
|
||||
* @return string 基础路径
|
||||
*/
|
||||
private function getBasePath(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
|
||||
// 无论是 app 模式还是插件模式,前端代码都统一生成在 frontend 目录下的 admin 目录中
|
||||
// 前端目录结构:project_root/frontend/admin 或 project_root/frontend/web 等
|
||||
return dirname(base_path()) . DS . 'frontend' . DS . 'admin';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\scene;
|
||||
|
||||
use core\generator\interfaces\SceneGeneratorInterface;
|
||||
use core\generator\utils\PathResolver;
|
||||
|
||||
/**
|
||||
* 后端场景生成器
|
||||
* 负责生成后端文件路径
|
||||
*/
|
||||
class BackendSceneGenerator implements SceneGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var array 配置信息
|
||||
*/
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* @var PathResolver 路径解析器
|
||||
*/
|
||||
private PathResolver $pathResolver;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param array $config 配置信息
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->pathResolver = new PathResolver();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件路径
|
||||
*
|
||||
* @param string $fileType 文件类型
|
||||
* @param string $extension 文件扩展名
|
||||
*
|
||||
* @return string 文件路径
|
||||
*/
|
||||
public function generateFilePath(string $fileType, string $extension = 'php'): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
$moduleName = $this->config['package_name'] ?? 'default';
|
||||
$className = $this->config['class_name'] ?? 'DefaultModel';
|
||||
$isPlugin = $template !== 'app';
|
||||
|
||||
// 处理特殊文件类型
|
||||
// if ($fileType === 'request') {
|
||||
// // 请求需要生成两个文件:FormRequest 和 QueryRequest
|
||||
// $dtoBasePath = $this->getBasePathForFileType($fileType);
|
||||
// $formPath = $this->pathResolver->generatePath($dtoBasePath, $moduleName, $className, 'request_form', $extension, $isPlugin);
|
||||
// $queryPath = $this->pathResolver->generatePath($dtoBasePath, $moduleName, $className, 'request_query', $extension, $isPlugin);
|
||||
//
|
||||
// // 返回表单请求路径,查询请求路径会在生成器中单独处理
|
||||
// return $formPath;
|
||||
// }
|
||||
|
||||
$basePath = $this->getBasePathForFileType($fileType);
|
||||
$path = $this->pathResolver->generatePath($basePath, $moduleName, $className, $fileType, $extension, $isPlugin);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件内容
|
||||
*
|
||||
* @param string $fileType 文件类型
|
||||
*
|
||||
* @return string 文件内容
|
||||
*/
|
||||
public function generateContent(string $fileType): string
|
||||
{
|
||||
// 内容生成由文件类型生成器负责
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础路径
|
||||
*
|
||||
* @return string 基础路径
|
||||
*/
|
||||
private function getBasePath(): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
|
||||
if ($template === 'app') {
|
||||
return base_path() . DS . 'app';
|
||||
} else {
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
return base_path() . DS . 'plugin' . DS . $pluginName . DS . 'app';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件类型获取基础路径
|
||||
*
|
||||
* @param string $fileType 文件类型
|
||||
*
|
||||
* @return string 基础路径
|
||||
*/
|
||||
private function getBasePathForFileType(string $fileType): string
|
||||
{
|
||||
$template = $this->config['template'] ?? 'app';
|
||||
|
||||
// 插件模式下,所有文件都在插件的 app 目录下
|
||||
if ($template !== 'app') {
|
||||
$pluginName = $this->config['namespace'] ?? '';
|
||||
return base_path() . DS . 'plugin' . DS . $pluginName . DS . 'app';
|
||||
}
|
||||
|
||||
// app 模式下,根据文件类型选择不同的基础路径
|
||||
$adminApiFileTypes = ['controller', 'validate', 'request_form', 'request_query','response'];
|
||||
if (in_array($fileType, $adminApiFileTypes)) {
|
||||
return base_path() . DS . 'app' . DS . 'adminapi';
|
||||
} else {
|
||||
return base_path() . DS . 'app';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import BaseService from '@/api/core/base';
|
||||
import type {{row_type_name}} from './types';
|
||||
|
||||
const baseUrl = '{base_url}';
|
||||
|
||||
export const {class_name}Service = {
|
||||
...BaseService<{row_type_name}>({ baseUrl }),
|
||||
};
|
||||
|
||||
export default {class_name}Service;
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface {row_type_name} {
|
||||
{fields}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"{module_name}": {
|
||||
"common": {
|
||||
"columns": {
|
||||
"index": "序号"
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
"{dummy_class_name}": {
|
||||
"title": "{class_name_comment}",
|
||||
"search": {
|
||||
"keywords": "关键字",
|
||||
"keywords_placeholder": "请输入{class_name_comment}关键字"
|
||||
},
|
||||
"form": {
|
||||
"create": "新增{class_name_comment}",
|
||||
"edit": "编辑{class_name_comment}",
|
||||
"view": "查看{class_name_comment}"
|
||||
},
|
||||
"messages": {
|
||||
"create_success": "{class_name_comment}创建成功",
|
||||
"create_fail": "{class_name_comment}创建失败",
|
||||
"update_success": "{class_name_comment}更新成功",
|
||||
"update_fail": "{class_name_comment}更新失败",
|
||||
"delete_success": "{class_name_comment}删除成功",
|
||||
"delete_fail": "{class_name_comment}删除失败"
|
||||
},
|
||||
"columns": {
|
||||
{fields}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="{kebab_module_name}-{kebab_class_name}-page art-full-height">
|
||||
<BasicCrud />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import { useCrud } from '@/components/crud'
|
||||
import { crudSchema } from './schemas'
|
||||
|
||||
defineOptions({ name: '{class_name}List' })
|
||||
|
||||
const [BasicCrud] = useCrud({
|
||||
...crudSchema(),
|
||||
toolbarActions: [],
|
||||
dropDownToolbarActions: [],
|
||||
tableActions: [],
|
||||
dropDownActions: [],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.{kebab_module_name}-{kebab_class_name}-page {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CrudSchema } from '@/components/crud'
|
||||
import { {service_name} } from '{api_import_path}'
|
||||
import { $t } from '@/locales'
|
||||
|
||||
// crud配置
|
||||
export const crudSchema = (): CrudSchema => {
|
||||
return {
|
||||
api: {service_name}.list,
|
||||
dialogTitle: $t('{lang_key}.title'),
|
||||
exportFileName: $t('{lang_key}.title'),
|
||||
crudApi: {
|
||||
add: {service_name}.create,
|
||||
edit: {service_name}.update,
|
||||
remove: {service_name}.remove,
|
||||
view: {service_name}.get
|
||||
},
|
||||
useCrud: {use_crud},
|
||||
hasAdd: {has_add},
|
||||
hasRemove: {has_remove},
|
||||
hasEdit: {has_edit},
|
||||
hasView: {has_view},
|
||||
addAuth: '{module_name}:{dummy_class_name}:create',
|
||||
removeAuth: '{module_name}:{dummy_class_name}:delete',
|
||||
editAuth: '{module_name}:{dummy_class_name}:update',
|
||||
viewAuth: '{module_name}:{dummy_class_name}:read',
|
||||
rowKey: 'id',
|
||||
|
||||
useSearchForm: true,
|
||||
|
||||
columns: [
|
||||
{
|
||||
type: 'selection'
|
||||
},
|
||||
{table_columns}
|
||||
],
|
||||
|
||||
searchFormSchema: {
|
||||
schema: [
|
||||
{search_form_schema}
|
||||
]
|
||||
},
|
||||
|
||||
dialogProps: {
|
||||
width: '{dialog_width}',
|
||||
{dialog_type}
|
||||
},
|
||||
|
||||
formSchema: {
|
||||
showDefaultActions: false,
|
||||
labelWidth: '100px',
|
||||
schema: [
|
||||
{form_schema}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace {namespace};
|
||||
|
||||
use app\adminapi\controller\Crud;
|
||||
use app\schema\request\BatchDeleteRequest;
|
||||
use {schema_request_namespace}\{class_name}FormRequest as {class_name}FormRequest;
|
||||
use {schema_request_namespace}\{class_name}QueryRequest as {class_name}QueryRequest;
|
||||
use app\adminapi\middleware\AccessTokenMiddleware;
|
||||
use app\adminapi\middleware\OperationMiddleware;
|
||||
use app\adminapi\middleware\PermissionMiddleware;
|
||||
use {validate_namespace}\{class_name}Validate as {class_name}Validate;
|
||||
use {schema_response_namespace}\{class_name}Response as {class_name}Response;
|
||||
use app\schema\request\IdRequest;
|
||||
use {service_namespace}\{class_name}Service as {class_name}Service;
|
||||
use core\exception\handler\AdminException;
|
||||
use core\tool\Json;
|
||||
use madong\swagger\annotation\response\DataResponse;
|
||||
use madong\swagger\annotation\response\PageResponse;
|
||||
use madong\swagger\annotation\response\SimpleResponse;
|
||||
use madong\swagger\attribute\Permission;
|
||||
use OpenApi\Attributes as OA;
|
||||
use support\Request;
|
||||
use support\annotation\Middleware;
|
||||
use WebmanTech\Swagger\DTO\SchemaConstants;
|
||||
|
||||
#[OA\Info(version: '1.0.0', title: 'My App')]
|
||||
#[OA\Server(url: '/adminapi', description: 'localhost')]
|
||||
#[Middleware(AccessTokenMiddleware::class, PermissionMiddleware::class, OperationMiddleware::class)]
|
||||
final class {class_name}Controller extends Crud
|
||||
{
|
||||
public function __construct({class_name}Service $service, {class_name}Validate $validate)
|
||||
{
|
||||
$this->service = $service;
|
||||
$this->validate = $validate;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/{package_name_dash}/{class_name_dash}',
|
||||
summary: '列表',
|
||||
tags: ['{class_name}'],
|
||||
x: [
|
||||
SchemaConstants::X_SCHEMA_REQUEST => {class_name}QueryRequest::class,
|
||||
]
|
||||
)]
|
||||
#[Permission(code: '{package_name_lower}:{class_name_lower}:list')]
|
||||
#[PageResponse(schema:{class_name}Response::class,example:[])]
|
||||
public function index(Request $request): \support\Response
|
||||
{
|
||||
return parent::index($request);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/{package_name_dash}/{class_name_dash}/{id}',
|
||||
summary: '详情',
|
||||
tags: ['{class_name}'],
|
||||
x: [
|
||||
SchemaConstants::X_PROPERTY_IN => 'id',
|
||||
SchemaConstants::X_SCHEMA_REQUEST => IdRequest::class,
|
||||
]
|
||||
)]
|
||||
#[OA\Parameter(
|
||||
name: 'id',
|
||||
description: '{class_name}ID',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string', example: 1)
|
||||
)]
|
||||
#[Permission(code: '{package_name_lower}:{class_name_lower}:read')]
|
||||
#[DataResponse(schema: {class_name}Response::class,example:[])]
|
||||
public function show(Request $request): \support\Response
|
||||
{
|
||||
try {
|
||||
$id = $request->route->param('id');
|
||||
$data = $this->service->get($id);
|
||||
if (empty($data)) {
|
||||
throw new AdminException('数据未找到', 400);
|
||||
}
|
||||
|
||||
$dataArray = $data->toArray();
|
||||
return Json::success('ok', $dataArray);
|
||||
} catch (\Throwable $e) {
|
||||
return Json::fail($e->getMessage(), [], $e->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/{package_name_dash}/{class_name_dash}',
|
||||
summary: '添加',
|
||||
tags: ['{class_name}'],
|
||||
x: [
|
||||
SchemaConstants::X_SCHEMA_REQUEST => {class_name}FormRequest::class,
|
||||
]
|
||||
)]
|
||||
#[Permission(code: '{package_name_lower}:{class_name_lower}:create')]
|
||||
#[SimpleResponse(schema: [], example: [])]
|
||||
public function store(Request $request): \support\Response
|
||||
{
|
||||
try {
|
||||
$data = $request->all();
|
||||
|
||||
if (isset($this->validate) && $this->validate) {
|
||||
if (!$this->validate->scene('store')->check($data)) {
|
||||
throw new \Exception($this->validate->getError());
|
||||
}
|
||||
}
|
||||
$model = $this->service->save($data);
|
||||
if (empty($model)) {
|
||||
throw new AdminException('插入失败');
|
||||
}
|
||||
$pk = $model->getPk();
|
||||
return Json::success('ok', [$pk => $model->getData($pk)]);
|
||||
} catch (\Throwable $e) {
|
||||
return Json::fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Put(
|
||||
path: '/{package_name_dash}/{class_name_dash}/{id}',
|
||||
summary: '更新',
|
||||
tags: ['{class_name}'],
|
||||
x: [
|
||||
SchemaConstants::X_SCHEMA_REQUEST => {class_name}FormRequest::class,
|
||||
]
|
||||
)]
|
||||
#[OA\Parameter(
|
||||
name: 'id',
|
||||
description: '{class_name}ID',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string', example: 1)
|
||||
)]
|
||||
#[Permission(code: '{package_name_lower}:{class_name_lower}:update')]
|
||||
#[SimpleResponse(schema: [], example: [])]
|
||||
public function update(Request $request): \support\Response
|
||||
{
|
||||
try {
|
||||
$id = $request->route->param('id');
|
||||
$data = $request->all();
|
||||
if (isset($this->validate) && $this->validate) {
|
||||
if (!$this->validate->scene('update')->check($data)) {
|
||||
throw new \Exception($this->validate->getError());
|
||||
}
|
||||
}
|
||||
$this->service->update($id, $data);
|
||||
return Json::success('ok', []);
|
||||
} catch (\Throwable $e) {
|
||||
return Json::fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/{package_name_dash}/{class_name_dash}/export',
|
||||
summary: '导出',
|
||||
tags: ['{class_name}']
|
||||
)]
|
||||
#[Permission(code: '{package_name_lower}:{class_name_lower}:list')]
|
||||
#[SimpleResponse(schema: [], example: [])]
|
||||
public function export(Request $request): \support\Response
|
||||
{
|
||||
return parent::export($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/{package_name_dash}/{class_name_dash}/{id}',
|
||||
summary: '删除',
|
||||
tags: ['{class_name}'],
|
||||
)]
|
||||
#[OA\Parameter(
|
||||
name: 'id',
|
||||
description: '{class_name}ID',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string', example: 1)
|
||||
)]
|
||||
#[Permission(code: '{package_name_lower}:{class_name_lower}:delete')]
|
||||
#[SimpleResponse(schema: [], example: [])]
|
||||
public function destroy(Request $request): \support\Response
|
||||
{
|
||||
try {
|
||||
$id = $request->route->param('id');
|
||||
$data = $request->input('data', []);
|
||||
$data = !empty($id) && $id !== '0' ? $id : $data;
|
||||
if (empty($data)) {
|
||||
throw new AdminException('参数错误');
|
||||
}
|
||||
$this->service->destroy($data);
|
||||
return Json::success('操作成功');
|
||||
} catch (\Exception $e) {
|
||||
return Json::fail($e->getMessage(), [], $e->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/{package_name_dash}/{class_name_dash}',
|
||||
summary: '批量删除',
|
||||
tags: ['{class_name}'],
|
||||
x: [
|
||||
SchemaConstants::X_SCHEMA_REQUEST => BatchDeleteRequest::class,
|
||||
]
|
||||
)]
|
||||
#[Permission(code: '{package_name_lower}:{class_name_lower}:delete')]
|
||||
#[SimpleResponse(schema: [], example: [])]
|
||||
public function batchDelete(Request $request): \support\Response
|
||||
{
|
||||
try {
|
||||
$data = $request->input('ids', []);
|
||||
if (empty($data)) {
|
||||
throw new AdminException('参数错误');
|
||||
}
|
||||
$this->service->destroy($data);
|
||||
return Json::success('操作成功');
|
||||
} catch (\Exception $e) {
|
||||
return Json::fail($e->getMessage(), [], $e->getCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace {namespace};
|
||||
|
||||
use {model_namespace}\{class_name};
|
||||
use core\base\BaseDao;
|
||||
|
||||
/**
|
||||
* {class_name}数据访问层
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class {class_name}Dao extends BaseDao
|
||||
{
|
||||
|
||||
protected function setModel(): string
|
||||
{
|
||||
return {class_name}::class;
|
||||
}
|
||||
|
||||
protected function getKeywordFields(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace {namespace};
|
||||
|
||||
use core\base\BaseModel;
|
||||
{soft_delete_use}
|
||||
|
||||
/**
|
||||
* {class_name}模型
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class {class_name} extends BaseModel
|
||||
{
|
||||
{soft_delete_trait}
|
||||
|
||||
protected $table = '{table_name}';
|
||||
|
||||
/**
|
||||
* 指示是否自动维护时间戳
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
{soft_delete}
|
||||
|
||||
protected $appends = ['created_date', 'updated_date'];
|
||||
|
||||
protected $fillable = [
|
||||
{fillable_fields}
|
||||
];
|
||||
|
||||
{searchers}
|
||||
|
||||
{relations}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace {namespace};
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
use app\schema\request\BaseFormRequest;
|
||||
use WebmanTech\DTO\Attributes\ValidationRules;
|
||||
|
||||
#[OA\Schema(
|
||||
title: '{class_name}表单',
|
||||
description: '{class_name}创建和编辑接口共用的表单请求参数'
|
||||
)]
|
||||
class {class_name}FormRequest extends BaseFormRequest
|
||||
{
|
||||
{fields}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace {namespace};
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
use app\schema\request\BaseQueryRequest;
|
||||
use WebmanTech\DTO\Attributes\ValidationRules;
|
||||
|
||||
#[OA\Schema(
|
||||
title: '{class_name}查询',
|
||||
description: '{class_name}列表查询接口的请求参数'
|
||||
)]
|
||||
class {class_name}QueryRequest extends BaseQueryRequest
|
||||
{
|
||||
|
||||
{fields}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace {namespace};
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
use madong\swagger\schema\BaseResponseDTO;
|
||||
|
||||
|
||||
#[OA\Schema(
|
||||
title: '{class_name}详情响应模型',
|
||||
description: '{class_name}详情接口的返回数据结构'
|
||||
)]
|
||||
class {class_name}Response extends BaseResponseDTO
|
||||
{
|
||||
{fields}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace {namespace};
|
||||
|
||||
use {dao_namespace}\{class_name}Dao;
|
||||
use core\base\BaseService;
|
||||
|
||||
/**
|
||||
* {class_name}服务层
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class {class_name}Service extends BaseService
|
||||
{
|
||||
public function __construct({class_name}Dao $dao)
|
||||
{
|
||||
$this->dao = $dao;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace {namespace};
|
||||
|
||||
use core\base\BaseValidate;
|
||||
|
||||
/**
|
||||
* {class_name}验证器
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class {class_name}Validate extends BaseValidate
|
||||
{
|
||||
/**
|
||||
* 验证规则
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
{rules}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $message = [
|
||||
{messages}
|
||||
];
|
||||
|
||||
/**
|
||||
* 场景
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $scene = [
|
||||
'store' => [
|
||||
{store_fields}
|
||||
],
|
||||
'update' => [
|
||||
{update_fields}
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\utils;
|
||||
|
||||
/**
|
||||
* 路径解析器
|
||||
* 负责处理文件路径的解析和生成
|
||||
*/
|
||||
class PathResolver
|
||||
{
|
||||
/**
|
||||
* 生成文件路径
|
||||
* @param string $basePath 基础路径
|
||||
* @param string $moduleName 模块名称
|
||||
* @param string $className 类名
|
||||
* @param string $fileType 文件类型
|
||||
* @param string $extension 文件扩展名
|
||||
* @param bool $isPlugin 是否是插件模式
|
||||
* @return string 文件路径
|
||||
*/
|
||||
public function generatePath(string $basePath, string $moduleName, string $className, string $fileType, string $extension = 'php', bool $isPlugin = false): string
|
||||
{
|
||||
// 前端文件类型
|
||||
$frontendFileTypes = ['api', 'api_model', 'view', 'view_schema', 'lang'];
|
||||
|
||||
// 根据文件类型处理模块名格式
|
||||
$processedModuleName = $moduleName;
|
||||
if (in_array($fileType, $frontendFileTypes)) {
|
||||
// 前端使用连字符格式
|
||||
$processedModuleName = str_replace('_', '-', $moduleName);
|
||||
} else {
|
||||
// 后端使用下划线格式
|
||||
$processedModuleName = str_replace('-', '_', $moduleName);
|
||||
}
|
||||
|
||||
// 确保 className 是大驼峰格式(处理下划线分隔的情况)
|
||||
$className = str_replace(' ', '', ucwords(str_replace('_', ' ', $className)));
|
||||
|
||||
// 插件模式下,service 路径不包含 admin 层级
|
||||
$servicePath = $isPlugin ? 'service/' : 'service/admin/';
|
||||
|
||||
$pathMap = [
|
||||
// 后端文件路径映射
|
||||
'controller' => 'controller/' . ($processedModuleName ? $processedModuleName . '/' : '') . $className . 'Controller.' . $extension,
|
||||
'model' => 'model/' . ($processedModuleName ? $processedModuleName . '/' : '') . $className . '.' . $extension,
|
||||
'service' => $servicePath . ($processedModuleName ? $processedModuleName . '/' : '') . $className . 'Service.' . $extension,
|
||||
'dao' => 'dao/' . ($processedModuleName ? $processedModuleName . '/' : '') . $className . 'Dao.' . $extension,
|
||||
'validate' => 'validate/' . ($processedModuleName ? $processedModuleName . '/' : '') . $className . 'Validate.' . $extension,
|
||||
'response' => 'schema/response/' . ($processedModuleName ? $processedModuleName . '/' : '') . $className . 'Response.' . $extension,
|
||||
'request_form' => 'schema/request/' . ($processedModuleName ? $processedModuleName . '/' : '') . $className . 'FormRequest.' . $extension,
|
||||
'request_query' => 'schema/request/' . ($processedModuleName ? $processedModuleName . '/' : '') . $className . 'QueryRequest.' . $extension,
|
||||
|
||||
// 前端文件路径映射
|
||||
'api' => 'api/' . ($processedModuleName ? $processedModuleName . '/' : '') . 'index.ts',
|
||||
'api_model' => 'api/' . ($processedModuleName ? $processedModuleName . '/' : '') . 'types.ts',
|
||||
'view' => 'views/' . ($processedModuleName ? $processedModuleName . '/' : '') . 'index.vue',
|
||||
'view_schema' => 'views/' . ($processedModuleName ? $processedModuleName . '/' : '') . 'schemas/index.tsx',
|
||||
'lang' => 'lang/zh-cn/' . $processedModuleName . '.json',
|
||||
];
|
||||
|
||||
$relativePath = $pathMap[$fileType] ?? $fileType . '/' . $className . '.' . $extension;
|
||||
return $basePath . DS . $relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保路径存在
|
||||
* @param string $path 路径
|
||||
* @return void
|
||||
*/
|
||||
public function ensurePathExists(string $path): void
|
||||
{
|
||||
$dirPath = dirname($path);
|
||||
|
||||
if (!is_dir($dirPath)) {
|
||||
mkdir($dirPath, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相对路径
|
||||
* @param string $fullPath 完整路径
|
||||
* @param string $basePath 基础路径
|
||||
* @return string 相对路径
|
||||
*/
|
||||
public function getRelativePath(string $fullPath, string $basePath): string
|
||||
{
|
||||
return str_replace($basePath . DS, '', $fullPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace core\generator\utils;
|
||||
|
||||
// 定义目录分隔符常量
|
||||
defined('DS') or define('DS', DIRECTORY_SEPARATOR);
|
||||
|
||||
/**
|
||||
* 模板渲染器
|
||||
* 负责处理模板文件的渲染
|
||||
*/
|
||||
class TemplateRenderer
|
||||
{
|
||||
/**
|
||||
* @var string 模板目录路径
|
||||
*/
|
||||
private string $templateDir;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->templateDir = base_path() . DS . 'core' . DS . 'generator' . DS . 'stubs';
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板
|
||||
*
|
||||
* @param string $templatePath 模板路径
|
||||
* @param array $data 模板数据
|
||||
*
|
||||
* @return string 渲染后的内容
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function render(string $templatePath, array $data): string
|
||||
{
|
||||
$templateFile = $this->templateDir . DS . $templatePath;
|
||||
|
||||
if (!file_exists($templateFile)) {
|
||||
throw new \Exception('Template file not found: ' . $templateFile);
|
||||
}
|
||||
|
||||
// 读取模板内容
|
||||
$content = file_get_contents($templateFile);
|
||||
|
||||
// 替换变量
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_bool($value)) {
|
||||
// 对于布尔值,转换为 TypeScript 布尔字面量
|
||||
$boolValue = $value ? 'true' : 'false';
|
||||
// 支持 {key} 格式的占位符
|
||||
$content = str_replace('{' . $key . '}', $boolValue, $content);
|
||||
// 支持 {$key} 格式的占位符
|
||||
$content = str_replace('{$' . $key . '}', $boolValue, $content);
|
||||
} elseif (is_scalar($value)) {
|
||||
// 支持 {key} 格式的占位符
|
||||
$content = str_replace('{' . $key . '}', $value, $content);
|
||||
// 支持 {$key} 格式的占位符
|
||||
$content = str_replace('{$' . $key . '}', $value, $content);
|
||||
} elseif (is_array($value)) {
|
||||
// 处理数组类型的数据,转换为 TypeScript 对象数组
|
||||
$arrayString = $this->arrayToTypeScript($value);
|
||||
$content = str_replace('{{' . $key . '}}', $arrayString, $content);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 PHP 数组转换为 TypeScript 对象数组字符串
|
||||
* @param array $array PHP 数组
|
||||
* @return string TypeScript 对象数组字符串
|
||||
*/
|
||||
private function arrayToTypeScript(array $array): string
|
||||
{
|
||||
if (empty($array)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($array as $item) {
|
||||
if (is_array($item)) {
|
||||
$items[] = $this->objectToTypeScript($item);
|
||||
}
|
||||
}
|
||||
|
||||
return implode(",\n ", $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 PHP 关联数组转换为 TypeScript 对象字符串
|
||||
* @param array $object PHP 关联数组
|
||||
* @return string TypeScript 对象字符串
|
||||
*/
|
||||
private function objectToTypeScript(array $object): string
|
||||
{
|
||||
$properties = [];
|
||||
foreach ($object as $key => $value) {
|
||||
if (is_bool($value)) {
|
||||
// 对于布尔值,转换为 TypeScript 布尔字面量
|
||||
$properties[] = $key . ': ' . ($value ? 'true' : 'false');
|
||||
} elseif (is_string($value)) {
|
||||
$properties[] = $key . ': \'' . addslashes($value) . '\'';
|
||||
} elseif (is_numeric($value)) {
|
||||
$properties[] = $key . ': ' . $value;
|
||||
} elseif (is_array($value)) {
|
||||
$properties[] = $key . ': ' . $this->objectToTypeScript($value);
|
||||
}
|
||||
}
|
||||
|
||||
return '{' . implode(', ', $properties) . '}';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模板是否存在
|
||||
* @param string $templatePath 模板路径
|
||||
* @return bool 是否存在
|
||||
*/
|
||||
public function templateExists(string $templatePath): bool
|
||||
{
|
||||
$templateFile = $this->templateDir . DS . $templatePath;
|
||||
return file_exists($templateFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\interface;
|
||||
|
||||
interface IEnum
|
||||
{
|
||||
public function label():string;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\interface;
|
||||
|
||||
interface IRequestInterface
|
||||
{
|
||||
/**
|
||||
* 获取POST参数.
|
||||
*/
|
||||
public function getMore(array $params, ?bool $suffix = null): array;
|
||||
|
||||
/**
|
||||
* 获取GET参数.
|
||||
*/
|
||||
public function postMore(array $params, ?bool $suffix = null): array;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt;
|
||||
|
||||
use core\jwt\enum\ClientType;
|
||||
use core\jwt\enum\LoginMode;
|
||||
use core\jwt\enum\TokenStatus;
|
||||
use core\jwt\enum\TokenType;
|
||||
use core\jwt\ex\JwtException;
|
||||
use core\jwt\ex\JwtRefreshTokenExpiredException;
|
||||
use core\jwt\ex\JwtTokenExpiredException;
|
||||
use core\jwt\ex\JwtTokenInBlacklistException;
|
||||
use core\jwt\ex\JwtTokenInvalidException;
|
||||
use core\jwt\interfaces\BlacklistStorageInterface;
|
||||
use core\jwt\interfaces\TokenStorageInterface;
|
||||
use core\jwt\storage\RedisTokenStorage;
|
||||
use core\jwt\storage\RedisBlacklistStorage;
|
||||
use Firebase\JWT\BeforeValidException;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
class JwtToken
|
||||
{
|
||||
/**
|
||||
* @var string 密钥
|
||||
*/
|
||||
protected string $secret;
|
||||
|
||||
/**
|
||||
* @var string 加密算法
|
||||
*/
|
||||
protected string $algo;
|
||||
|
||||
/**
|
||||
* @var array 配置
|
||||
*/
|
||||
protected array $config;
|
||||
|
||||
/**
|
||||
* @var TokenStorageInterface Token 存储
|
||||
*/
|
||||
protected TokenStorageInterface $tokenStorage;
|
||||
|
||||
/**
|
||||
* @var BlacklistStorageInterface 黑名单存储
|
||||
*/
|
||||
protected BlacklistStorageInterface $blacklistStorage;
|
||||
|
||||
/**
|
||||
* @var Token|null 当前 Token
|
||||
*/
|
||||
protected ?Token $currentToken = null;
|
||||
|
||||
/**
|
||||
* @var array|null 当前载荷缓存
|
||||
*/
|
||||
protected ?array $payloadCache = null;
|
||||
|
||||
public function __construct(
|
||||
?TokenStorageInterface $tokenStorage = null,
|
||||
?BlacklistStorageInterface $blacklistStorage = null,
|
||||
array $config = []
|
||||
)
|
||||
{
|
||||
$this->config = $config ?: config('core.jwt.app', []);
|
||||
$this->secret = $this->config['secret'] ?? '';
|
||||
$this->algo = $this->config['algo'] ?? 'HS256';
|
||||
|
||||
if (empty($this->secret) || $this->secret === 'your-secret-key-change-in-production') {
|
||||
$this->secret = 'default-secret-key-for-development';
|
||||
}
|
||||
|
||||
$this->tokenStorage = $tokenStorage ?? new RedisTokenStorage($this->config);
|
||||
$this->blacklistStorage = $blacklistStorage ?? new RedisBlacklistStorage($this->config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 存储
|
||||
*/
|
||||
public function getTokenStorage(): TokenStorageInterface
|
||||
{
|
||||
return $this->tokenStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取黑名单存储
|
||||
*/
|
||||
public function getBlacklistStorage(): BlacklistStorageInterface
|
||||
{
|
||||
return $this->blacklistStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Token
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID,需用string)
|
||||
* @param string $clientType 客户端类型(可扩展)
|
||||
* @param array $extraData 额外数据
|
||||
* @param string|null $loginMode 登录模式(null 使用配置默认)
|
||||
*
|
||||
* @return Token
|
||||
* @throws \core\jwt2\ex\JwtException
|
||||
*/
|
||||
public function generate(
|
||||
string $id,
|
||||
string $clientType,
|
||||
array $extraData = [],
|
||||
?string $loginMode = null
|
||||
): Token
|
||||
{
|
||||
// 验证客户端类型
|
||||
if (!ClientType::isValid($clientType)) {
|
||||
throw new JwtException("Invalid client type: {$clientType}");
|
||||
}
|
||||
|
||||
$loginMode = $loginMode ?? ($this->config['login_mode'] ?? LoginMode::MULTI->value);
|
||||
|
||||
// 根据登录模式处理现有 Token
|
||||
$this->handleLoginMode($id, $clientType, $loginMode);
|
||||
|
||||
// 生成 JTI
|
||||
$jti = $this->generateJti();
|
||||
|
||||
// 生成 Access Token
|
||||
$accessTtl = $this->config['ttl']['access'] ?? 7200;
|
||||
$accessPayload = $this->buildPayload(
|
||||
id: $id,
|
||||
client: $clientType,
|
||||
jti: $jti,
|
||||
type: TokenType::ACCESS,
|
||||
extra: $extraData
|
||||
);
|
||||
$accessToken = $this->makeToken($accessPayload);
|
||||
|
||||
// 生成 Refresh Token
|
||||
$refreshTtl = $this->config['ttl']['refresh'] ?? 604800;
|
||||
$refreshJti = $this->generateJti();
|
||||
$refreshPayload = $this->buildPayload(
|
||||
id: $id,
|
||||
client: $clientType,
|
||||
jti: $refreshJti,
|
||||
type: TokenType::REFRESH,
|
||||
extra: $extraData
|
||||
);
|
||||
$refreshToken = $this->makeToken($refreshPayload);
|
||||
|
||||
// 存储 Token 信息
|
||||
$this->tokenStorage->save($jti, [
|
||||
'id' => $id,
|
||||
'client_type' => $clientType,
|
||||
'refresh_jti' => $refreshJti,
|
||||
'extra' => $extraData,
|
||||
'login_mode' => $loginMode,
|
||||
'created_at' => time(),
|
||||
], $accessTtl);
|
||||
|
||||
// 创建 Token 对象
|
||||
$fullPayload = array_merge($accessPayload, ['id' => $id]);
|
||||
return Token::create($accessToken, $refreshToken, $accessTtl, $fullPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*
|
||||
* @param string|null $refreshToken 刷新令牌(null 使用请求中的)
|
||||
*
|
||||
* @return Token
|
||||
* @throws \core\jwt\ex\JwtException
|
||||
* @throws \core\jwt\ex\JwtRefreshTokenExpiredException
|
||||
* @throws \core\jwt\ex\JwtTokenInBlacklistException
|
||||
*/
|
||||
public function refresh(?string $refreshToken = null): Token
|
||||
{
|
||||
$token = $refreshToken ?? $this->getRefreshTokenFromRequest();
|
||||
|
||||
if (empty($token)) {
|
||||
throw new JwtException('Refresh token is required');
|
||||
}
|
||||
|
||||
// 解析 Token
|
||||
try {
|
||||
$payload = $this->parseToken($token);
|
||||
} catch (ExpiredException $e) {
|
||||
throw new JwtRefreshTokenExpiredException('Refresh token has expired');
|
||||
}
|
||||
|
||||
// 验证 Token 类型
|
||||
if (($payload['type'] ?? '') !== TokenType::REFRESH->value) {
|
||||
throw new JwtException('Invalid token type for refresh');
|
||||
}
|
||||
|
||||
$id = (string)($payload['id'] ?? '');
|
||||
$clientType = $payload['client'] ?? '';
|
||||
$oldJti = $payload['jti'] ?? '';
|
||||
|
||||
if (empty($id)) {
|
||||
throw new JwtException('Invalid user in token');
|
||||
}
|
||||
|
||||
// 检查刷新令牌是否在黑名单
|
||||
if ($this->blacklistStorage->has($oldJti)) {
|
||||
throw new JwtTokenInBlacklistException('Token has been revoked');
|
||||
}
|
||||
|
||||
// 通过 refresh_jti 反查对应的 access token 会话
|
||||
[$oldAccessJti, $oldTokenInfo] = $this->findAccessTokenByRefreshJti($id, $clientType, $oldJti);
|
||||
if ($oldTokenInfo === null) {
|
||||
throw new JwtRefreshTokenExpiredException('Refresh token has been revoked');
|
||||
}
|
||||
|
||||
// 获取额外数据
|
||||
$extraData = $oldTokenInfo['extra'] ?? [];
|
||||
$loginMode = $this->config['login_mode'] ?? $oldTokenInfo['login_mode'] ?? LoginMode::MULTI->value;
|
||||
|
||||
// 将旧 access_token 和 refresh_token 加入黑名单
|
||||
$blacklistTtl = $this->config['ttl']['refresh'] ?? 604800;
|
||||
if ($oldAccessJti !== '') {
|
||||
$this->blacklistStorage->add($oldAccessJti, $blacklistTtl);
|
||||
$this->tokenStorage->delete($oldAccessJti);
|
||||
}
|
||||
$this->blacklistStorage->add($oldJti, $blacklistTtl);
|
||||
|
||||
// 生成新 Token
|
||||
return $this->generate($id, $clientType, $extraData, $loginMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Token
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function parse(string $token): array
|
||||
{
|
||||
return $this->parseToken($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function user(): ?array
|
||||
{
|
||||
$payload = $this->getPayloadFromRequest();
|
||||
if (empty($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string)($payload['id'] ?? ''),
|
||||
'client_type' => $payload['client'] ?? '',
|
||||
'jti' => $payload['jti'] ?? '',
|
||||
'extra' => $payload['extra'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID(雪花ID)
|
||||
*/
|
||||
public function id(): ?string
|
||||
{
|
||||
$user = $this->user();
|
||||
return $user ? $user['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Token
|
||||
*/
|
||||
public function getToken(): ?Token
|
||||
{
|
||||
if ($this->currentToken !== null) {
|
||||
return $this->currentToken;
|
||||
}
|
||||
|
||||
$payload = $this->getPayloadFromRequest();
|
||||
if (empty($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessTokenFromRequest();
|
||||
if (empty($accessToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessTtl = $this->config['ttl']['access'] ?? 7200;
|
||||
$this->currentToken = Token::create($accessToken, '', $accessTtl, $payload);
|
||||
|
||||
return $this->currentToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
*
|
||||
* @param string|null $token
|
||||
*
|
||||
* @return array|null
|
||||
* @throws \core\jwt\ex\JwtException
|
||||
* @throws \core\jwt\ex\JwtTokenExpiredException
|
||||
* @throws \core\jwt\ex\JwtTokenInBlacklistException
|
||||
* @throws \core\jwt\ex\JwtTokenInvalidException
|
||||
*/
|
||||
public function verify(?string $token = null): ?array
|
||||
{
|
||||
$token = $token ?? $this->getAccessTokenFromRequest();
|
||||
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->parseToken($token);
|
||||
|
||||
// 检查是否在黑名单
|
||||
$jti = $payload['jti'] ?? '';
|
||||
if (!empty($jti) && $this->blacklistStorage->has($jti)) {
|
||||
throw new JwtTokenInBlacklistException('Token has been revoked');
|
||||
}
|
||||
|
||||
// 检查 Token 类型
|
||||
if (($payload['type'] ?? '') !== TokenType::ACCESS->value) {
|
||||
throw new JwtException('Invalid token type');
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
$id = (string)($payload['id'] ?? '');
|
||||
if (empty($id)) {
|
||||
throw new JwtException('Invalid user in token');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
} catch (ExpiredException $e) {
|
||||
throw new JwtTokenExpiredException('Token has expired');
|
||||
} catch (SignatureInvalidException $e) {
|
||||
throw new JwtTokenInvalidException('Invalid token signature');
|
||||
} catch (BeforeValidException $e) {
|
||||
throw new JwtTokenInvalidException('Token not yet valid');
|
||||
} catch (UnexpectedValueException $e) {
|
||||
throw new JwtTokenInvalidException('Invalid token format');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录(当前设备)
|
||||
*
|
||||
* @param string|null $token
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function logout(?string $token = null): bool
|
||||
{
|
||||
$token = $token ?? $this->getAccessTokenFromRequest();
|
||||
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->parseToken($token);
|
||||
$jti = $payload['jti'] ?? '';
|
||||
|
||||
if (!empty($jti)) {
|
||||
// 将 Token 加入黑名单
|
||||
$this->blacklistStorage->add($jti, $this->config['ttl']['access'] ?? 7200);
|
||||
|
||||
// 获取并删除 refresh_token
|
||||
$tokenInfo = $this->tokenStorage->get($jti);
|
||||
if ($tokenInfo && !empty($tokenInfo['refresh_jti'])) {
|
||||
$this->tokenStorage->delete($tokenInfo['refresh_jti']);
|
||||
}
|
||||
|
||||
// 删除 Token 信息
|
||||
$this->tokenStorage->delete($jti);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出所有设备
|
||||
*
|
||||
* @param string $id
|
||||
* @param string|null $clientType
|
||||
*
|
||||
* @return int 退出设备数
|
||||
*/
|
||||
/**
|
||||
* 退出所有设备
|
||||
*
|
||||
* @param string $id
|
||||
* @param string|null $clientType
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function logoutAll(string $id, ?string $clientType = null): int
|
||||
{
|
||||
// 获取用户的所有 token 信息
|
||||
$tokens = $this->tokenStorage->getUserTokens($id, $clientType);
|
||||
|
||||
if (empty($tokens)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
// 将所有 token 加入黑名单并删除
|
||||
foreach ($tokens as $jti => $info) {
|
||||
// 将 access_token 加入黑名单(使用 refresh_ttl 确保足够长)
|
||||
$blacklistTtl = $this->config['ttl']['refresh'] ?? 604800;
|
||||
$this->blacklistStorage->add($jti, $blacklistTtl);
|
||||
|
||||
// 将 refresh_token 也加入黑名单
|
||||
if (!empty($info['refresh_jti'])) {
|
||||
$this->blacklistStorage->add($info['refresh_jti'], $blacklistTtl);
|
||||
// 删除 refresh_token 存储
|
||||
$this->tokenStorage->delete($info['refresh_jti']);
|
||||
}
|
||||
|
||||
// 删除 access_token 存储
|
||||
$this->tokenStorage->delete($jti);
|
||||
$count++;
|
||||
}
|
||||
|
||||
// 清理用户索引(deleteByUser 只会清理索引,不会删除已删除的 token)
|
||||
$this->tokenStorage->deleteByUser($id, $clientType);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出其他设备(保留当前)
|
||||
*
|
||||
* @param string|null $id
|
||||
* @param string|null $clientType
|
||||
* @param string|null $exceptJti 保留的 JTI
|
||||
*
|
||||
* @return int 退出设备数
|
||||
*/
|
||||
public function logoutOthers(?string $id = null, ?string $clientType = null, ?string $exceptJti = null): int
|
||||
{
|
||||
$id = $id ?? $this->id();
|
||||
if ($id === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$exceptJti = $exceptJti ?? ($this->getPayloadFromRequest()['jti'] ?? null);
|
||||
|
||||
// 获取用户的所有 token 信息
|
||||
$tokens = $this->tokenStorage->getUserTokens($id, $clientType);
|
||||
|
||||
if (empty($tokens)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$blacklistTtl = $this->config['ttl']['refresh'] ?? 604800;
|
||||
$count = 0;
|
||||
|
||||
// 遍历并处理需要删除的 token
|
||||
foreach ($tokens as $jti => $info) {
|
||||
// 跳过保留的 JTI
|
||||
if ($exceptJti !== null && $jti === $exceptJti) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 将 access_token 加入黑名单
|
||||
$this->blacklistStorage->add($jti, $blacklistTtl);
|
||||
|
||||
// 将 refresh_token 加入黑名单并删除
|
||||
if (!empty($info['refresh_jti'])) {
|
||||
$this->blacklistStorage->add($info['refresh_jti'], $blacklistTtl);
|
||||
$this->tokenStorage->delete($info['refresh_jti']);
|
||||
}
|
||||
|
||||
// 删除 access_token 存储
|
||||
$this->tokenStorage->delete($jti);
|
||||
$count++;
|
||||
}
|
||||
|
||||
// 清理用户索引
|
||||
$this->tokenStorage->deleteByUser($id, $clientType, $exceptJti);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有会话列表
|
||||
*
|
||||
* @param string $id
|
||||
* @param string|null $clientType
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getSessions(string $id, ?string $clientType = null): array
|
||||
{
|
||||
// 使用 tokenStorage 获取用户的所有 token 信息
|
||||
$tokens = $this->tokenStorage->getUserTokens($id, $clientType);
|
||||
|
||||
if (empty($tokens)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sessions = [];
|
||||
foreach ($tokens as $jti => $info) {
|
||||
// 获取完整的 token 数据
|
||||
$tokenData = $this->tokenStorage->get($jti);
|
||||
if ($tokenData !== null) {
|
||||
$sessions[] = [
|
||||
'jti' => $jti,
|
||||
'client_type' => $info['client_type'] ?? $tokenData['client_type'] ?? '',
|
||||
'created_at' => $info['created_at'] ?? $tokenData['created_at'] ?? 0,
|
||||
'extra' => $tokenData['extra'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有在线会话列表
|
||||
*
|
||||
* @param string|null $clientType
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getAllSessions(?string $clientType = null): array
|
||||
{
|
||||
$tokens = $this->tokenStorage->getAllTokens($clientType);
|
||||
|
||||
return array_values($tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新当前 Token 的最后活跃时间
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function touchCurrentToken(): bool
|
||||
{
|
||||
$payload = $this->getPayloadFromRequest();
|
||||
$jti = $payload['jti'] ?? '';
|
||||
if (empty($jti)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tokenStorage->touch($jti);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 JTI 踢出指定会话
|
||||
*
|
||||
* @param string $jti
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function kickoutByJti(string $jti): bool
|
||||
{
|
||||
// 获取 token 信息
|
||||
$tokenData = $this->tokenStorage->get($jti);
|
||||
|
||||
if ($tokenData === null) {
|
||||
return $this->tokenStorage->markOffline($jti);
|
||||
}
|
||||
|
||||
// 将 access_token 加入黑名单
|
||||
$blacklistTtl = $this->config['ttl']['refresh'] ?? 604800;
|
||||
$this->blacklistStorage->add($jti, $blacklistTtl);
|
||||
|
||||
// 将 refresh_token 也加入黑名单
|
||||
$refreshJti = $tokenData['refresh_jti'] ?? '';
|
||||
if (!empty($refreshJti)) {
|
||||
$this->blacklistStorage->add($refreshJti, $blacklistTtl);
|
||||
}
|
||||
|
||||
// 标记会话离线,保留监控列表记录
|
||||
return $this->tokenStorage->markOffline($jti);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录设备数
|
||||
*
|
||||
* @param string $id
|
||||
* @param string|null $clientType
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function deviceCount(string $id, ?string $clientType = null): int
|
||||
{
|
||||
return $this->tokenStorage->countByUser($id, $clientType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否在黑名单
|
||||
*
|
||||
* @param string $id
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isUserBlacklisted(string $id): bool
|
||||
{
|
||||
return $this->blacklistStorage->hasUser($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户加入黑名单
|
||||
*
|
||||
* @param string $id
|
||||
* @param int|null $ttl 有效期(秒)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function blacklistUser(string $id, ?int $ttl = null): bool
|
||||
{
|
||||
$ttl = $ttl ?? ($this->config['ttl']['refresh'] ?? 604800);
|
||||
|
||||
// 退出用户所有设备
|
||||
$this->logoutAll($id);
|
||||
|
||||
// 加入黑名单
|
||||
return $this->blacklistStorage->addUser($id, $ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销 Token
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function revoke(string $token): bool
|
||||
{
|
||||
try {
|
||||
$payload = $this->parseToken($token);
|
||||
$jti = $payload['jti'] ?? '';
|
||||
|
||||
if (!empty($jti)) {
|
||||
$this->blacklistStorage->add($jti, $this->config['ttl']['access'] ?? 7200);
|
||||
$this->tokenStorage->delete($jti);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 状态
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return TokenStatus
|
||||
*/
|
||||
public function getTokenStatus(string $token): TokenStatus
|
||||
{
|
||||
try {
|
||||
$payload = $this->parseToken($token);
|
||||
$jti = $payload['jti'] ?? '';
|
||||
|
||||
// 检查是否在黑名单
|
||||
if (!empty($jti) && $this->blacklistStorage->has($jti)) {
|
||||
return TokenStatus::REVOKED;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
$exp = $payload['exp'] ?? 0;
|
||||
if ($exp > 0 && $exp < time()) {
|
||||
return TokenStatus::EXPIRED;
|
||||
}
|
||||
|
||||
return TokenStatus::ACTIVE;
|
||||
} catch (ExpiredException $e) {
|
||||
return TokenStatus::EXPIRED;
|
||||
} catch (\Throwable $e) {
|
||||
return TokenStatus::REVOKED;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/**
|
||||
* 处理登录模式
|
||||
*/
|
||||
protected function handleLoginMode(string $id, string $clientType, string $loginMode): void
|
||||
{
|
||||
switch ($loginMode) {
|
||||
case LoginMode::SINGLE->value:
|
||||
// 单端登录:踢掉所有现有设备
|
||||
$this->logoutAll($id);
|
||||
break;
|
||||
|
||||
case LoginMode::CLIENT->value:
|
||||
// 客户端模式:踢掉同类型设备的登录
|
||||
$this->logoutAll($id, $clientType);
|
||||
break;
|
||||
|
||||
case LoginMode::MULTI->value:
|
||||
// 多端登录:检查最大设备数
|
||||
$maxDevices = $this->config['security']['max_devices'] ?? 0;
|
||||
if ($maxDevices > 0) {
|
||||
$count = $this->tokenStorage->countByUser($id);
|
||||
if ($count >= $maxDevices) {
|
||||
// 删除最早的设备
|
||||
$this->tokenStorage->deleteOldest($id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 refresh_jti 反查 access token 会话
|
||||
*/
|
||||
protected function findAccessTokenByRefreshJti(string $id, string $clientType, string $refreshJti): array
|
||||
{
|
||||
$tokens = $this->tokenStorage->getUserTokens($id, $clientType);
|
||||
foreach ($tokens as $accessJti => $info) {
|
||||
if (($info['refresh_jti'] ?? '') !== $refreshJti) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tokenInfo = $this->tokenStorage->get($accessJti);
|
||||
if ($tokenInfo !== null) {
|
||||
return [$accessJti, $tokenInfo];
|
||||
}
|
||||
}
|
||||
|
||||
return ['', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Token 载荷
|
||||
*/
|
||||
protected function buildPayload(
|
||||
string $id,
|
||||
string $client,
|
||||
string $jti,
|
||||
TokenType $type,
|
||||
array $extra = []
|
||||
): array
|
||||
{
|
||||
$now = time();
|
||||
$ttl = match ($type) {
|
||||
TokenType::ACCESS => $this->config['ttl']['access'] ?? 7200,
|
||||
TokenType::REFRESH => $this->config['ttl']['refresh'] ?? 604800,
|
||||
};
|
||||
|
||||
$payload = [
|
||||
'iss' => $this->config['issuer'] ?? 'webman',
|
||||
'id' => $id,
|
||||
'client' => $client,
|
||||
'jti' => $jti,
|
||||
'type' => $type->value,
|
||||
'iat' => $now,
|
||||
'exp' => $now + $ttl,
|
||||
];
|
||||
|
||||
if (!empty($extra)) {
|
||||
$payload['extra'] = $extra;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JWT Token
|
||||
*/
|
||||
protected function makeToken(array $payload): string
|
||||
{
|
||||
return JWT::encode($payload, $this->secret, $this->algo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Token
|
||||
*/
|
||||
protected function parseToken(string $token): array
|
||||
{
|
||||
// 处理带有前缀的 token
|
||||
if (str_starts_with($token, 'Bearer ')) {
|
||||
$token = substr($token, 7);
|
||||
}
|
||||
return (array)JWT::decode($token, new Key($this->secret, $this->algo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JTI
|
||||
*/
|
||||
protected function generateJti(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中获取 Access Token
|
||||
*/
|
||||
protected function getAccessTokenFromRequest(): ?string
|
||||
{
|
||||
$auth = request()->header('Authorization', '');
|
||||
if (str_starts_with($auth, 'Bearer ')) {
|
||||
return substr($auth, 7);
|
||||
}
|
||||
return request()->input('token');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中获取 Refresh Token
|
||||
*/
|
||||
protected function getRefreshTokenFromRequest(): ?string
|
||||
{
|
||||
// 先从 Authorization 头中获取
|
||||
$tokenName = config('core.jwt.app.token_name', 'Authorization');
|
||||
$authorization = request()->header($tokenName);
|
||||
if (!empty($authorization)) {
|
||||
if (str_starts_with($authorization, 'Bearer ')) {
|
||||
$token = substr($authorization, 7);
|
||||
if (!empty($token)) {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 再从请求输入中获取
|
||||
return request()->input('refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中获取 Token 载荷
|
||||
*/
|
||||
public function getPayloadFromRequest(): array
|
||||
{
|
||||
if ($this->payloadCache !== null) {
|
||||
return $this->payloadCache;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $this->getAccessTokenFromRequest();
|
||||
if (empty($token)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->payloadCache = $this->parseToken($token);
|
||||
return $this->payloadCache;
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Token 数据对象
|
||||
*/
|
||||
readonly class Token implements JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param string $accessToken 访问令牌
|
||||
* @param string $refreshToken 刷新令牌
|
||||
* @param int $expiresIn 有效期(秒)
|
||||
* @param string $tokenType 令牌类型
|
||||
* @param string $jti 令牌唯一标识
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @param string $clientType 客户端类型
|
||||
* @param DateTimeImmutable $issuedAt 签发时间
|
||||
* @param DateTimeImmutable $expiresAt 过期时间
|
||||
* @param array $payload 载荷数据
|
||||
*/
|
||||
public function __construct(
|
||||
public string $accessToken,
|
||||
public string $refreshToken,
|
||||
public int $expiresIn,
|
||||
public string $tokenType = 'Bearer',
|
||||
public string $jti = '',
|
||||
public string $id = '',
|
||||
public string $clientType = '',
|
||||
public DateTimeImmutable $issuedAt = new DateTimeImmutable(),
|
||||
public DateTimeImmutable $expiresAt = new DateTimeImmutable(),
|
||||
public array $payload = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建 Token 实例
|
||||
*
|
||||
* @param string $accessToken 访问令牌
|
||||
* @param string $refreshToken 刷新令牌
|
||||
* @param int $expiresIn 有效期(秒)
|
||||
* @param array $payload 完整载荷
|
||||
* @return static
|
||||
*/
|
||||
public static function create(
|
||||
string $accessToken,
|
||||
string $refreshToken,
|
||||
int $expiresIn,
|
||||
array $payload = []
|
||||
): static {
|
||||
$now = new DateTimeImmutable();
|
||||
$expiresAt = (new DateTimeImmutable())->setTimestamp($now->getTimestamp() + $expiresIn);
|
||||
|
||||
return new static(
|
||||
accessToken: $accessToken,
|
||||
refreshToken: $refreshToken,
|
||||
expiresIn: $expiresIn,
|
||||
jti: $payload['jti'] ?? '',
|
||||
id: (string)($payload['id'] ?? ''),
|
||||
clientType: $payload['client'] ?? '',
|
||||
issuedAt: $now,
|
||||
expiresAt: $expiresAt,
|
||||
payload: $payload,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中解析 Token
|
||||
*
|
||||
* 支持多种格式:
|
||||
* 1. Header: Authorization: Bearer <token>
|
||||
* 2. Header: Authorization: <token>
|
||||
* 3. 参数: access_token=<token>
|
||||
* 4. 参数: token=<token>
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function fromRequest(): ?string
|
||||
{
|
||||
// 1. Header Authorization Bearer
|
||||
$auth = request()->header('Authorization', '');
|
||||
if (!empty($auth)) {
|
||||
if (str_starts_with($auth, 'Bearer ')) {
|
||||
return substr($auth, 7);
|
||||
}
|
||||
// 直接是 token
|
||||
return $auth;
|
||||
}
|
||||
|
||||
// 2. 参数 access_token
|
||||
$token = request()->input('access_token');
|
||||
if (!empty($token)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
// 3. 参数 token
|
||||
$token = request()->input('token');
|
||||
if (!empty($token)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Token 字符串
|
||||
*
|
||||
* @param string $tokenString JWT token 字符串
|
||||
* @param int $expiresIn 有效期(秒)
|
||||
* @param array $payload 载荷数据
|
||||
* @return static
|
||||
*/
|
||||
public static function parse(string $tokenString, int $expiresIn, array $payload = []): static
|
||||
{
|
||||
return static::create($tokenString, '', $expiresIn, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
*/
|
||||
public function getAccessToken(): string
|
||||
{
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新令牌
|
||||
*/
|
||||
public function getRefreshToken(): string
|
||||
{
|
||||
return $this->refreshToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户ID(雪花ID)
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端类型
|
||||
*/
|
||||
public function getClientType(): string
|
||||
{
|
||||
return $this->clientType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取令牌唯一标识
|
||||
*/
|
||||
public function getJti(): string
|
||||
{
|
||||
return $this->jti;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取过期时间戳
|
||||
*/
|
||||
public function getExpiresAt(): int
|
||||
{
|
||||
return $this->expiresAt->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已过期
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return time() >= $this->expiresAt->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余有效期(秒)
|
||||
*/
|
||||
public function getRemainingTime(): int
|
||||
{
|
||||
$remaining = $this->expiresAt->getTimestamp() - time();
|
||||
return max(0, $remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为数组
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'token_type' => $this->tokenType,
|
||||
'access_token' => $this->accessToken,
|
||||
'refresh_token' => $this->refreshToken,
|
||||
'expires_in' => $this->expiresIn,
|
||||
'expires_at' => $this->expiresAt->format('Y-m-d H:i:s'),
|
||||
'jti' => $this->jti,
|
||||
'id' => $this->id,
|
||||
'client_type' => $this->clientType,
|
||||
'issued_at' => $this->issuedAt->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为前端格式
|
||||
*/
|
||||
public function toFrontend(): array
|
||||
{
|
||||
return [
|
||||
'token' => $this->accessToken,
|
||||
'refresh_token' => $this->refreshToken,
|
||||
'expires_in' => $this->expiresIn,
|
||||
'expires_at' => $this->expiresAt->getTimestamp(),
|
||||
'token_type' => $this->tokenType,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 序列化
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* JWT 配置文件
|
||||
*/
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
'token_name' => 'Authorization',
|
||||
// ========== 密钥配置 ==========
|
||||
// 必须修改为您的实际密钥,建议使用 openssl_random_pseudo_bytes(32) 生成
|
||||
'secret' => env('JWT_SECRET', 'your-secret-key-change-in-production'),
|
||||
|
||||
// 加密算法
|
||||
'algo' => env('JWT_ALGO', 'HS256'),
|
||||
|
||||
// ========== Token 有效期配置 (秒) ==========
|
||||
'ttl' => [
|
||||
// 访问令牌有效期
|
||||
'access' => env('JWT_ACCESS_TTL', 3600),
|
||||
|
||||
// 刷新令牌有效期,默认 7 天
|
||||
'refresh' => env('JWT_REFRESH_TTL', 604800),
|
||||
],
|
||||
|
||||
// ========== 登录模式 ==========
|
||||
// single: 单端登录(同一用户只能在一个设备登录)
|
||||
// client: 客户端模式(同一客户端只能在一个设备,不同客户端可同时)
|
||||
// multi: 多端登录(同一用户可在多个设备同时登录)
|
||||
'login_mode' => env('JWT_LOGIN_MODE', 'multi'),
|
||||
|
||||
// ========== 客户端类型配置 ==========
|
||||
// 预定义类型: admin, api, web, mobile, mini
|
||||
// 可在此处添加自定义类型
|
||||
'client_types' => [
|
||||
// 示例:添加自定义客户端类型
|
||||
// 'pc' => 'pc',
|
||||
// 'ipad' => 'ipad',
|
||||
],
|
||||
|
||||
// ========== 存储配置 ==========
|
||||
'storage' => [
|
||||
// Token 存储驱动
|
||||
'token' => env('JWT_STORAGE_TOKEN', 'redis'),
|
||||
// 黑名单存储驱动
|
||||
'blacklist' => env('JWT_STORAGE_BLACKLIST', 'redis'),
|
||||
// Redis 前缀
|
||||
'redis_prefix' => env('JWT_REDIS_PREFIX', 'jwt:'),
|
||||
],
|
||||
|
||||
// ========== 安全配置 ==========
|
||||
'security' => [
|
||||
// 是否启用刷新令牌
|
||||
'refresh_enabled' => true,
|
||||
|
||||
// 刷新令牌宽限期(秒),允许在令牌即将过期时提前刷新
|
||||
'refresh_grace_period' => env('JWT_REFRESH_GRACE_PERIOD', 3600),
|
||||
|
||||
// 最大同时登录设备数(0 表示不限制)
|
||||
'max_devices' => env('JWT_MAX_DEVICES', 0),
|
||||
|
||||
// 是否强制刷新(每次请求都返回新的 access_token)
|
||||
'force_refresh' => false,
|
||||
|
||||
// 滑动过期:每次使用自动延长 access_token 有效期
|
||||
'sliding_expiry' => false,
|
||||
],
|
||||
|
||||
// ========== Redis 连接配置 ==========
|
||||
'redis' => [
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => env('REDIS_PORT', 6379),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => env('REDIS_DATABASE', 0),
|
||||
'timeout' => env('REDIS_TIMEOUT', 0),
|
||||
],
|
||||
|
||||
// ========== 预留字段 ==========
|
||||
// 可在生成 Token 时传入自定义数据
|
||||
'reserved_claims' => [
|
||||
// 'iss', // 签发者
|
||||
// 'aud', // 受众
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\enum;
|
||||
|
||||
/**
|
||||
* 客户端类型枚举
|
||||
*/
|
||||
enum ClientType: string
|
||||
{
|
||||
case ADMIN = 'admin';
|
||||
case API = 'api';
|
||||
case WEB = 'web';
|
||||
case MOBILE = 'mobile';
|
||||
case MINI = 'mini';
|
||||
|
||||
/**
|
||||
* 检查客户端类型是否有效
|
||||
*/
|
||||
public static function isValid(string $type): bool
|
||||
{
|
||||
foreach (self::cases() as $case) {
|
||||
if ($case->value === $type) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 合并配置文件中定义的额外类型
|
||||
$config = config('jwt.app.client_types', []);
|
||||
return in_array($type, $config, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有有效的客户端类型值
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
$values = array_map(fn(self $case) => $case->value, self::cases());
|
||||
|
||||
// 合并配置文件中定义的额外类型
|
||||
$config = config('jwt.app.client_types', []);
|
||||
return array_merge($values, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举值
|
||||
*/
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\enum;
|
||||
|
||||
/**
|
||||
* 登录模式枚举
|
||||
*/
|
||||
enum LoginMode: string
|
||||
{
|
||||
/**
|
||||
* 单端登录模式 - 同一用户只能在一个设备登录,新登录会踢掉旧登录
|
||||
*/
|
||||
case SINGLE = 'single';
|
||||
|
||||
/**
|
||||
* 客户端模式 - 同一客户端只能在一个设备登录,不同客户端可同时登录
|
||||
*/
|
||||
case CLIENT = 'client';
|
||||
|
||||
/**
|
||||
* 多端模式 - 同一用户可在多个设备同时登录
|
||||
*/
|
||||
case MULTI = 'multi';
|
||||
|
||||
/**
|
||||
* 获取枚举值
|
||||
*/
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\enum;
|
||||
|
||||
/**
|
||||
* Token 状态枚举
|
||||
*/
|
||||
enum TokenStatus: string
|
||||
{
|
||||
case ACTIVE = 'active';
|
||||
case REFRESHED = 'refreshed';
|
||||
case REVOKED = 'revoked';
|
||||
case EXPIRED = 'expired';
|
||||
|
||||
/**
|
||||
* 获取枚举值
|
||||
*/
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\enum;
|
||||
|
||||
/**
|
||||
* Token 类型枚举
|
||||
*/
|
||||
enum TokenType: string
|
||||
{
|
||||
case ACCESS = 'access';
|
||||
case REFRESH = 'refresh';
|
||||
|
||||
/**
|
||||
* 获取枚举值
|
||||
*/
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\ex;
|
||||
|
||||
/**
|
||||
* JWT V2 模块基础异常
|
||||
*/
|
||||
class JwtException extends \Exception
|
||||
{
|
||||
public function __construct(string $message = '', int $code = -1, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\ex;
|
||||
|
||||
/**
|
||||
* 登录模式异常
|
||||
*/
|
||||
class JwtLoginModeException extends JwtException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\ex;
|
||||
|
||||
/**
|
||||
* 刷新令牌已过期异常
|
||||
*/
|
||||
class JwtRefreshTokenExpiredException extends JwtException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\ex;
|
||||
|
||||
/**
|
||||
* Token 已过期异常
|
||||
*/
|
||||
class JwtTokenExpiredException extends JwtException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\ex;
|
||||
|
||||
/**
|
||||
* Token 在黑名单中异常
|
||||
*/
|
||||
class JwtTokenInBlacklistException extends JwtException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\ex;
|
||||
|
||||
/**
|
||||
* Token 无效异常
|
||||
*/
|
||||
class JwtTokenInvalidException extends JwtException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\ex;
|
||||
|
||||
/**
|
||||
* Token 已被替换异常
|
||||
*/
|
||||
class JwtTokenReplacedException extends JwtException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\interfaces;
|
||||
|
||||
/**
|
||||
* 黑名单存储接口
|
||||
*
|
||||
* 可实现此接口以支持不同的存储后端(Redis、MySQL、Memcached 等)
|
||||
*/
|
||||
interface BlacklistStorageInterface
|
||||
{
|
||||
/**
|
||||
* 添加 Token 到黑名单
|
||||
*
|
||||
* @param string $jti Token 唯一标识
|
||||
* @param int $ttl 有效期(秒)
|
||||
* @return bool
|
||||
*/
|
||||
public function add(string $jti, int $ttl): bool;
|
||||
|
||||
/**
|
||||
* 检查 Token 是否在黑名单
|
||||
*
|
||||
* @param string $jti
|
||||
* @return bool
|
||||
*/
|
||||
public function has(string $jti): bool;
|
||||
|
||||
/**
|
||||
* 移除 Token 从黑名单
|
||||
*
|
||||
* @param string $jti
|
||||
* @return bool
|
||||
*/
|
||||
public function remove(string $jti): bool;
|
||||
|
||||
/**
|
||||
* 添加用户到黑名单
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @param int $ttl 有效期(秒)
|
||||
* @return bool
|
||||
*/
|
||||
public function addUser(string $id, int $ttl): bool;
|
||||
|
||||
/**
|
||||
* 检查用户是否在黑名单
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @return bool
|
||||
*/
|
||||
public function hasUser(string $id): bool;
|
||||
|
||||
/**
|
||||
* 移除用户从黑名单
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @return bool
|
||||
*/
|
||||
public function removeUser(string $id): bool;
|
||||
|
||||
/**
|
||||
* 获取黑名单剩余 TTL
|
||||
*
|
||||
* @param string $jti
|
||||
* @return int -1 表示不存在,-2 表示已过期
|
||||
*/
|
||||
public function getTtl(string $jti): int;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\interfaces;
|
||||
|
||||
/**
|
||||
* Token 存储接口
|
||||
*
|
||||
* 可实现此接口以支持不同的存储后端(Redis、MySQL、Memcached 等)
|
||||
*/
|
||||
interface TokenStorageInterface
|
||||
{
|
||||
/**
|
||||
* 保存 Token 信息
|
||||
*
|
||||
* @param string $jti Token 唯一标识
|
||||
* @param array $data Token 数据
|
||||
* @param int $ttl 有效期(秒)
|
||||
* @return bool
|
||||
*/
|
||||
public function save(string $jti, array $data, int $ttl): bool;
|
||||
|
||||
/**
|
||||
* 获取 Token 信息
|
||||
*
|
||||
* @param string $jti
|
||||
* @return array|null
|
||||
*/
|
||||
public function get(string $jti): ?array;
|
||||
|
||||
/**
|
||||
* 删除 Token 信息
|
||||
*
|
||||
* @param string $jti
|
||||
* @return bool
|
||||
*/
|
||||
public function delete(string $jti): bool;
|
||||
|
||||
/**
|
||||
* 验证 Token 是否存在
|
||||
*
|
||||
* @param string $jti
|
||||
* @return bool
|
||||
*/
|
||||
public function exists(string $jti): bool;
|
||||
|
||||
/**
|
||||
* 根据用户删除所有 Token
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @param string|null $clientType
|
||||
* @param string|null $exceptJti 保留的 JTI
|
||||
* @return int 删除数量
|
||||
*/
|
||||
public function deleteByUser(string $id, ?string $clientType = null, ?string $exceptJti = null): int;
|
||||
|
||||
/**
|
||||
* 删除最早的 Token
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @return bool
|
||||
*/
|
||||
public function deleteOldest(string $id): bool;
|
||||
|
||||
/**
|
||||
* 获取用户的所有 Token 数量
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @param string|null $clientType
|
||||
* @return int
|
||||
*/
|
||||
public function countByUser(string $id, ?string $clientType = null): int;
|
||||
|
||||
/**
|
||||
* 获取用户的所有 Token
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @return array
|
||||
*/
|
||||
public function getByUser(string $id): array;
|
||||
|
||||
/**
|
||||
* 获取用户的所有 Token 信息(包含索引信息)
|
||||
*
|
||||
* @param string $id 用户ID(雪花ID)
|
||||
* @param string|null $clientType 客户端类型
|
||||
* @return array
|
||||
*/
|
||||
public function getUserTokens(string $id, ?string $clientType = null): array;
|
||||
|
||||
/**
|
||||
* 获取所有在线 Token 信息
|
||||
*
|
||||
* @param string|null $clientType 客户端类型
|
||||
* @return array
|
||||
*/
|
||||
public function getAllTokens(?string $clientType = null): array;
|
||||
|
||||
/**
|
||||
* 更新 Token 最后活跃时间
|
||||
*
|
||||
* @param string $jti Token 唯一标识
|
||||
* @param int|null $timestamp 活跃时间戳
|
||||
* @return bool
|
||||
*/
|
||||
public function touch(string $jti, ?int $timestamp = null): bool;
|
||||
|
||||
/**
|
||||
* 标记 Token 会话离线
|
||||
*
|
||||
* @param string $jti Token 唯一标识
|
||||
* @param int|null $timestamp 离线时间戳
|
||||
* @return bool
|
||||
*/
|
||||
public function markOffline(string $jti, ?int $timestamp = null): bool;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\storage;
|
||||
|
||||
use core\jwt\interfaces\BlacklistStorageInterface;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* Redis 黑名单存储
|
||||
*/
|
||||
class RedisBlacklistStorage implements BlacklistStorageInterface
|
||||
{
|
||||
/**
|
||||
* @var string 前缀
|
||||
*/
|
||||
protected string $prefix;
|
||||
|
||||
/**
|
||||
* @var array 配置
|
||||
*/
|
||||
protected array $config;
|
||||
|
||||
/**
|
||||
* @var string 用户黑名单 Key 后缀
|
||||
*/
|
||||
protected const USER_BLACKLIST_SUFFIX = 'user:blacklist:';
|
||||
|
||||
/**
|
||||
* @var string Token 黑名单 Key 后缀
|
||||
*/
|
||||
protected const TOKEN_BLACKLIST_SUFFIX = 'blacklist:';
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->prefix = $config['storage']['redis_prefix'] ?? 'jwt2:';
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Token 到黑名单
|
||||
*/
|
||||
public function add(string $jti, int $ttl): bool
|
||||
{
|
||||
$key = $this->prefix . self::TOKEN_BLACKLIST_SUFFIX . $jti;
|
||||
Redis::setex($key, $ttl, '1');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Token 是否在黑名单
|
||||
*/
|
||||
public function has(string $jti): bool
|
||||
{
|
||||
$key = $this->prefix . self::TOKEN_BLACKLIST_SUFFIX . $jti;
|
||||
return (bool) Redis::exists($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Token 从黑名单
|
||||
*/
|
||||
public function remove(string $jti): bool
|
||||
{
|
||||
$key = $this->prefix . self::TOKEN_BLACKLIST_SUFFIX . $jti;
|
||||
Redis::del($key);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加用户到黑名单
|
||||
*/
|
||||
public function addUser(string $id, int $ttl): bool
|
||||
{
|
||||
$key = $this->prefix . self::USER_BLACKLIST_SUFFIX . $id;
|
||||
Redis::setex($key, $ttl, '1');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否在黑名单
|
||||
*/
|
||||
public function hasUser(string $id): bool
|
||||
{
|
||||
$key = $this->prefix . self::USER_BLACKLIST_SUFFIX . $id;
|
||||
return (bool) Redis::exists($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除用户从黑名单
|
||||
*/
|
||||
public function removeUser(string $id): bool
|
||||
{
|
||||
$key = $this->prefix . self::USER_BLACKLIST_SUFFIX . $id;
|
||||
Redis::del($key);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取黑名单剩余 TTL
|
||||
*/
|
||||
public function getTtl(string $jti): int
|
||||
{
|
||||
$key = $this->prefix . self::TOKEN_BLACKLIST_SUFFIX . $jti;
|
||||
return (int) Redis::ttl($key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace core\jwt\storage;
|
||||
|
||||
use core\jwt\interfaces\TokenStorageInterface;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* Redis Token 存储
|
||||
*/
|
||||
class RedisTokenStorage implements TokenStorageInterface
|
||||
{
|
||||
/**
|
||||
* @var string 前缀
|
||||
*/
|
||||
protected string $prefix;
|
||||
|
||||
/**
|
||||
* @var array 配置
|
||||
*/
|
||||
protected array $config;
|
||||
|
||||
/**
|
||||
* @var string 用户索引 Key 前缀
|
||||
*/
|
||||
protected const USER_INDEX_PREFIX = 'user:';
|
||||
|
||||
/**
|
||||
* @var string 全局在线 Token 索引 Key
|
||||
*/
|
||||
protected const ONLINE_INDEX_KEY = 'online';
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->prefix = $config['storage']['redis_prefix'] ?? 'jwt2:';
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Token 信息
|
||||
*/
|
||||
public function save(string $jti, array $data, int $ttl): bool
|
||||
{
|
||||
$key = $this->prefix . $jti;
|
||||
$id = $data['id'] ?? '';
|
||||
$clientType = $data['client_type'] ?? '';
|
||||
$now = time();
|
||||
$data['last_active_at'] = $data['last_active_at'] ?? $now;
|
||||
|
||||
// 保存 Token 数据
|
||||
Redis::setex($key, $ttl, json_encode($data));
|
||||
|
||||
$onlineKey = $this->prefix . self::ONLINE_INDEX_KEY;
|
||||
Redis::hset($onlineKey, $jti, json_encode([
|
||||
'id' => $id,
|
||||
'client_type' => $clientType,
|
||||
'created_at' => $data['created_at'] ?? $now,
|
||||
'last_active_at' => $data['last_active_at'],
|
||||
'refresh_jti' => $data['refresh_jti'] ?? '',
|
||||
'status' => $data['status'] ?? 'online',
|
||||
'offline_at' => $data['offline_at'] ?? null,
|
||||
'snapshot' => $data,
|
||||
]));
|
||||
Redis::expire($onlineKey, $this->config['ttl']['refresh'] ?? 604800);
|
||||
|
||||
// 添加用户索引(保存 refresh_jti 以便踢人时使用)
|
||||
if (!empty($id)) {
|
||||
$userKey = $this->prefix . self::USER_INDEX_PREFIX . $id;
|
||||
Redis::hset($userKey, $jti, json_encode([
|
||||
'id' => $id,
|
||||
'client_type' => $clientType,
|
||||
'created_at' => $data['created_at'] ?? $now,
|
||||
'last_active_at' => $data['last_active_at'],
|
||||
'refresh_jti' => $data['refresh_jti'] ?? '',
|
||||
'status' => $data['status'] ?? 'online',
|
||||
'offline_at' => $data['offline_at'] ?? null,
|
||||
]));
|
||||
// 设置用户索引过期时间(比 refresh_token 长一点)
|
||||
Redis::expire($userKey, $this->config['ttl']['refresh'] ?? 604800);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 信息
|
||||
*/
|
||||
public function get(string $jti): ?array
|
||||
{
|
||||
$key = $this->prefix . $jti;
|
||||
$data = Redis::get($key);
|
||||
|
||||
if ($data === null || $data === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode((string) $data, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Token 信息
|
||||
*/
|
||||
public function delete(string $jti): bool
|
||||
{
|
||||
$key = $this->prefix . $jti;
|
||||
|
||||
// 获取数据以便清理用户索引
|
||||
$data = $this->get($jti);
|
||||
|
||||
Redis::del($key);
|
||||
|
||||
$indexRaw = Redis::hget($this->prefix . self::ONLINE_INDEX_KEY, $jti);
|
||||
$indexData = $indexRaw ? (json_decode((string)$indexRaw, true) ?: []) : [];
|
||||
if (($indexData['status'] ?? '') === 'offline') {
|
||||
return true;
|
||||
}
|
||||
|
||||
Redis::hdel($this->prefix . self::ONLINE_INDEX_KEY, $jti);
|
||||
|
||||
// 清理用户索引
|
||||
if ($data && !empty($data['id'])) {
|
||||
$userKey = $this->prefix . self::USER_INDEX_PREFIX . $data['id'];
|
||||
Redis::hdel($userKey, $jti);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token 是否存在
|
||||
*/
|
||||
public function exists(string $jti): bool
|
||||
{
|
||||
$key = $this->prefix . $jti;
|
||||
return (bool) Redis::exists($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户删除所有 Token
|
||||
*/
|
||||
public function deleteByUser(string $id, ?string $clientType = null, ?string $exceptJti = null): int
|
||||
{
|
||||
$userKey = $this->prefix . self::USER_INDEX_PREFIX . $id;
|
||||
$jtis = Redis::hgetall($userKey);
|
||||
|
||||
if (empty($jtis)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($jtis as $jtiKey => $info) {
|
||||
// 跳过保留的 JTI
|
||||
if ($exceptJti !== null && $jtiKey === $exceptJti) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果指定了客户端类型,只删除匹配的
|
||||
if ($clientType !== null) {
|
||||
$infoData = json_decode((string) $info, true);
|
||||
if (($infoData['client_type'] ?? '') !== $clientType) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$infoData = json_decode((string) $info, true) ?: [];
|
||||
if (($infoData['status'] ?? '') === 'offline') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 删除 Token
|
||||
Redis::del($this->prefix . $jtiKey);
|
||||
Redis::hdel($userKey, $jtiKey);
|
||||
Redis::hdel($this->prefix . self::ONLINE_INDEX_KEY, $jtiKey);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有 Token 信息
|
||||
*/
|
||||
public function getUserTokens(string $id, ?string $clientType = null): array
|
||||
{
|
||||
$userKey = $this->prefix . self::USER_INDEX_PREFIX . $id;
|
||||
$jtis = Redis::hgetall($userKey);
|
||||
|
||||
$tokens = [];
|
||||
foreach ($jtis as $jtiKey => $info) {
|
||||
// 如果指定了客户端类型,只返回匹配的
|
||||
if ($clientType !== null) {
|
||||
$infoData = json_decode((string) $info, true);
|
||||
if (($infoData['client_type'] ?? '') !== $clientType) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$tokens[$jtiKey] = json_decode((string) $info, true);
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除最早的 Token
|
||||
*/
|
||||
public function deleteOldest(string $id): bool
|
||||
{
|
||||
$userKey = $this->prefix . self::USER_INDEX_PREFIX . $id;
|
||||
$jtis = Redis::hgetall($userKey);
|
||||
|
||||
if (empty($jtis)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 找出最早的
|
||||
$oldestJti = null;
|
||||
$oldestTime = PHP_INT_MAX;
|
||||
|
||||
foreach ($jtis as $jtiKey => $info) {
|
||||
$infoData = json_decode((string) $info, true);
|
||||
$createdAt = $infoData['created_at'] ?? 0;
|
||||
if ($createdAt < $oldestTime) {
|
||||
$oldestTime = $createdAt;
|
||||
$oldestJti = $jtiKey;
|
||||
}
|
||||
}
|
||||
|
||||
if ($oldestJti !== null) {
|
||||
return $this->delete($oldestJti);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有 Token 数量
|
||||
*/
|
||||
public function countByUser(string $id, ?string $clientType = null): int
|
||||
{
|
||||
$userKey = $this->prefix . self::USER_INDEX_PREFIX . $id;
|
||||
$jtis = Redis::hgetall($userKey);
|
||||
|
||||
if (empty($jtis)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($clientType === null) {
|
||||
return count($jtis);
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($jtis as $jtiKey => $info) {
|
||||
$infoData = json_decode((string) $info, true);
|
||||
if (($infoData['client_type'] ?? '') === $clientType) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有 Token
|
||||
*/
|
||||
public function getByUser(string $id): array
|
||||
{
|
||||
$userKey = $this->prefix . self::USER_INDEX_PREFIX . $id;
|
||||
$jtis = Redis::hgetall($userKey);
|
||||
|
||||
$tokens = [];
|
||||
foreach ($jtis as $jtiKey => $info) {
|
||||
$tokenData = $this->get($jtiKey);
|
||||
if ($tokenData !== null) {
|
||||
$tokens[] = $tokenData;
|
||||
} else {
|
||||
$infoData = json_decode((string) $info, true) ?: [];
|
||||
if (($infoData['status'] ?? '') === 'offline') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 清理失效的索引
|
||||
Redis::hdel($userKey, $jtiKey);
|
||||
Redis::hdel($this->prefix . self::ONLINE_INDEX_KEY, $jtiKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有在线 Token 信息
|
||||
*/
|
||||
public function getAllTokens(?string $clientType = null): array
|
||||
{
|
||||
$onlineKey = $this->prefix . self::ONLINE_INDEX_KEY;
|
||||
$jtis = Redis::hgetall($onlineKey);
|
||||
|
||||
if (empty($jtis)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = [];
|
||||
foreach ($jtis as $jtiKey => $info) {
|
||||
$indexData = json_decode((string) $info, true) ?: [];
|
||||
if ($clientType !== null && ($indexData['client_type'] ?? '') !== $clientType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$storedTokenData = $this->get($jtiKey);
|
||||
$tokenData = $storedTokenData ?? $indexData['snapshot'] ?? null;
|
||||
if ($tokenData === null) {
|
||||
$tokenData = $indexData;
|
||||
}
|
||||
|
||||
$sessionStatus = $indexData['status'] ?? 'online';
|
||||
if ($storedTokenData === null) {
|
||||
$sessionStatus = 'offline';
|
||||
}
|
||||
|
||||
$tokens[$jtiKey] = array_merge($tokenData, [
|
||||
'jti' => $jtiKey,
|
||||
'status' => $sessionStatus,
|
||||
'offline_at' => $indexData['offline_at'] ?? ($sessionStatus === 'offline' ? ($indexData['last_active_at'] ?? time()) : null),
|
||||
'last_active_at' => $tokenData['last_active_at'] ?? $indexData['last_active_at'] ?? $tokenData['created_at'] ?? time(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Token 最后活跃时间
|
||||
*/
|
||||
public function touch(string $jti, ?int $timestamp = null): bool
|
||||
{
|
||||
$data = $this->get($jti);
|
||||
if ($data === null) {
|
||||
$indexRaw = Redis::hget($this->prefix . self::ONLINE_INDEX_KEY, $jti);
|
||||
$indexData = $indexRaw ? (json_decode((string)$indexRaw, true) ?: []) : [];
|
||||
if (($indexData['status'] ?? '') !== 'offline') {
|
||||
Redis::hdel($this->prefix . self::ONLINE_INDEX_KEY, $jti);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$timestamp = $timestamp ?? time();
|
||||
$data['last_active_at'] = $timestamp;
|
||||
$ttl = Redis::ttl($this->prefix . $jti);
|
||||
if ($ttl > 0) {
|
||||
Redis::setex($this->prefix . $jti, $ttl, json_encode($data));
|
||||
} else {
|
||||
Redis::set($this->prefix . $jti, json_encode($data));
|
||||
}
|
||||
|
||||
$indexData = [
|
||||
'id' => $data['id'] ?? '',
|
||||
'client_type' => $data['client_type'] ?? '',
|
||||
'created_at' => $data['created_at'] ?? $timestamp,
|
||||
'last_active_at' => $timestamp,
|
||||
'refresh_jti' => $data['refresh_jti'] ?? '',
|
||||
];
|
||||
Redis::hset($this->prefix . self::ONLINE_INDEX_KEY, $jti, json_encode($indexData));
|
||||
|
||||
if (!empty($data['id'])) {
|
||||
Redis::hset($this->prefix . self::USER_INDEX_PREFIX . $data['id'], $jti, json_encode($indexData));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function markOffline(string $jti, ?int $timestamp = null): bool
|
||||
{
|
||||
$timestamp = $timestamp ?? time();
|
||||
$data = $this->get($jti);
|
||||
$onlineKey = $this->prefix . self::ONLINE_INDEX_KEY;
|
||||
$indexRaw = Redis::hget($onlineKey, $jti);
|
||||
$indexData = $indexRaw ? (json_decode((string)$indexRaw, true) ?: []) : [];
|
||||
|
||||
if ($data === null && empty($indexData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$snapshot = $data ?? $indexData['snapshot'] ?? $indexData;
|
||||
$snapshot['status'] = 'offline';
|
||||
$snapshot['offline_at'] = $timestamp;
|
||||
$snapshot['last_active_at'] = $snapshot['last_active_at'] ?? $indexData['last_active_at'] ?? $timestamp;
|
||||
|
||||
$newIndexData = [
|
||||
'id' => $snapshot['id'] ?? $indexData['id'] ?? '',
|
||||
'client_type' => $snapshot['client_type'] ?? $indexData['client_type'] ?? '',
|
||||
'created_at' => $snapshot['created_at'] ?? $indexData['created_at'] ?? $timestamp,
|
||||
'last_active_at' => $snapshot['last_active_at'],
|
||||
'refresh_jti' => $snapshot['refresh_jti'] ?? $indexData['refresh_jti'] ?? '',
|
||||
'status' => 'offline',
|
||||
'offline_at' => $timestamp,
|
||||
'snapshot' => $snapshot,
|
||||
];
|
||||
|
||||
Redis::hset($onlineKey, $jti, json_encode($newIndexData));
|
||||
Redis::expire($onlineKey, $this->config['ttl']['refresh'] ?? 604800);
|
||||
|
||||
if (!empty($newIndexData['id'])) {
|
||||
$userKey = $this->prefix . self::USER_INDEX_PREFIX . $newIndexData['id'];
|
||||
Redis::hset($userKey, $jti, json_encode([
|
||||
'id' => $newIndexData['id'],
|
||||
'client_type' => $newIndexData['client_type'],
|
||||
'created_at' => $newIndexData['created_at'],
|
||||
'last_active_at' => $newIndexData['last_active_at'],
|
||||
'refresh_jti' => $newIndexData['refresh_jti'],
|
||||
'status' => 'offline',
|
||||
'offline_at' => $timestamp,
|
||||
]));
|
||||
Redis::expire($userKey, $this->config['ttl']['refresh'] ?? 604800);
|
||||
}
|
||||
|
||||
Redis::del($this->prefix . $jti);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\logger;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 日志处理门面
|
||||
* @method static void emergency(string $name, $arguments) 系统不可用(最高级别)
|
||||
* @method static void alert(string $name, $arguments) 需立即处理
|
||||
* @method static void critical(string $name, $arguments) 严重故障
|
||||
* @method static void error(string $name, $arguments) 运行错误
|
||||
* @method static void warning(string $name, $arguments) 警告信息
|
||||
* @method static void notice(string $name, $arguments) 重要事件
|
||||
* @method static void info(string $name, $arguments) 一般事件
|
||||
* @method static void debug(string $name, $arguments) 调试信息
|
||||
* @method static void log(string $name, $arguments) 通用日志(默认级别)
|
||||
* @method static void cleanup(bool $forceAll = false, ?string $channel = null, bool $removeEmptyDirs = true) 清除日志
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
final class Logger
|
||||
{
|
||||
private static ?LoggerService $service = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
// 禁止实例化
|
||||
}
|
||||
|
||||
private static function getService(): LoggerService
|
||||
{
|
||||
if (self::$service === null) {
|
||||
self::$service = new LoggerService();
|
||||
}
|
||||
return self::$service;
|
||||
}
|
||||
|
||||
public static function __callStatic(string $name, array $arguments): void
|
||||
{
|
||||
try {
|
||||
self::getService()->handleLog($name, $arguments);
|
||||
} catch (Throwable $e) {
|
||||
error_log('Logging failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\logger;
|
||||
|
||||
use core\logger\enum\LogLevel;
|
||||
use Monolog\Logger as Monolog;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
|
||||
final class LoggerService
|
||||
{
|
||||
private Monolog $logger;
|
||||
private static ?array $configCache = null;
|
||||
private static float $configCacheTime = 0;
|
||||
private const CONFIG_CACHE_TTL = 60;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
if (!self::getConfig('enable')) {
|
||||
throw new \RuntimeException('Logger is disabled in config');
|
||||
}
|
||||
|
||||
$this->logger = new Monolog(self::getConfig('base.channel'));
|
||||
|
||||
if (self::getConfig('handlers.stream')) {
|
||||
$this->addStreamHandlers();
|
||||
}
|
||||
|
||||
if (self::getConfig('handlers.syslog')) {
|
||||
$this->addSyslogHandler();
|
||||
}
|
||||
}
|
||||
|
||||
private function addStreamHandlers(): void
|
||||
{
|
||||
$formatter = new LineFormatter(
|
||||
self::getConfig('format.output'),
|
||||
self::getConfig('format.date'),
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
foreach (LogLevel::cases() as $level) {
|
||||
if ($this->shouldRecord($level)) {
|
||||
$handler = new StreamHandler(
|
||||
$this->getLogFile($level),
|
||||
$level->value,
|
||||
true,
|
||||
null,
|
||||
true
|
||||
);
|
||||
$handler->setFormatter($formatter);
|
||||
$handler->setLevel($level->value);
|
||||
$handler->setBubble(false);
|
||||
$this->logger->pushHandler($handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function handleLog(string $levelName, array $arguments): void
|
||||
{
|
||||
$level = LogLevel::fromName(strtoupper($levelName));
|
||||
if ($level === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::getConfig('enable') || !$this->shouldRecord($level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->log(
|
||||
$level->value,
|
||||
$arguments[0] ?? '',
|
||||
$arguments[1] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
private function shouldRecord(LogLevel $level): bool
|
||||
{
|
||||
if (!self::getConfig('enable')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($level === LogLevel::DEBUG &&
|
||||
false === self::getConfig('levels.debug', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$minLevel = LogLevel::fromName(
|
||||
strtoupper(self::getConfig('levels.min_level', 'debug'))
|
||||
);
|
||||
|
||||
return $level->value >= $minLevel->value;
|
||||
}
|
||||
|
||||
private function addSyslogHandler(): void
|
||||
{
|
||||
// syslog处理器实现
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $key
|
||||
* @param $default
|
||||
*
|
||||
* @return array|mixed|null
|
||||
*/
|
||||
public static function getConfig(string $key = null, $default = null): mixed
|
||||
{
|
||||
$now = microtime(true);
|
||||
|
||||
if (self::$configCache === null || ($now - self::$configCacheTime) > self::CONFIG_CACHE_TTL) {
|
||||
self::$configCache = config('core.logger.app', []);
|
||||
self::$configCacheTime = $now;
|
||||
}
|
||||
|
||||
return data_get(self::$configCache, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置缓存
|
||||
*/
|
||||
public static function clearConfigCache(): void
|
||||
{
|
||||
self::$configCache = null;
|
||||
self::$configCacheTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按通道和月份分类的日志文件路径
|
||||
*/
|
||||
private function getLogFile(LogLevel $level): string
|
||||
{
|
||||
$basePath = $this->getBaseLogPath();
|
||||
$monthDir = date('Y-m');
|
||||
$dayPrefix = date('d');
|
||||
|
||||
$fullPath = "{$basePath}/{$monthDir}";
|
||||
|
||||
if (!is_dir($fullPath) && !mkdir($fullPath, 0755, true)) {
|
||||
throw new \RuntimeException("Cannot create directory: {$fullPath}");
|
||||
}
|
||||
|
||||
return sprintf('%s/%s-%s.log', $fullPath, $dayPrefix, $level->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础日志路径(包含通道目录)
|
||||
*/
|
||||
private function getBaseLogPath(): string
|
||||
{
|
||||
$path = rtrim(self::getConfig('base.path'), '/');
|
||||
$channel = self::getConfig('base.channel', 'app');
|
||||
|
||||
$fullPath = "{$path}/{$channel}";
|
||||
|
||||
if (!is_dir($fullPath) && !mkdir($fullPath, 0755)) {
|
||||
throw new \RuntimeException("Channel directory creation failed: {$fullPath}");
|
||||
}
|
||||
|
||||
return $fullPath;
|
||||
}
|
||||
|
||||
public function cleanup(bool $forceAll = false, ?string $channel = null, bool $removeEmptyDirs = true): void
|
||||
{
|
||||
if (!self::getConfig('enable')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$basePath = rtrim(self::getConfig('base.path'), '/');
|
||||
$channel = $channel ?? self::getConfig('base.channel', 'app');
|
||||
$logPath = "{$basePath}/{$channel}";
|
||||
|
||||
if (!is_dir($logPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteExpiredFiles($logPath, $forceAll);
|
||||
|
||||
if ($removeEmptyDirs) {
|
||||
$this->deleteEmptyDirectories($logPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteExpiredFiles(string $logPath, bool $forceAll): void
|
||||
{
|
||||
$retentionDays = (int)self::getConfig('base.retention_days', 7);
|
||||
$cutoffDate = strtotime("-{$retentionDays} days");
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($logPath, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file->isFile() || $file->getExtension() !== 'log') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = $file->getFilename();
|
||||
$filePath = $file->getRealPath();
|
||||
$dirPath = dirname($filePath);
|
||||
|
||||
$monthDir = basename($dirPath);
|
||||
if (!preg_match('/^(\d{4})-(\d{2})$/', $monthDir, $monthMatches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preg_match('/^(\d{2})-(.+)\.log$/', $fileName, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileDay = $matches[1];
|
||||
$logLevel = strtoupper($matches[2]);
|
||||
$year = $monthMatches[1];
|
||||
$month = $monthMatches[2];
|
||||
|
||||
$fileDateStr = "{$year}-{$month}-{$fileDay}";
|
||||
$fileDate = strtotime($fileDateStr);
|
||||
|
||||
if ($fileDate === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::getConfig('debug') && $logLevel === 'INFO') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($forceAll || $fileDate < $cutoffDate) {
|
||||
$this->safeUnlink($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteEmptyDirectories(string $logPath): void
|
||||
{
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($logPath, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $dir) {
|
||||
if ($dir->isDir() && $this->isEmptyDirectory($dir->getRealPath())) {
|
||||
$this->safeRmdir($dir->getRealPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isEmptyDirectory(string $dirPath): bool
|
||||
{
|
||||
if (!is_dir($dirPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dir = new \FilesystemIterator($dirPath);
|
||||
return !$dir->valid();
|
||||
}
|
||||
|
||||
private function safeUnlink(string $filePath): bool
|
||||
{
|
||||
try {
|
||||
return @unlink($filePath);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Failed to delete file: {$filePath} - " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function safeRmdir(string $dirPath): bool
|
||||
{
|
||||
try {
|
||||
return @rmdir($dirPath);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Failed to remove directory: {$dirPath} - " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
// 基础配置
|
||||
'base' => [
|
||||
'path' => runtime_path('logs'), // 日志存储路径
|
||||
'channel' => 'core', // 日志通道名称
|
||||
'retention_days' => 7, // 默认保留7天
|
||||
'daily_rotation' => true, // 启用每日文件分割
|
||||
],
|
||||
// 格式配置
|
||||
'format' => [
|
||||
'date' => 'Y-m-d H:i:s.u', // 日期格式
|
||||
'output' => "[%datetime%] %channel%.%level_name%: %message% %context%\n",
|
||||
],
|
||||
|
||||
// 级别配置
|
||||
'levels' => [
|
||||
'debug' => config('app.debug', false), // 是否启用debug日志
|
||||
'min_level' => 'debug', // 最小记录级别
|
||||
],
|
||||
|
||||
// 处理器配置
|
||||
'handlers' => [
|
||||
'stream' => true, // 启用文件处理器
|
||||
'syslog' => false, // 是否启用syslog
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\logger\enum;
|
||||
|
||||
use core\interface\IEnum;
|
||||
use Error;
|
||||
use ValueError;
|
||||
|
||||
/**
|
||||
* 日志级别
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
enum LogLevel: int implements IEnum
|
||||
{
|
||||
case DEBUG = 100;
|
||||
case INFO = 200;
|
||||
case NOTICE = 250;
|
||||
case WARNING = 300;
|
||||
case ERROR = 400;
|
||||
case CRITICAL = 500;
|
||||
case ALERT = 550;
|
||||
case EMERGENCY = 600;
|
||||
|
||||
/* 基础方法 */
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return strtolower($this->name);
|
||||
}
|
||||
|
||||
public function value(): int
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/* 工厂方法 */
|
||||
|
||||
public static function fromName(string $name): ?self
|
||||
{
|
||||
try {
|
||||
return constant("self::" . strtoupper($name));
|
||||
} catch (Error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function tryFromName(string $name): self
|
||||
{
|
||||
return self::fromName($name) ?? throw new ValueError("Invalid log level: $name");
|
||||
}
|
||||
|
||||
/* 实用方法 */
|
||||
|
||||
public static function valuesArray(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
|
||||
public static function namesArray(): array
|
||||
{
|
||||
return array_column(self::cases(), 'name');
|
||||
}
|
||||
|
||||
public static function isValid(string|int $level): bool
|
||||
{
|
||||
if (is_int($level)) {
|
||||
return in_array($level, self::valuesArray(), true);
|
||||
}
|
||||
|
||||
return self::fromName($level) !== null;
|
||||
}
|
||||
|
||||
public static function getMinLevel(): self
|
||||
{
|
||||
return self::DEBUG;
|
||||
}
|
||||
|
||||
public static function getMaxLevel(): self
|
||||
{
|
||||
return self::EMERGENCY;
|
||||
}
|
||||
|
||||
/* 比较方法 */
|
||||
|
||||
public function isHigherThan(self $other): bool
|
||||
{
|
||||
return $this->value > $other->value;
|
||||
}
|
||||
|
||||
public function isLowerThan(self $other): bool
|
||||
{
|
||||
return $this->value < $other->value;
|
||||
}
|
||||
|
||||
/* 转换方法 */
|
||||
|
||||
public function toPsr3Level(): string
|
||||
{
|
||||
return $this->label();
|
||||
}
|
||||
|
||||
public function toMonologLevel(): int
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace core\monitor;
|
||||
|
||||
/**
|
||||
* 系统监控服务类
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
class ServerMonitor
|
||||
{
|
||||
|
||||
/**
|
||||
* 获取磁盘信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDiskInfo(): array
|
||||
{
|
||||
$disk = [];
|
||||
if (stristr(PHP_OS, 'WIN')) {
|
||||
// Windows 系统
|
||||
$drives = shell_exec('wmic logicaldisk get size,freespace,caption');
|
||||
$lines = explode("\n", trim($drives ?? ''));
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^([A-Z]:)/', $line, $matches)) {
|
||||
$parts = preg_split('/\s+/', trim($line));
|
||||
if (count($parts) >= 3) {
|
||||
$disk[] = [
|
||||
'filesystem' => $matches[1],
|
||||
'size' => $this->formatBytes(intval($parts[1])),
|
||||
'available' => $this->formatBytes(intval($parts[2])),
|
||||
'used' => $this->formatBytes(intval($parts[1]) - intval($parts[2])),
|
||||
'use_percentage' => sprintf('%.2f', (intval($parts[1]) - intval($parts[2])) / intval($parts[1]) * 100) . '%',
|
||||
'mounted_on' => $matches[1],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Linux 系统
|
||||
$diskInfo = shell_exec('df -h');
|
||||
$lines = explode("\n", trim($diskInfo ?? ''));
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\/dev\/\w+/', $line)) {
|
||||
$parts = preg_split('/\s+/', $line);
|
||||
$disk[] = [
|
||||
'filesystem' => $parts[0],
|
||||
'size' => $parts[1],
|
||||
'used' => $parts[2],
|
||||
'available' => $parts[3],
|
||||
'use_percentage' => $parts[4],
|
||||
'mounted_on' => $parts[5],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $disk;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取cpu信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getCpuInfo(): array
|
||||
{
|
||||
try {
|
||||
if (PHP_OS == 'Linux') {
|
||||
$cpu = $this->getCpuUsage();
|
||||
preg_match('/(\d+)/', shell_exec('cat /proc/cpuinfo | grep "cache size"') ?? '', $cache);
|
||||
if (count($cache) == 0) {
|
||||
// aarch64 有可能是arm架构
|
||||
$cache = trim(shell_exec("lscpu | grep L3 | awk '{print \$NF}'") ?? '');
|
||||
if ($cache == '') {
|
||||
$cache = trim(shell_exec("lscpu | grep L2 | awk '{print \$NF}'") ?? '');
|
||||
}
|
||||
if ($cache != '') {
|
||||
$cache = [0, intval(str_replace(['K', 'B'], '', strtoupper($cache)))];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$cpu = shell_exec('wmic cpu get LoadPercentage | findstr /V "LoadPercentage"');
|
||||
$cpu = intval(trim($cpu ?? '0'));
|
||||
$cache = shell_exec('wmic cpu get L3CacheSize | findstr /V "L3CacheSize"');
|
||||
$cache = trim($cache ?? '');
|
||||
if ($cache == '') {
|
||||
$cache = shell_exec('wmic cpu get L2CacheSize | findstr /V "L2CacheSize"');
|
||||
$cache = trim($cache ?? '');
|
||||
}
|
||||
if ($cache != '') {
|
||||
$cache = [0, intval($cache) * 1024];
|
||||
}
|
||||
}
|
||||
return [
|
||||
'cpu_name' => $this->getCpuName(), // CPU 名称
|
||||
'physical_cores' => $this->getCpuPhysicsCores(), // 物理核心数
|
||||
'logical_cores' => $this->getCpuLogicCores(), // 逻辑核心数
|
||||
'cache_size_mb' => $cache[1] ? $cache[1] / 1024 : 0, // 缓存大小(MB)
|
||||
'cpu_usage_percentage' => $cpu, // CPU 使用率(%)
|
||||
'free_cpu_percentage' => round(100 - $cpu, 2), // 可用 CPU 百分比(%)
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'cpu_name' => '获取失败',
|
||||
'physical_cores' => '获取失败',
|
||||
'logical_cores' => '获取失败',
|
||||
'cache_size_mb' => '获取失败',
|
||||
'cpu_usage_percentage' => '获取失败',
|
||||
'free_cpu_percentage' => '获取失败',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内存信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getMemoryInfo(): array
|
||||
{
|
||||
$result = [];
|
||||
if (stristr(PHP_OS, 'WIN')) {
|
||||
// Windows 系统
|
||||
$cap = shell_exec('wmic Path Win32_PhysicalMemory Get Capacity | findstr /V "Capacity"');
|
||||
$caps = array_filter(array_map('trim', explode("\n", $cap)));
|
||||
$result['total_memory'] = round(array_sum(array_map('intval', $caps)) / 1024 / 1024 / 1024, 2); // 总内存(GB)
|
||||
$free = shell_exec('wmic OS get FreePhysicalMemory | findstr /V "FreePhysicalMemory"');
|
||||
$result['available_memory'] = round(intval(trim($free)) / 1024, 2); // 可用内存(MB)
|
||||
$result['used_memory'] = round($result['total_memory'] - $result['available_memory'], 2); // 使用内存(GB)
|
||||
$result['php_memory_usage'] = round(memory_get_usage(true) / 1024 / 1024, 2); // PHP 使用内存(MB)
|
||||
} else {
|
||||
// Linux 系统
|
||||
$memInfo = shell_exec('cat /proc/meminfo');
|
||||
$memInfoArray = array_filter(array_map('trim', explode("\n", $memInfo)));
|
||||
|
||||
$total = $available = 0;
|
||||
|
||||
foreach ($memInfoArray as $line) {
|
||||
if (str_starts_with($line, 'MemTotal:')) {
|
||||
preg_match('/(\d+)/', $line, $matches);
|
||||
$total = intval($matches[1]);
|
||||
}
|
||||
if (str_starts_with($line, 'MemAvailable:')) {
|
||||
preg_match('/(\d+)/', $line, $matches);
|
||||
$available = intval($matches[1]);
|
||||
}
|
||||
}
|
||||
$result['total_memory'] = sprintf('%.2f', $total / 1024 / 1024); // 总内存(GB)
|
||||
$result['available_memory'] = sprintf('%.2f', $available / 1024 / 1024); // 可用内存(GB)
|
||||
$result['used_memory'] = sprintf('%.2f', ($total - $available) / 1024 / 1024); // 使用内存(GB)
|
||||
$result['php_memory_usage'] = round(memory_get_usage(true) / 1024 / 1024, 2); // PHP 使用内存(MB)
|
||||
}
|
||||
// 计算内存使用率
|
||||
$result['memory_usage_rate'] = sprintf('%.2f', ($result['used_memory'] / $result['total_memory']) * 100);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Redis详情
|
||||
*
|
||||
* @return array
|
||||
* @throws \RedisException
|
||||
*/
|
||||
public function getRedisInfo(): array
|
||||
{
|
||||
$config = config('redis.default');
|
||||
$redis = new \Redis();
|
||||
$redis->connect($config['host'], $config['port']);
|
||||
if (!empty($config['password'])) {
|
||||
$redis->auth($config['password']);
|
||||
}
|
||||
$info = $redis->info(); // 获取 Redis 监控信息
|
||||
|
||||
return [
|
||||
'uptime_in_seconds' => $info['uptime_in_seconds'], // 运行时间(秒)
|
||||
'connected_clients' => $info['connected_clients'], // 连接的客户端数量
|
||||
'used_memory' => $this->formatBytes($info['used_memory']), // 使用的内存
|
||||
'memory_fragmentation_ratio' => $info['mem_fragmentation_ratio'], // 内存碎片比率
|
||||
'total_commands_processed' => $info['total_commands_processed'], // 处理的命令总数
|
||||
'total_connections_received' => $info['total_connections_received'], // 接收的连接总数
|
||||
'keyspace_hits' => $info['keyspace_hits'], // 键空间命中次数
|
||||
'keyspace_misses' => $info['keyspace_misses'], // 键空间未命中次数
|
||||
'hit_rate' => $this->calculateHitRate($info), // 命中率
|
||||
'variable' => $info ?? [],
|
||||
'data' => $this->getRedisKeysAndValues($redis),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取PHP环境信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getPhpInfo(): array
|
||||
{
|
||||
return [
|
||||
'php_version' => PHP_VERSION,
|
||||
'os' => PHP_OS,
|
||||
'project_path' => BASE_PATH,
|
||||
'memory_limit' => ini_get('memory_limit'),
|
||||
'max_execution_time' => ini_get('max_execution_time'),
|
||||
'error_reporting' => ini_get('error_reporting'),
|
||||
'display_errors' => ini_get('display_errors'),
|
||||
'upload_max_filesize' => ini_get('upload_max_filesize'),
|
||||
'post_max_size' => ini_get('post_max_size'),
|
||||
'extension_dir' => ini_get('extension_dir'),
|
||||
'loaded_extensions' => implode(', ', get_loaded_extensions()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描redis数据
|
||||
*
|
||||
* @param $redis
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getRedisKeysAndValues($redis): array
|
||||
{
|
||||
|
||||
$keyValues = [];
|
||||
$iterator = null;
|
||||
do {
|
||||
$keys = $redis->scan($iterator);
|
||||
foreach ($keys as $key) {
|
||||
$keyValues[$key] = $redis->get($key);
|
||||
}
|
||||
} while ($iterator > 0);
|
||||
return $keyValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取CPU名称
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getCpuName(): string
|
||||
{
|
||||
if (PHP_OS == 'Linux') {
|
||||
preg_match('/^\s+\d\s+(.+)/', shell_exec('cat /proc/cpuinfo | grep name | cut -f2 -d: | uniq -c') ?? '', $matches);
|
||||
if (count($matches) == 0) {
|
||||
// aarch64 有可能是arm架构
|
||||
$name = trim(shell_exec("lscpu| grep Architecture | awk '{print $2}'") ?? '');
|
||||
if ($name != '') {
|
||||
$mfMhz = trim(shell_exec("lscpu| grep 'MHz' | awk '{print \$NF}' | head -n1") ?? '');
|
||||
$mfGhz = trim(shell_exec("lscpu| grep 'GHz' | awk '{print \$NF}' | head -n1") ?? '');
|
||||
if ($mfMhz == '' && $mfGhz == '') {
|
||||
return $name;
|
||||
} else if ($mfGhz != '') {
|
||||
return $name . ' @ ' . $mfGhz . 'GHz';
|
||||
} else if ($mfMhz != '') {
|
||||
return $name . ' @ ' . round(intval($mfMhz) / 1000, 2) . 'GHz';
|
||||
}
|
||||
} else {
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
return $matches[1] ?? "未知";
|
||||
} else {
|
||||
$name = shell_exec('wmic cpu get Name | findstr /V "Name"');
|
||||
return trim($name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取cpu物理核心数
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getCpuPhysicsCores(): string
|
||||
{
|
||||
if (PHP_OS == 'Linux') {
|
||||
$num = str_replace("\n", '', shell_exec('cat /proc/cpuinfo |grep "physical id"|sort |uniq|wc -l'));
|
||||
return intval($num) == 0 ? '1' : $num;
|
||||
} else {
|
||||
$num = shell_exec('wmic cpu get NumberOfCores | findstr /V "NumberOfCores"');
|
||||
$num = trim($num ?? '1');
|
||||
$nums = explode("\n", $num);
|
||||
$num = 0;
|
||||
foreach ($nums as $n) {
|
||||
$num += intval(trim($n));
|
||||
}
|
||||
return strval($num);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取cpu逻辑核心数
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getCpuLogicCores(): string
|
||||
{
|
||||
if (PHP_OS == 'Linux') {
|
||||
return str_replace("\n", '', shell_exec('cat /proc/cpuinfo |grep "processor"|wc -l'));
|
||||
} else {
|
||||
$num = shell_exec('wmic cpu get NumberOfLogicalProcessors | findstr /V "NumberOfLogicalProcessors"');
|
||||
$num = trim($num ?? '1');
|
||||
$nums = explode("\n", $num);
|
||||
$num = 0;
|
||||
foreach ($nums as $n) {
|
||||
$num += intval(trim($n));
|
||||
}
|
||||
return strval($num);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取CPU使用率
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getCpuUsage(): string
|
||||
{
|
||||
$start = $this->calculationCpu();
|
||||
sleep(1);
|
||||
$end = $this->calculationCpu();
|
||||
|
||||
$totalStart = $start['total'];
|
||||
$totalEnd = $end['total'];
|
||||
|
||||
$timeStart = $start['time'];
|
||||
$timeEnd = $end['time'];
|
||||
|
||||
return sprintf('%.2f', ($timeEnd - $timeStart) / ($totalEnd - $totalStart) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算CPU
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function calculationCpu(): array
|
||||
{
|
||||
$mode = '/(cpu)[\s]+([0-9]+)[\s]+([0-9]+)[\s]+([0-9]+)[\s]+([0-9]+)[\s]+([0-9]+)[\s]+([0-9]+)[\s]+([0-9]+)[\s]+([0-9]+)/';
|
||||
$string = shell_exec('cat /proc/stat | grep cpu');
|
||||
preg_match_all($mode, $string, $matches);
|
||||
|
||||
$total = $matches[2][0] + $matches[3][0] + $matches[4][0] + $matches[5][0] + $matches[6][0] + $matches[7][0] + $matches[8][0] + $matches[9][0];
|
||||
$time = $matches[2][0] + $matches[3][0] + $matches[4][0] + $matches[6][0] + $matches[7][0] + $matches[8][0] + $matches[9][0];
|
||||
|
||||
return ['total' => $total, 'time' => $time];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节可读格式
|
||||
*
|
||||
* @param $bytes
|
||||
* @param $decimals
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function formatBytes($bytes, $decimals = 2): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$factor = floor((strlen($bytes) - 1) / 3);
|
||||
return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . ' ' . @$units[$factor];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算Redis命中率
|
||||
*
|
||||
* @param array $info
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
private function calculateHitRate(array $info): float
|
||||
{
|
||||
if ($info['keyspace_hits'] + $info['keyspace_misses'] == 0) {
|
||||
return 0; // 避免除以零
|
||||
}
|
||||
return sprintf('%.2f', ($info['keyspace_hits'] / ($info['keyspace_hits'] + $info['keyspace_misses'])) * 100); // 命中率(%)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\notify;
|
||||
|
||||
use core\notify\enum\PushClientType;
|
||||
use core\notify\NotificationService;
|
||||
use support\Container;
|
||||
|
||||
|
||||
/**
|
||||
* - 消息通知服务门面类
|
||||
* - 提供静态方法访问通知服务的快捷方式,支持以下功能:
|
||||
* - 消息推送(支持单条/批量)
|
||||
* - 消息记录管理
|
||||
* - 多通道通知分发
|
||||
* @method static array sendAndRecord(PushClientType $clientType, string $businessModule, string $tenantId, string|int|array $userIds, string $event, array $data = [], array $messageData = [], ?string $socketId = null) 推送消息并记录(支持单条/批量)
|
||||
* @method static array batchSend(PushClientType $clientType, string $tenantId, array $messages) 批量推送不同业务消息
|
||||
* @method static array pushOnly(PushClientType $clientType, string $businessModule, string $tenantId, string|int|array $userIds, string $event = 'message', array $data = [], $messages = [], ?string $socketId = null) 仅推送消息(不记录)
|
||||
* @method static array recordOnly(array $userIds, array $messageData) 仅记录消息(不推送)
|
||||
*
|
||||
* @see \core\notify\NotificationService 底层服务实现类
|
||||
*/
|
||||
final class Notification
|
||||
{
|
||||
private static ?NotificationService $instance = null;
|
||||
|
||||
/**
|
||||
* 私有构造方法
|
||||
* 禁止外部实例化,强制使用静态调用方式
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
// 禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通知服务实例(单例模式)
|
||||
*
|
||||
* @return NotificationService 通知服务实例
|
||||
*/
|
||||
private static function instance(): NotificationService
|
||||
{
|
||||
return self::$instance ??= Container::make(NotificationService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态方法调用代理
|
||||
*
|
||||
* @param string $method 调用的方法名
|
||||
* @param array $args 方法参数
|
||||
*
|
||||
* @return mixed 方法调用结果
|
||||
*/
|
||||
public static function __callStatic(string $method, array $args)
|
||||
{
|
||||
return self::instance()->$method(...$args);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\notify;
|
||||
|
||||
use app\model\message\Message;
|
||||
use core\logger\Logger;
|
||||
use core\notify\enum\PushClientType;
|
||||
use core\uuid\UUIDGenerator;
|
||||
use InvalidArgumentException;
|
||||
use madong\helper\Arr;
|
||||
use Webman\Push\Api;
|
||||
|
||||
final class NotificationService
|
||||
{
|
||||
private const DEFAULT_EXPIRE_DAYS = 30;
|
||||
|
||||
protected ?Api $pushApi = null;
|
||||
protected Message $messageModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
$this->pushApi = new Api(
|
||||
$config['api'],
|
||||
$config['app_key'],
|
||||
$config['app_secret']
|
||||
);
|
||||
$this->messageModel = new Message();
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送消息并记录(支持单条/批量)
|
||||
*
|
||||
* @param PushClientType $clientType 客户端类型枚举值
|
||||
* @param string $businessModule 业务模块标识
|
||||
* @param string $tenantId 租户ID
|
||||
* @param string|int|array $userIds 接收者ID(单个ID或ID数组)
|
||||
* @param string $event 事件类型
|
||||
* @param array $data 推送数据(将直接传递给客户端)
|
||||
* @param array $messageData 消息记录数据,包含:
|
||||
* - 'title' (string): 消息标题
|
||||
* - 'content' (string): 消息内容
|
||||
* - 'type' (string): 消息类型(默认为事件类型)
|
||||
* - 'priority' (int): 消息优先级
|
||||
* - 'related_id' (string|null): 关联ID
|
||||
* - 'expired_at' (int|null): 过期时间戳(默认为null,使用默认过期天数)
|
||||
* - 'message_uuid'(string|null):消息uuid 区别
|
||||
* @param string|null $socketId 指定socket连接ID
|
||||
*
|
||||
* @return array 返回结果数组包含:
|
||||
* - 'push_count' (int): 成功推送的频道数量
|
||||
* - 'messages' (array): 创建的消息对象数组(包含完整消息详情)
|
||||
*/
|
||||
public function sendAndRecord(
|
||||
PushClientType $clientType,
|
||||
string $businessModule,
|
||||
string $tenantId,
|
||||
string|int|array $userIds,
|
||||
string $event = 'message',
|
||||
array $data = [],
|
||||
array $messageData = [],
|
||||
?string $socketId = null
|
||||
): array
|
||||
{
|
||||
|
||||
$userIds = Arr::normalize($userIds);
|
||||
$result = [
|
||||
'push_count' => 0,
|
||||
'messages' => [], // 直接返回创建的消息对象
|
||||
];
|
||||
|
||||
try {
|
||||
// 1. 批量创建消息记录
|
||||
// 使用合并后的消息数据创建记录,包含事件类型、租户ID、频道和过期时间
|
||||
$messages = $this->createMessages(
|
||||
$userIds,
|
||||
array_merge($messageData, [
|
||||
'type' => $event,
|
||||
'tenant_id' => $tenantId,
|
||||
'channel' => $this->buildChannel($clientType, $businessModule, $tenantId, null),
|
||||
'expired_at' => $this->calculateExpireTimestamp($messageData['expired_at'] ?? null),
|
||||
]),
|
||||
);
|
||||
|
||||
// 2. 准备推送数据(直接使用已创建的消息对象)
|
||||
// 将消息格式化为适合推送的格式后附加到推送数据中
|
||||
$pushData = array_merge($data, [
|
||||
'messages' => $this->formatMessagesForPush($messages),
|
||||
]);
|
||||
|
||||
// 3. 执行推送
|
||||
$channels = array_map(
|
||||
fn($userId) => $this->buildChannel($clientType, $businessModule, $tenantId, $userId),
|
||||
$userIds
|
||||
);
|
||||
|
||||
// 批量单条消息批量推送异常等官方修复
|
||||
$this->pushApi->trigger($channels, $event, $pushData, $socketId);
|
||||
|
||||
$result['push_count'] = count($channels);
|
||||
$result['messages'] = $messages;
|
||||
|
||||
Logger::debug("推送成功", [
|
||||
'channels' => $channels,
|
||||
'message_count' => count($messages),
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error("推送失败: " . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'user_ids' => $userIds,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量推送不同业务消息(带记录)
|
||||
*
|
||||
* @param PushClientType $clientType 客户端类型枚举值
|
||||
* @param string $tenantId 租户ID
|
||||
* @param array $messages 批量消息数组,每个元素包含以下字段:
|
||||
* - 'module' (string): 业务模块标识
|
||||
* - 'userIds' (string|array): 接收者ID(单个ID或ID数组)
|
||||
* - 'event' (string): 事件类型
|
||||
* - 'data' (array, optional): 推送数据,默认为空数组
|
||||
* - 'title' (string, optional): 消息标题,默认为空字符串
|
||||
* - 'content' (string, optional): 消息内容,默认为空字符串
|
||||
* - 'message_type' (string, optional): 消息类型,默认为事件类型(event)
|
||||
* - 'priority' (int, optional): 消息优先级,默认为0
|
||||
* - 'related_id' (string|null, optional): 关联ID,默认为null
|
||||
* - 'expired_at' (int|null, optional): 过期时间戳,默认为null(使用默认过期天数)
|
||||
* - 'message_uuid'(string|null):消息uuid 区别公共重复推送
|
||||
*
|
||||
* @return array 返回批量推送结果数组,每个元素包含:
|
||||
* - 'push_count' (int): 成功推送数量
|
||||
* - 'message_ids' (array): 创建的消息ID数组
|
||||
* - 'message_details' (array): 完整的消息记录详情
|
||||
*/
|
||||
public function batchSend(
|
||||
PushClientType $clientType,
|
||||
string $tenantId,
|
||||
array $messages
|
||||
): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$results[] = $this->sendAndRecord(
|
||||
$clientType,
|
||||
$msg['module'],
|
||||
$tenantId,
|
||||
$msg['receiver_id'],
|
||||
$msg['event'] ?? 'message',
|
||||
$msg['data'] ?? [],
|
||||
[
|
||||
'title' => $msg['title'] ?? '',
|
||||
'content' => $msg['content'] ?? '',
|
||||
'type' => $msg['message_type'] ?? $msg['event'],
|
||||
'priority' => $msg['priority'] ?? 0,
|
||||
'related_id' => $msg['related_id'] ?? null,
|
||||
'expired_at' => $msg['expired_at'] ?? null,
|
||||
'message_uuid' => $msg['message_uuid'] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅推送消息(不记录)
|
||||
*/
|
||||
public function pushOnly(
|
||||
PushClientType $clientType,
|
||||
string $businessModule,
|
||||
string $tenantId,
|
||||
string|int|array $userIds,
|
||||
string $event = 'message',
|
||||
array $data = [],
|
||||
array $messages = [],
|
||||
?string $socketId = null
|
||||
): int
|
||||
{
|
||||
$userIds = Arr::normalize($userIds);
|
||||
$channels = array_map(
|
||||
fn($userId) => $this->buildChannel($clientType, $businessModule, $tenantId, $userId),
|
||||
$userIds
|
||||
);
|
||||
|
||||
$pushData = array_merge($data, [
|
||||
'messages' => $this->formatMessagesForPush($messages),
|
||||
]);
|
||||
|
||||
return $this->pushApi->trigger($channels, $event, $pushData, $socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅记录消息(不推送)
|
||||
*/
|
||||
public function recordOnly(
|
||||
array $userIds,
|
||||
array $messageData
|
||||
): array
|
||||
{
|
||||
return $this->createMessages(Arr::normalize($userIds), $messageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建消息记录(返回完整消息对象)
|
||||
*/
|
||||
private function createMessages(array $receiverIds, array $data): array
|
||||
{
|
||||
$defaults = [
|
||||
'status' => 'unread',
|
||||
'priority' => 3,
|
||||
'expired_at' => $data['expired_at'] ?? $this->calculateExpireTimestamp(self::DEFAULT_EXPIRE_DAYS),
|
||||
];
|
||||
|
||||
$messages = [];
|
||||
foreach ($receiverIds as $receiverId) {
|
||||
$message = $this->messageModel->create(array_merge(
|
||||
$defaults,
|
||||
$data,
|
||||
['message_uuid' => $this->generateMessageUuid($data['message_uuid'] ?? null)],
|
||||
['receiver_id' => $receiverId]
|
||||
));
|
||||
$messages[] = $message->toArray(); // 直接返回模型数据
|
||||
}
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一的 UUID(如果未提供则自动生成)
|
||||
*
|
||||
* @param string|null $uuid 可选的外部 UUID(如果为 null 则自动生成)
|
||||
*
|
||||
* @return string 有效的 UUID
|
||||
* @throws \Exception 如果 UUID 生成失败
|
||||
*/
|
||||
private function generateMessageUuid(?string $uuid = null): string
|
||||
{
|
||||
if (empty($uuid)) {
|
||||
try {
|
||||
return UUIDGenerator::generate(); // 假设返回字符串 UUID
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception("Failed to generate UUID: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算过期时间戳
|
||||
*/
|
||||
private function calculateExpireTimestamp(int|null $expireDays): int
|
||||
{
|
||||
if ($expireDays === null) {
|
||||
$expireDays = self::DEFAULT_EXPIRE_DAYS;
|
||||
}
|
||||
return time() + ($expireDays * 86400); // 86400秒=1天
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息数据用于推送
|
||||
*/
|
||||
private function formatMessagesForPush(array $messages): array
|
||||
{
|
||||
return array_map(function ($message) {
|
||||
return $message;
|
||||
}, $messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标准化频道名称
|
||||
*/
|
||||
public function buildChannel(
|
||||
PushClientType $clientType,
|
||||
string $businessModule,
|
||||
string $tenantId,
|
||||
?string $userId = null
|
||||
): string
|
||||
{
|
||||
// 业务模块校验
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $businessModule)) {
|
||||
throw new InvalidArgumentException('业务模块只能包含小写字母、数字和下划线');
|
||||
}
|
||||
|
||||
return implode('-', [
|
||||
$clientType->value,
|
||||
$businessModule,
|
||||
$tenantId,
|
||||
$userId ?: '*',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getConfig(): array
|
||||
{
|
||||
$config = config('core.notify.app.webman-push');
|
||||
if (empty($config)) {
|
||||
throw new \RuntimeException('消息推送配置未定义');
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
'webman-push' => [
|
||||
'websocket' => config('plugin.webman.push.app.websocket'),
|
||||
'api' => config('plugin.webman.push.app.api'),
|
||||
'app_key' => config('plugin.webman.push.app.app_key'),
|
||||
'app_secret' => config('plugin.webman.push.app.app_secret'),
|
||||
'channel_hook' => config('plugin.webman.push.app.channel_hook'),
|
||||
'auth' => config('plugin.webman.push.app.auth'),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: https://madong.tech
|
||||
*/
|
||||
|
||||
namespace core\notify\enum;
|
||||
|
||||
use core\interface\IEnum;
|
||||
|
||||
/**
|
||||
* 推送客户端枚举
|
||||
*
|
||||
* @author Mr.April
|
||||
* @since 1.0
|
||||
*/
|
||||
enum PushClientType: string implements IEnum
|
||||
{
|
||||
|
||||
case BACKEND = 'backend'; // 管理后台
|
||||
case APP = 'app'; // 移动应用
|
||||
case MP = 'mp'; // 小程序
|
||||
case WEB = 'web'; // 网页端
|
||||
|
||||
/**
|
||||
* 获取人类识别的标签
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::BACKEND => '管理后台',
|
||||
self::APP => '移动应用',
|
||||
self::MP => '小程序',
|
||||
self::WEB => '网页端',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有枚举值
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
/**
|
||||
* 插件安装基类
|
||||
* 使用 Trait 实现职责分离:
|
||||
* - ConfigTrait: 配置管理
|
||||
* - MigrationTrait: 迁移操作
|
||||
* - SeedTrait: 种子操作
|
||||
* - MenuTrait: 菜单操作
|
||||
* - TemplateTrait: 模板资源操作
|
||||
* - DependencyTrait: 依赖安装
|
||||
* 使用方式:
|
||||
* 1. 在插件目录创建 Install.php
|
||||
* 2. 继承此类即可,迁移和种子会自动执行
|
||||
* 3. 可重写 install/uninstall/update 方法添加自定义逻辑
|
||||
* 配置覆盖:
|
||||
* - 默认配置: core/plugin/config/default.php
|
||||
* - 应用配置: core/plugin/config/app.php
|
||||
* - 插件配置: plugin/{name}/config/info.php (可覆盖上述配置)
|
||||
*/
|
||||
|
||||
namespace core\plugin;
|
||||
|
||||
use core\plugin\traits\ConfigTrait;
|
||||
use core\plugin\traits\MigrationTrait;
|
||||
use core\plugin\traits\SeedTrait;
|
||||
use core\plugin\traits\MenuTrait;
|
||||
use core\plugin\traits\TemplateTrait;
|
||||
use core\plugin\traits\DependencyTrait;
|
||||
|
||||
class PluginInstall
|
||||
{
|
||||
use ConfigTrait;
|
||||
use MigrationTrait;
|
||||
use SeedTrait;
|
||||
use MenuTrait;
|
||||
use TemplateTrait;
|
||||
use DependencyTrait;
|
||||
|
||||
/** @var string 插件名称 */
|
||||
protected string $pluginName = '';
|
||||
|
||||
/** @var string 插件路径 */
|
||||
protected string $pluginPath = '';
|
||||
|
||||
/** @var string|null 连接名(默认使用框架配置) */
|
||||
protected ?string $connection = null;
|
||||
|
||||
/** @var array 插件配置 */
|
||||
protected array $appConfig = [];
|
||||
|
||||
/** @var callable|null 进度回调函数(用于在线模式通过 SSE 返回进度) */
|
||||
protected $progressCallback = null;
|
||||
|
||||
/** @var bool 是否为在线模式(非 CLI) */
|
||||
protected bool $isOnlineMode = false;
|
||||
|
||||
/** @var bool 卸载阶段是否保留已复制的模板资源 */
|
||||
protected bool $preserveTemplatesDuringUninstall = false;
|
||||
|
||||
/**
|
||||
* @param callable|null $progressCallback 进度回调
|
||||
* @param bool $isOnlineMode 是否为在线模式
|
||||
*/
|
||||
public function __construct(?callable $progressCallback = null, bool $isOnlineMode = false)
|
||||
{
|
||||
$this->progressCallback = $progressCallback;
|
||||
$this->isOnlineMode = $isOnlineMode;
|
||||
$this->init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置进度回调
|
||||
*/
|
||||
public function setProgressCallback(?callable $callback): self
|
||||
{
|
||||
$this->progressCallback = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否为在线模式
|
||||
*/
|
||||
public function setOnlineMode(bool $isOnlineMode): self
|
||||
{
|
||||
$this->isOnlineMode = $isOnlineMode;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出日志(CLI 模式 echo,在线模式通过回调)
|
||||
*/
|
||||
protected function log(string $message, ?int $progress = null): void
|
||||
{
|
||||
if ($this->isOnlineMode && $this->progressCallback) {
|
||||
// 在线模式:通过回调返回进度
|
||||
($this->progressCallback)($message, $progress);
|
||||
} else {
|
||||
// CLI 模式:直接输出
|
||||
echo $message . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出进度
|
||||
*/
|
||||
protected function progress(string $message, int $progress): void
|
||||
{
|
||||
$this->log($message, $progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出消息(用于 Trait 中的 echo 替换)
|
||||
* CLI 模式直接输出,在线模式输出到缓冲区
|
||||
*/
|
||||
protected function output(string $message): void
|
||||
{
|
||||
echo $message . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
protected function init(): void
|
||||
{
|
||||
$this->pluginPath = $this->getPluginPath();
|
||||
$this->pluginName = basename($this->pluginPath);
|
||||
$this->appConfig = $this->getAppConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件路径 - 子类可重写
|
||||
*/
|
||||
protected function getPluginPath(): string
|
||||
{
|
||||
$class = static::class;
|
||||
$parts = explode('\\', $class);
|
||||
if (count($parts) >= 2 && $parts[0] === 'plugin') {
|
||||
return base_path('plugin/' . $parts[1]);
|
||||
}
|
||||
return base_path('plugin');
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件名称
|
||||
*/
|
||||
protected function getPluginName(): string
|
||||
{
|
||||
return $this->pluginName ?: basename($this->getPluginPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置数据库连接
|
||||
*/
|
||||
public function setConnection(?string $connection): self
|
||||
{
|
||||
$this->connection = $connection;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制重新运行迁移
|
||||
*/
|
||||
public function forceMigrate(): void
|
||||
{
|
||||
$this->output("⚠️ Force migration - clearing migration logs...");
|
||||
$this->clearLogs();
|
||||
$this->runMigrations();
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装
|
||||
*/
|
||||
public function install($version): void
|
||||
{
|
||||
$this->log("====== Plugin Install: {$this->getPluginName()} ======", 0);
|
||||
$this->log("Version: {$version}");
|
||||
$this->log("Path: {$this->pluginPath}");
|
||||
|
||||
// 安装前回调
|
||||
$this->beforeInstall($version);
|
||||
|
||||
// 安装 Composer 依赖(后端)
|
||||
$this->installComposerDeps();
|
||||
|
||||
// 运行迁移(后端)
|
||||
$this->runMigrations();
|
||||
|
||||
// 运行种子(后端)
|
||||
$this->runSeeds();
|
||||
|
||||
// 安装前端依赖
|
||||
$this->installNpmDeps();
|
||||
|
||||
// 复制前端模板到各端
|
||||
$this->copyTemplates();
|
||||
|
||||
// 执行各端安装命令
|
||||
$this->runInstallCommands();
|
||||
|
||||
// 安装后回调 - 可重写用于导入菜单等操作
|
||||
$this->afterInstall($version);
|
||||
|
||||
// 保存插件安装状态
|
||||
$this->savePluginConfig($version);
|
||||
|
||||
$this->log("====== Install Completed ======", 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装前回调 - 可重写
|
||||
*/
|
||||
protected function beforeInstall(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running beforeInstall...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装后回调 - 可重写
|
||||
*/
|
||||
protected function afterInstall(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running afterInstall...");
|
||||
|
||||
// 加载插件菜单
|
||||
$this->loadMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载前回调 - 可重写
|
||||
*/
|
||||
protected function beforeUninstall(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running beforeUninstall...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载后回调 - 清理菜单
|
||||
*/
|
||||
protected function afterUninstall(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running afterUninstall...");
|
||||
|
||||
// 卸载菜单
|
||||
$this->uninstallPluginMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载
|
||||
*/
|
||||
public function uninstall($version): void
|
||||
{
|
||||
$this->log("====== Plugin Uninstall: {$this->getPluginName()} ======", 0);
|
||||
|
||||
$this->preserveTemplatesDuringUninstall = true;
|
||||
try {
|
||||
// 卸载前回调和卸载后回调均不得物理删除模板资源。
|
||||
$this->beforeUninstall($version);
|
||||
|
||||
// 获取卸载配置
|
||||
$pluginInfo = $this->getPluginInfo();
|
||||
$uninstallConfig = $pluginInfo['uninstall'] ?? [];
|
||||
$dropTables = $uninstallConfig['drop_tables'] ?? false;
|
||||
|
||||
$this->log("📋 Uninstall config: drop_tables=" . ($dropTables ? 'true' : 'false'));
|
||||
|
||||
// 回滚迁移(可选删除表)
|
||||
$this->rollbackMigrations($dropTables);
|
||||
|
||||
// 清理日志
|
||||
$this->clearLogs();
|
||||
|
||||
$this->afterUninstall($version);
|
||||
} finally {
|
||||
$this->preserveTemplatesDuringUninstall = false;
|
||||
}
|
||||
|
||||
$this->log('📦 Plugin package, templates and dependencies are preserved until deletion; only the installation marker is removed.');
|
||||
|
||||
$this->log("====== Uninstall Completed ======", 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件
|
||||
*/
|
||||
public function delete($version): void
|
||||
{
|
||||
$this->log("====== Plugin Delete: {$this->getPluginName()} ======", 10);
|
||||
|
||||
// 获取插件配置
|
||||
$pluginInfo = $this->getPluginInfo();
|
||||
$uninstallConfig = $pluginInfo['uninstall'] ?? [];
|
||||
$undeletable = $uninstallConfig['undeletable'] ?? false;
|
||||
|
||||
$this->log("📋 Plugin config: undeletable=" . ($undeletable ? 'true' : 'false'), 20);
|
||||
|
||||
// 检查是否允许删除
|
||||
if ($undeletable) {
|
||||
throw new \Exception('系统内置插件不允许删除');
|
||||
}
|
||||
|
||||
// 删除前回调
|
||||
$this->beforeDelete($version);
|
||||
|
||||
// 删除安装时复制到前端项目的模板资源
|
||||
$this->deleteTemplates();
|
||||
|
||||
// 按插件配置移除安装时合并的依赖
|
||||
$globalUninstallConfig = $this->getConfig('uninstall', []);
|
||||
$removeDependencies = $uninstallConfig['remove_dependencies'] ?? $globalUninstallConfig['remove_dependencies'] ?? false;
|
||||
if ($removeDependencies) {
|
||||
$this->removeMergedDependencies();
|
||||
}
|
||||
|
||||
// 获取插件目录
|
||||
$pluginDir = $this->pluginPath;
|
||||
|
||||
if (!is_dir($pluginDir)) {
|
||||
throw new \Exception('插件目录不存在:' . $pluginDir);
|
||||
}
|
||||
|
||||
$this->log("📁 Deleting plugin directory: {$pluginDir}", 40);
|
||||
|
||||
// 使用递归删除整个插件目录
|
||||
$this->removeDirectory($pluginDir);
|
||||
|
||||
$this->log("✅ Plugin deleted successfully", 100);
|
||||
|
||||
// 删除后回调
|
||||
$this->afterDelete($version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前回调 - 可重写
|
||||
*/
|
||||
protected function beforeDelete(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running beforeDelete...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除后回调 - 可重写
|
||||
*/
|
||||
protected function afterDelete(string $version): void
|
||||
{
|
||||
$this->log(" 🔄 Running afterDelete...");
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录
|
||||
*/
|
||||
protected function removeDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = scandir($directory);
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->removeDirectory($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
@rmdir($directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板资源 - 仅应在删除插件或升级清理阶段调用
|
||||
*/
|
||||
protected function deletePluginTemplates(): void
|
||||
{
|
||||
$this->deleteTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新
|
||||
*/
|
||||
public function update($version): void
|
||||
{
|
||||
$this->output("====== Plugin Update: {$this->getPluginName()} ======");
|
||||
|
||||
// 先卸载
|
||||
$this->uninstall($version);
|
||||
|
||||
// 升级前清理旧模板,避免已删除的旧文件残留
|
||||
$this->deleteTemplates();
|
||||
|
||||
// 再安装
|
||||
$this->install($version);
|
||||
|
||||
$this->output("====== Update Completed ======");
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 端安装命令执行
|
||||
// =========================================================
|
||||
|
||||
/**
|
||||
* 执行各端安装命令
|
||||
*/
|
||||
protected function runInstallCommands(): void
|
||||
{
|
||||
$this->output("📦 Running install commands...");
|
||||
|
||||
$projectRoot = $this->getProjectRoot();
|
||||
|
||||
// 后端安装命令
|
||||
$this->runBackendCommands($projectRoot);
|
||||
|
||||
// 前端安装命令
|
||||
$this->runFrontendCommands($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行后端安装命令
|
||||
*/
|
||||
protected function runBackendCommands(string $projectRoot): void
|
||||
{
|
||||
// 可在子类中重写实现自定义命令
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行前端安装命令
|
||||
*/
|
||||
protected function runFrontendCommands(string $projectRoot): void
|
||||
{
|
||||
// 可在子类中重写实现自定义命令
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 插件迁移基类
|
||||
*
|
||||
* 迁移文件写法(匿名类方式):
|
||||
*
|
||||
* <?php
|
||||
* use Illuminate\Database\Schema\Builder;
|
||||
* use Illuminate\Database\Schema\Blueprint;
|
||||
*
|
||||
* return new class {
|
||||
* public function up(Builder $schema): void
|
||||
* {
|
||||
* $schema->create('official_app', function (Blueprint $table) {
|
||||
* $table->bigInteger('id')->primary();
|
||||
* $table->string('name', 50);
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* public function down(Builder $schema): void
|
||||
* {
|
||||
* $schema->dropIfExists('official_app');
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
|
||||
namespace core\plugin;
|
||||
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
abstract class PluginMigrate
|
||||
{
|
||||
/**
|
||||
* 迁移向上执行(安装)
|
||||
*/
|
||||
abstract public function up(Builder $schema): void;
|
||||
|
||||
/**
|
||||
* 迁移向下执行(回滚)
|
||||
*/
|
||||
public function down(Builder $schema): void
|
||||
{
|
||||
// 可选实现
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
/**
|
||||
* 插件默认配置
|
||||
* 此文件的配置可被插件的 config/info.php 覆盖
|
||||
* 覆盖规则:info.php 中的配置会合并到此文件的配置中
|
||||
*/
|
||||
|
||||
return [
|
||||
'enable' => true,
|
||||
/**
|
||||
* 安装配置
|
||||
*/
|
||||
'install' => [
|
||||
// 是否自动创建日志目录
|
||||
'auto_mkdir_logs' => true,
|
||||
],
|
||||
|
||||
/**
|
||||
* 卸载配置
|
||||
*/
|
||||
'uninstall' => [
|
||||
// 卸载时是否删除数据表(默认 false)
|
||||
'drop_tables' => false,
|
||||
// 卸载时是否移除合并的依赖(默认 false)
|
||||
'remove_dependencies' => false,
|
||||
],
|
||||
|
||||
/**
|
||||
* 模板资源配置
|
||||
* 插件模板复制目标位置(相对于项目根目录,即 server 的兄弟节点)
|
||||
* 结构: ['端' => '相对路径']
|
||||
*/
|
||||
'template' => [
|
||||
// 后台模板目标路径(相对于项目根目录)
|
||||
'admin' => 'frontend/admin/src/apps',
|
||||
// 前台模板目标路径
|
||||
'web' => 'frontend/web/app/apps',
|
||||
],
|
||||
|
||||
/**
|
||||
* 资源目录配置
|
||||
* 插件资源目录名称
|
||||
*/
|
||||
'resource' => [
|
||||
// 数据库迁移目录
|
||||
'migration' => 'database/migrations',
|
||||
// 数据库种子目录
|
||||
'seed' => 'database/seeds',
|
||||
// 菜单资源目录
|
||||
'menu' => 'menu',
|
||||
// 模板资源目录
|
||||
'template' => 'template',
|
||||
],
|
||||
|
||||
/**
|
||||
* Logo 配置
|
||||
*/
|
||||
'logo' => [
|
||||
// Logo 存储目录(相对于项目根目录)
|
||||
'path' => 'resource/images/plugin/logo',
|
||||
// 默认 logo 文件名
|
||||
'default' => 'default.png',
|
||||
// 支持的扩展名
|
||||
'ext' => ['png', 'jpg', 'jpeg', 'svg', 'webp'],
|
||||
],
|
||||
|
||||
/**
|
||||
* 依赖合并配置
|
||||
*/
|
||||
'dependencies' => [
|
||||
// 后台依赖配置 (admin/package.json)
|
||||
'admin' => [
|
||||
'enabled' => true,
|
||||
'merge_dev' => true, // 合并 devDependencies
|
||||
'merge_prod' => true, // 合并 dependencies
|
||||
],
|
||||
// 前台依赖配置 (web/package.json)
|
||||
'web' => [
|
||||
'enabled' => true,
|
||||
'merge_dev' => true,
|
||||
'merge_prod' => true,
|
||||
],
|
||||
// 后端依赖配置 (server/composer.json)
|
||||
'backend' => [
|
||||
'enabled' => true,
|
||||
'merge_require' => true, // 合并 require
|
||||
'merge_require_dev' => true, // 合并 require-dev
|
||||
],
|
||||
],
|
||||
|
||||
/**
|
||||
* 数据库配置
|
||||
*/
|
||||
'database' => [
|
||||
// 连接名(默认使用框架配置)
|
||||
'connection' => null,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace core\plugin\traits;
|
||||
|
||||
/**
|
||||
* 配置管理 Trait
|
||||
* 实现配置的获取、合并和覆盖机制
|
||||
*/
|
||||
trait ConfigTrait
|
||||
{
|
||||
/**
|
||||
* 获取应用配置
|
||||
*/
|
||||
protected function getAppConfig(): array
|
||||
{
|
||||
static $config = null;
|
||||
|
||||
if ($config === null) {
|
||||
// Trait 中 __DIR__ 是 core/plugin/traits,需要两级目录才能到达 core/plugin
|
||||
$configFile = dirname(dirname(__DIR__)) . '/plugin/config/app.php';
|
||||
$config = file_exists($configFile) ? include $configFile : [];
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置项(合并 appConfig + info.php 配置)
|
||||
* info.php 中的配置优先级更高
|
||||
*/
|
||||
protected function getConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
// 先从 appConfig 获取
|
||||
$appConfigValue = $this->getAppConfigValue($key);
|
||||
|
||||
// 再从插件 info.php 获取(覆盖 appConfig)
|
||||
$pluginConfigValue = $this->getPluginConfigValue($key);
|
||||
|
||||
// 插件配置优先
|
||||
if ($pluginConfigValue !== null) {
|
||||
return $pluginConfigValue;
|
||||
}
|
||||
|
||||
return $appConfigValue !== null ? $appConfigValue : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 appConfig 获取配置值
|
||||
*/
|
||||
private function getAppConfigValue(string $key): mixed
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->appConfig;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($value[$k])) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从插件 info.php 获取配置值
|
||||
*/
|
||||
private function getPluginConfigValue(string $key): mixed
|
||||
{
|
||||
static $pluginInfo = null;
|
||||
|
||||
if ($pluginInfo === null) {
|
||||
$infoFile = $this->getPluginInfoFile();
|
||||
$pluginInfo = file_exists($infoFile) ? include $infoFile : [];
|
||||
}
|
||||
|
||||
if (empty($pluginInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$keys = explode('.', $key);
|
||||
$value = $pluginInfo;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($value[$k])) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件信息配置文件路径
|
||||
*/
|
||||
protected function getPluginInfoFile(): string
|
||||
{
|
||||
return $this->pluginPath . '/config/info.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件信息(包含隐式默认配置)
|
||||
* info.php 中的配置会覆盖默认配置
|
||||
*/
|
||||
protected function getPluginInfo(): array
|
||||
{
|
||||
// 先获取显式配置(info.php)
|
||||
$infoFile = $this->getPluginInfoFile();
|
||||
$explicitConfig = [];
|
||||
|
||||
if (file_exists($infoFile)) {
|
||||
$explicitConfig = include $infoFile;
|
||||
$explicitConfig = is_array($explicitConfig) ? $explicitConfig : [];
|
||||
}
|
||||
|
||||
// 合并隐式默认配置
|
||||
return array_merge($this->getImplicitConfig(), $explicitConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取隐式默认配置
|
||||
*/
|
||||
protected function getImplicitConfig(): array
|
||||
{
|
||||
return [
|
||||
'dependencies' => [],
|
||||
'require' => [
|
||||
'composer' => [],
|
||||
],
|
||||
'npm_require' => [
|
||||
'admin' => [],
|
||||
'web' => [],
|
||||
],
|
||||
'supports' => [
|
||||
'backend' => true,
|
||||
'frontend' => true,
|
||||
'admin' => true,
|
||||
'web' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置文件路径
|
||||
*/
|
||||
protected function getPluginConfigFile(): string
|
||||
{
|
||||
return $this->pluginPath . '/config/installed.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存插件安装状态
|
||||
*/
|
||||
protected function savePluginConfig(string $version): void
|
||||
{
|
||||
$configFile = $this->getPluginConfigFile();
|
||||
$configDir = dirname($configFile);
|
||||
|
||||
if (!is_dir($configDir)) {
|
||||
mkdir($configDir, 0755, true);
|
||||
}
|
||||
|
||||
$existingConfig = [];
|
||||
if (file_exists($configFile)) {
|
||||
$existingConfig = include $configFile;
|
||||
$existingConfig = is_array($existingConfig) ? $existingConfig : [];
|
||||
}
|
||||
|
||||
$config = array_merge($existingConfig, [
|
||||
'name' => $this->getPluginName(),
|
||||
'version' => $version,
|
||||
'installed_at' => date('Y-m-d H:i:s'),
|
||||
'status' => 'installed',
|
||||
]);
|
||||
|
||||
$content = "<?php\n\n/**\n * 插件安装状态配置\n * 此文件由系统自动生成,请勿手动修改\n */\n\nreturn " . var_export($config, true) . ";\n";
|
||||
|
||||
file_put_contents($configFile, $content);
|
||||
|
||||
$this->output("📝 Plugin config saved: {$configFile}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件安装状态
|
||||
*/
|
||||
protected function getPluginConfig(): ?array
|
||||
{
|
||||
$configFile = $this->getPluginConfigFile();
|
||||
|
||||
if (!file_exists($configFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
return is_array($config) ? $config : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查插件是否已安装
|
||||
*/
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
$config = $this->getPluginConfig();
|
||||
return $config !== null && ($config['status'] ?? '') === 'installed';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件版本
|
||||
*/
|
||||
public function getVersion(): ?string
|
||||
{
|
||||
$config = $this->getPluginConfig();
|
||||
return $config['version'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件配置文件
|
||||
*/
|
||||
protected function removePluginConfig(): void
|
||||
{
|
||||
$configFile = $this->getPluginConfigFile();
|
||||
|
||||
if (file_exists($configFile)) {
|
||||
unlink($configFile);
|
||||
$this->output("📝 Plugin config removed: {$configFile}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user