初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
+44
View File
@@ -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
+293
View File
@@ -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(),
];
}
}
+20
View File
@@ -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
{
}
+108
View File
@@ -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);
}
}
+316
View File
@@ -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 [];
}
}
+147
View File
@@ -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();
}
}
+211
View File
@@ -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;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
return [
'enable' => true,
'captcha' => [
'mode' => 'session',//session||redis
'expire' => 300,//验证码过期时间
'default' => [
],
],
];
+527
View File
@@ -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;
}
}
+83
View File
@@ -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());// 发送失败,返回错误信息
}
}
}
+288
View File
@@ -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(),
];
}
}
+187
View File
@@ -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));
}
}
+117
View File
@@ -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 '开发环境';
}
}
+40
View File
@@ -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;
}
+698
View File
@@ -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;
}
}
+690
View File
@@ -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']));
}
}
}
}
+28
View File
@@ -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);
}
}
+18
View File
@@ -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;
}
+855
View File
@@ -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 [];
}
}
}
+225
View File
@@ -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();
}
}
+84
View File
@@ -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', // 受众
],
];
+53
View File
@@ -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;
}
}
+33
View File
@@ -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;
}
}
+23
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
}
+15
View File
@@ -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;
}
}
+58
View File
@@ -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());
}
}
}
+292
View File
@@ -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;
}
}
}
+39
View File
@@ -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
],
];
+119
View File
@@ -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;
}
}
+388
View File
@@ -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); // 命中率(%)
}
}
+72
View File
@@ -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);
}
}
+321
View File
@@ -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;
}
}
+23
View File
@@ -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');
}
}
+449
View File
@@ -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
{
// 可在子类中重写实现自定义命令
}
}
+47
View File
@@ -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
{
// 可选实现
}
}
+109
View File
@@ -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,
],
];
+229
View File
@@ -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