first commit

This commit is contained in:
liu
2026-07-13 15:23:29 +08:00
commit 50885a98c8
473 changed files with 33772 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\AnnoRoute;
interface AnnoRoute
{
/**
* register route
*
* @param string $path
* @return void
*/
public function register(string $path): void;
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace Modules\AnnoRoute;
use Symfony\Component\Finder\Finder;
class AnnoRouteService implements AnnoRoute
{
/**
* register route
* @param string|array $path
* @return void
*/
public function register($path): void
{
$paths = is_array($path) ? $path : [$path];
foreach ($paths as $p) {
$this->registerFromPath($p);
}
}
/**
* 从指定路径注册路由
*/
private function registerFromPath(string $path): void
{
if (!is_dir($path)) {
return;
}
$finder = new Finder();
$finder->files()
->in($path)
->name('*Controller.php');
foreach ($finder as $controller) {
$className = $this->getClassNameFromFile(
$controller->getRealPath(),
$controller->getPath()
);
if ($className && class_exists($className)) {
RouteRegisterService::register($className);
}
}
}
/**
* 从文件路径和所在目录解析类名
*/
private function getClassNameFromFile(string $filePath, string $fileDir): ?string
{
// 读取文件内容获取命名空间
$content = file_get_contents($filePath);
if (!preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatch)) {
// 没有命名空间,尝试使用 PSR-0 规则
return $this->guessClassNameFromPath($filePath, $fileDir);
}
$namespace = trim($namespaceMatch[1]);
$className = basename($filePath, '.php');
return $namespace . '\\' . $className;
}
/**
* 当文件没有命名空间时,通过路径猜测类名
*/
private function guessClassNameFromPath(string $filePath, string $fileDir): ?string
{
// 获取相对于项目根目录的路径
$basePath = base_path();
$relativePath = str_replace($basePath, '', $fileDir);
// 将路径转换为命名空间
$namespace = str_replace('/', '\\', ltrim($relativePath, '/'));
// 移除开头的反斜杠并转换路径分隔符
$namespace = trim($namespace, '\\');
$className = basename($filePath, '.php');
// 如果命名空间为空,直接返回类名
if (empty($namespace)) {
return $className;
}
return $namespace . '\\' . $className;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\AnnoRoute\Attribute;
use Attribute;
use Modules\AnnoRoute\BaseAttribute;
#[Attribute(Attribute::TARGET_METHOD)]
class DeleteRoute extends BaseAttribute
{
/** @var string 请求方法 */
public string $httpMethod = 'DELETE';
/**
* @param string $route 路由地址
* @param string|bool $authorize 权限字段
* @param string|array $middleware 中间件
* @param array $where 路由参数约束
*/
public function __construct(
public string $route = '',
public string|bool $authorize = true,
public string | array $middleware = '',
public array $where = [],
)
{
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\AnnoRoute\Attribute;
use Attribute;
use Modules\AnnoRoute\BaseAttribute;
#[Attribute(Attribute::TARGET_METHOD)]
class GetRoute extends BaseAttribute
{
/** @var string 请求方法 */
public string $httpMethod = 'GET';
/**
* @param string $route 路由地址
* @param string|bool $authorize 权限字段
* @param string|array $middleware 中间件
* @param array $where 路由参数约束
*/
public function __construct(
public string $route = '',
public string|bool $authorize = true,
public string | array $middleware = '',
public array $where = [],
)
{
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\AnnoRoute\Attribute;
use Attribute;
use Modules\AnnoRoute\BaseAttribute;
#[Attribute(Attribute::TARGET_METHOD)]
class PostRoute extends BaseAttribute
{
/** @var string 请求方法 */
public string $httpMethod = 'POST';
/**
* @param string $route 路由地址
* @param string|bool $authorize 权限字段
* @param string|array $middleware 中间件
* @param array $where 路由参数约束
*/
public function __construct(
public string $route = '',
public string|bool $authorize = true,
public string | array $middleware = '',
public array $where = [],
)
{
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\AnnoRoute\Attribute;
use Attribute;
use Modules\AnnoRoute\BaseAttribute;
#[Attribute(Attribute::TARGET_METHOD)]
class PutRoute extends BaseAttribute
{
/** @var string 请求方法 */
public string $httpMethod = 'PUT';
/**
* @param string $route 路由地址
* @param string|bool $authorize 权限字段
* @param string|array $middleware 中间件
* @param array $where 路由参数约束
*/
public function __construct(
public string $route = '',
public string|bool $authorize = true,
public string | array $middleware = '',
public array $where = [],
)
{
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\AnnoRoute\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class RequestAttribute
{
/**
* @param string $routePrefix 路由前缀
* @param string $abilitiesPrefix 权限前缀
* @param string | array $middleware 控制器中间件
* @param string | null $authGuard 用户提供程序
*/
public function __construct(
public string $routePrefix = '',
public string $abilitiesPrefix = '',
public string | array $middleware = '',
public ?string $authGuard = null
)
{
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Modules\AnnoRoute;
use Illuminate\Http\JsonResponse;
use Modules\Common\Enum\ShowType as ShopTypeEnum;
abstract class BaseAttribute
{
/**
* 路由地址,使用该属性来指定路由地址,完整的路由地址由 RequestAttribute 的 routePrefix 和 route 拼接而成,例如:
* RequestAttribute 的 routePrefix 为 /admin/userMapping 的 route 为 /create,那么完整的路由地址为 /admin/user/create
*
* Routing address, use this attribute to specify the routing address. The complete routing address is composed
* of the routePrefix of RequestAttribute and the route concatenated, for example: If the routePrefix of RequestAttribute
* is /admin/user and the route of Mapping is /create, then the complete routing address is /admin/user/create
*
* @var string
*/
public string $route = '';
/**
* 路由中间件,该属性用于指定路由中间件,多个中间件以数组形式传递,例如:['auth', 'admin']
*
* Route middleware, this attribute is used to specify the route middleware, multiple middleware are passed in
* as an array, for example: ['auth', 'admin']
*
* @var string|array
*/
public string | array $middleware = '';
/**
* HTTP 的请求方法
*
* HTTP request method
*
* @var string
*/
public string $httpMethod = 'POST';
/**
* 权限字符串,该属性用于指定权限【能力】字符串,用于权限【能力】认证,完整的能力字符串由 RequestAttribute 的 abilitiesPrefix 和 authorize
* 拼接而成,例如:RequestAttribute 的 abilitiesPrefix 为 adminMapping 的 authorize 为 user,那么完整的权限【能力】字符串为 admin.user
*
* Permission string, this attribute is used to specify the permission string, used for permission authentication,
* the complete permission string is composed of the abilitiesPrefix of RequestAttribute and the authorize concatenated,
* for example: If the abilitiesPrefix of RequestAttribute is admin and the authorize of Mapping is user, then the complete
* permission string is admin.user
*
* @var string | bool
*/
public string | bool $authorize = true;
/**
* 路由参数约束,该属性用于指定路由参数的正则约束,例如:['id' => '[0-9]+']
*
* Route parameter constraints, this attribute is used to specify regex constraints for route parameters,
* for example: ['id' => '[0-9]+']
*
* @var array
*/
public array $where = [];
/**
* 成功响应
* @param $msg array|string
* @return JsonResponse
*/
protected static function success(array|string $msg = ''): JsonResponse
{
if (is_array($msg)) {
$data = $msg;
$msg = '';
} else {
$data = [];
}
return response()->json([
'success' => true,
'data' => $data,
'showType' => ShopTypeEnum::SUCCESS_MESSAGE->value,
'msg' => $msg,
]);
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace Modules\AnnoRoute;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use ReflectionClass;
use ReflectionException;
class RouteRegisterService
{
/**
* @var array|string[]
*/
private static array $mapping = [
GetRoute::class,
PostRoute::class,
PutRoute::class,
DeleteRoute::class
];
/**
* register 注册路由
*/
public static function register(string $className): void
{
try {
$classRef = new ReflectionClass($className);
$classAttr = collect($classRef->getAttributes());
if($classAttr->isEmpty()) return;
$classAttrName = $classAttr->map->getName();
if(! $classAttrName->contains(RequestAttribute::class)) {
return;
}
$requestMapping = $classAttr->first(fn ($item) => $item->getName() == RequestAttribute::class);
$routeInstance = $requestMapping->newInstance();
// 默认参数
$routePrefix = $routeInstance->routePrefix ?? '';
$authGuard = $routeInstance->authGuard ?? null;
$abilitiesPrefix = $routeInstance->abilitiesPrefix ?? '';
$middleware = self::registerMiddleware($routeInstance->middleware);
$methods = $classRef->getMethods();
foreach ($methods as $method) {
// 方法注解
$attributes = $method->getAttributes();
if(empty($attributes)) {
continue;
}
$methodName = $method->getName();
foreach ($attributes as $attribute) {
if (in_array($attribute->getName(), self::$mapping)) {
$instance = $attribute->newInstance();
self::registerRoute(
$instance,
$methodName,
$className,
$authGuard,
$middleware,
$routePrefix,
$abilitiesPrefix,
);
}
}
}
} catch (ReflectionException $e) {
echo $e->getMessage();
}
}
/**
* 注册路由
* @param BaseAttribute $instance
* @param string $method
* @param string $className
* @param string|null $authGuard
* @param array $middleware
* @param string $routePrefix
* @param string $abilitiesPrefix
* @return void
*/
private static function registerRoute(
BaseAttribute $instance,
string $method,
string $className,
string $authGuard = null,
array $middleware = [],
string $routePrefix = '',
string $abilitiesPrefix = ''
): void
{
$authorize = $instance->authorize;
$authMiddleware = [];
if (!empty($authorize)) {
$authMiddleware[] = 'auth:sanctum';
if(! empty($authGuard) ) {
$authMiddleware[] = 'authGuard:' . $authGuard;
} else {
$authMiddleware[] = 'authGuard';
}
if (is_string($authorize) && !empty($abilitiesPrefix)) {
$authMiddleware[] = 'abilities:' . $abilitiesPrefix . '.' . $authorize;
} else if ($authorize !== true) {
$authMiddleware[] = 'abilities:' . $authorize;
}
}
$middleware = array_merge($authMiddleware, self::registerMiddleware($instance->middleware), $middleware);
$route = Route::{Str::lower($instance->httpMethod)}(
$routePrefix . $instance->route,
[$className, $method]
)->middleware(array_unique($middleware));
if (!empty($instance->where)) {
$route->where($instance->where);
}
}
/**
* 获取中间件
* @param $middleware
* @return string[]
*/
private static function registerMiddleware($middleware): array
{
if(empty($middleware)) {
return [];
}
if(is_array($middleware)) {
return $middleware;
}
if (is_string($middleware)) {
return [$middleware];
}
return [];
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\AnnoRoute;
use Illuminate\Support\ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(AnnoRoute::class, AnnoRouteService::class);
}
}
@@ -0,0 +1,262 @@
<?php
namespace Modules\Common\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use ReflectionClass;
use ReflectionException;
use Symfony\Component\Finder\Finder;
class GenerateRouteHelperCommand extends Command
{
protected $signature = 'route:helper';
protected $description = 'Scan annotation routes and generate IDE route helper files for Laravel Idea';
private static array $mapping = [
GetRoute::class,
PostRoute::class,
PutRoute::class,
DeleteRoute::class,
];
private static array $httpMethodMap = [
GetRoute::class => 'get',
PostRoute::class => 'post',
PutRoute::class => 'put',
DeleteRoute::class => 'delete',
];
/** @var array<string, list<array{method: string, uri: string, controller: string, action: string, middleware: array}>> */
private array $controllerRoutes = [];
public function handle(): int
{
$paths = [
app_path('Http/Controllers'),
base_path('modules'),
];
$totalRoutes = 0;
foreach ($paths as $path) {
if (!is_dir($path)) {
continue;
}
$finder = new Finder();
$finder->files()->in($path)->name('*Controller.php');
foreach ($finder as $file) {
$className = $this->getClassNameFromFile(
$file->getRealPath(),
$file->getPath()
);
if ($className && class_exists($className)) {
$routes = $this->scanController($className);
if (!empty($routes)) {
$this->controllerRoutes[$className] = $routes;
$totalRoutes += count($routes);
}
}
}
}
if (empty($this->controllerRoutes)) {
$this->warn('No annotation routes found.');
return self::SUCCESS;
}
$this->writeHelperFile();
$this->info("Routes generated to: " . base_path('routes/api.php'));
$this->info("Found {$totalRoutes} routes.");
return self::SUCCESS;
}
private function scanController(string $className): array
{
$routes = [];
try {
$classRef = new ReflectionClass($className);
} catch (ReflectionException) {
return $routes;
}
$classAttrs = collect($classRef->getAttributes());
if ($classAttrs->isEmpty()) {
return $routes;
}
$hasRequestAttr = $classAttrs->some(
fn($attr) => $attr->getName() === RequestAttribute::class
);
if (!$hasRequestAttr) {
return $routes;
}
$requestAttr = $classAttrs->first(
fn($attr) => $attr->getName() === RequestAttribute::class
);
$requestInstance = $requestAttr->newInstance();
$routePrefix = $requestInstance->routePrefix ?? '';
$authGuard = $requestInstance->authGuard ?? null;
$abilitiesPrefix = $requestInstance->abilitiesPrefix ?? '';
$classMiddleware = $this->normalizeMiddleware($requestInstance->middleware);
foreach ($classRef->getMethods() as $method) {
$attrs = $method->getAttributes();
if (empty($attrs)) {
continue;
}
$methodName = $method->getName();
foreach ($attrs as $attr) {
if (in_array($attr->getName(), self::$mapping)) {
$instance = $attr->newInstance();
$authMiddleware = $this->buildAuthMiddleware(
$instance->authorize,
$authGuard,
$abilitiesPrefix,
);
$allMiddleware = array_values(array_unique(array_merge(
$authMiddleware,
$this->normalizeMiddleware($instance->middleware),
$classMiddleware,
)));
$routes[] = [
'method' => self::$httpMethodMap[$attr->getName()],
'uri' => $instance->route,
'prefix' => trim($routePrefix, '/'),
'controller' => $className,
'action' => $methodName,
'middleware' => $allMiddleware,
];
}
}
}
return $routes;
}
private function buildAuthMiddleware(string|bool $authorize, ?string $authGuard, string $abilitiesPrefix): array
{
if (empty($authorize) || $authorize === false) {
return [];
}
$authMiddleware = ['auth:sanctum'];
if (!empty($authGuard)) {
$authMiddleware[] = 'authGuard:' . $authGuard;
} else {
$authMiddleware[] = 'authGuard';
}
if (is_string($authorize) && !empty($abilitiesPrefix)) {
$authMiddleware[] = 'abilities:' . $abilitiesPrefix . '.' . $authorize;
} else if ($authorize !== true) {
$authMiddleware[] = 'abilities:' . $authorize ;
}
return $authMiddleware;
}
private function normalizeMiddleware(string|array $middleware): array
{
if (empty($middleware)) {
return [];
}
if (is_array($middleware)) {
return $middleware;
}
return [$middleware];
}
private function getClassNameFromFile(string $filePath, string $fileDir): ?string
{
$content = file_get_contents($filePath);
if (!preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatch)) {
return $this->guessClassNameFromPath($filePath, $fileDir);
}
$namespace = trim($namespaceMatch[1]);
$className = basename($filePath, '.php');
return $namespace . '\\' . $className;
}
private function guessClassNameFromPath(string $filePath, string $fileDir): ?string
{
$basePath = base_path();
$relativePath = str_replace($basePath, '', $fileDir);
$namespace = str_replace('/', '\\', ltrim($relativePath, '/'));
$namespace = trim($namespace, '\\');
$className = basename($filePath, '.php');
return empty($namespace) ? $className : $namespace . '\\' . $className;
}
private function writeHelperFile(): void
{
$outputPath = base_path('routes/api.php');
$lines = [
'<?php',
'',
'use Illuminate\Support\Facades\Route;',
'',
'Route::get(\'/\', function () {',
' return "Hello, thank you for using XinAdmin. ";',
'});',
'',
];
// Each controller gets its own Route::controller group
foreach ($this->controllerRoutes as $className => $routes) {
$prefix = $routes[0]['prefix'];
$shortName = substr(strrchr($className, '\\'), 1);
$lines[] = "// {$shortName}";
$chain = "Route::controller({$className}::class)";
if ($prefix !== '') {
$chain .= "->prefix('{$prefix}')";
}
$chain .= '->group(function () {';
$lines[] = $chain;
foreach ($routes as $route) {
$uri = $route['uri'];
if ($uri === '') {
$uri = '/';
}
$middlewareStr = '';
if (!empty($route['middleware'])) {
$middlewareStr = "->middleware(['" . implode("', '", $route['middleware']) . "'])";
}
$lines[] = " Route::{$route['method']}('{$uri}', '{$route['action']}'){$middlewareStr};";
}
$lines[] = '});';
$lines[] = '';
}
File::put($outputPath, implode("\n", $lines));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Common\Enum;
/**
* 响应状态枚举类
*/
enum ShowType: int
{
// 成功响应
case SUCCESS_MESSAGE = 0;
// 警告响应
case WARN_MESSAGE = 1;
// 错误响应
case ERROR_MESSAGE = 2;
// 成功通知
case SUCCESS_NOTIFICATION = 3;
// 警告通知
case WARN_NOTIFICATION = 4;
// 错误通知
case ERROR_NOTIFICATION = 5;
// 静默响应
case SILENT = 99;
}
@@ -0,0 +1,119 @@
<?php
namespace Modules\Common\Http\Controllers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Routing\Controller;
use Modules\Common\Trait\RequestJson;
abstract class BaseController extends Controller
{
use RequestJson;
/**
* 查询字符串
* The fields queried by the current model
*
* @var array
*/
protected array $searchField = [];
/**
* 快速搜索字段
* Quick search field
*
* @var array
*/
protected array $quickSearchField = [];
/** 构建查询方法 */
protected function buildSearch(array $params, Builder $model): Builder
{
// 构建筛选
if (isset($params['filter']) && $params['filter'] != '') {
if(is_array($params['filter'])) {
$filter = $params['filter'];
} else {
$filter = json_decode($params['filter'], true);
}
foreach ($filter as $k => $v) {
if (! $v) {
continue;
}
$model->whereIn($k, $v);
}
unset($params['filter']);
}
// 构建查询
foreach ($this->searchField ?? [] as $key => $op) {
if (isset($params[$key]) && $params[$key] != '') {
if (in_array($op, ['=', '>', '<>', '<', '>=', '<='])) {
$model->where($key, $op, $params[$key]);
continue;
}
if ($op == 'like') {
$model->where($key, $op, '%'.$params[$key].'%');
continue;
}
if ($op == 'afterLike') {
$model->where($key, $op, $params[$key].'%');
continue;
}
if ($op == 'beforeLike') {
$model->where($key, $op, '%'.$params[$key]);
continue;
}
if ($op == 'date') {
$date = date('Y-m-d', strtotime($params[$key]));
$model->whereDate($key, $date);
continue;
}
if ($op == 'betweenDate') {
if (is_array($params[$key])) {
$start = $params[$key][0];
$end = $params[$key][1];
$model->whereDate($key, '>=', $start);
$model->whereDate($key, '<=', $end);
}
}
}
}
// 快速搜索
if (isset($params['keywordSearch']) && $params['keywordSearch'] != '') {
$quickSearchArr = $this->quickSearchField ?? [];
if (count($quickSearchArr) > 0) {
$model->whereAny(
$quickSearchArr,
'like',
'%'.str_replace('%', '\%', $params['keywordSearch']).'%'
);
}
}
// 构建排序
if (isset($params['sorter']) && $params['sorter']) {
if(is_array($params['sorter'])) {
$sorter = $params['sorter'];
} else {
$sorter = json_decode($params['sorter'], true);
}
if (count($sorter) > 0) {
$column = array_keys($sorter)[0];
$direction = $sorter[$column] == 'ascend' ? 'asc' : 'desc';
$model->orderBy($column, $direction);
}
}
return $model;
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Common\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest
{
/**
* 是否为更新请求
* @return bool
*/
protected function isUpdate(): bool
{
if ($this->isMethod('PUT') || $this->isMethod('PATCH')) {
return true;
}
return false;
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\Common\Middlewares;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AllowCrossDomainMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// 添加跨域头
$response->headers->set('Access-Control-Allow-Origin', '*');
$response->headers->set('Access-Control-Allow-Credentials', 'true');
$response->headers->set('Access-Control-Max-Age', 1800);
$response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, User-Language');
// 如果是预检请求, 返回 204
if ($request->isMethod('OPTIONS')) {
return response()->json([], 204, $response->headers->all());
}
return $response;
}
}
@@ -0,0 +1,91 @@
<?php
namespace Modules\Common\Middlewares;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Session;
class LanguageMiddleware
{
/**
* 支持的语言列表
*/
protected array $supportedLanguages = [
'en' => 'en', // 英语
'zh' => 'zh', // 简体中文
'jp' => 'ja', // 日语
];
/**
* 默认语言
*/
protected string $defaultLanguage = 'zh';
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
// 获取当前语言
$locale = $this->getLocale($request);
// 设置应用语言
App::setLocale($locale);
// 让请求继续处理
return $next($request);
}
/**
* 获取当前语言设置
*/
protected function getLocale(Request $request): string
{
// 优先级 1: URL 参数 (例如 ?lang=en)
if ($request->has('lang')) {
$lang = $request->get('lang');
if ($this->isSupported($lang)) {
return $lang;
}
}
// 优先级 2: User-Language 头
$browserLocale = $this->getBrowserLocale($request);
if ($browserLocale && $this->isSupported($browserLocale)) {
return $browserLocale;
}
// 优先级 3: Session 中存储的语言
if (Session::has('locale')) {
$lang = Session::get('locale');
if ($this->isSupported($lang)) {
return $lang;
}
}
// 优先级 4: 配置文件中的默认语言
return config('app.locale', $this->defaultLanguage);
}
/**
* 从 User-Language 头中获取浏览器偏好语言
*/
protected function getBrowserLocale(Request $request): ?string
{
$acceptLanguage = $request->header('User-Language');
if (!$acceptLanguage) {
return null;
}
return $acceptLanguage;
}
/**
* 检查语言是否被支持
*/
protected function isSupported(string $locale): bool
{
return array_key_exists($locale, $this->supportedLanguages);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Common\Providers;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\ServiceProvider;
class PaginationProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->bind('Illuminate\Pagination\LengthAwarePaginator', function ($app, $options) {
return new class(
$options['items'],
$options['total'],
$options['perPage'],
$options['currentPage'],
$options['options']
) extends LengthAwarePaginator {
public function toArray(): array
{
return [
'data' => $this->items(),
'total' => $this->total(),
'pageSize' => $this->perPage(),
'current' => $this->currentPage(),
];
}
};
});
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace Modules\Common\Trait;
use App\Exceptions\HttpResponseException;
use Illuminate\Http\JsonResponse;
use Modules\Common\Enum\ShowType as ShopTypeEnum;
/**
* 响应 trait
* 支持 throw 响应
*/
trait RequestJson
{
/**
* 成功响应
*
* @param string|array $data 响应数据
* @param string $message 响应内容
*/
protected function success(string|array $data = [], string $message = 'ok'): JsonResponse
{
if (is_array($data)) {
return self::renderJson(true, $data, $message);
}
return self::renderJson(true, [], $data);
}
/**
* 抛出成功响应,中断程序运行
*
* @param string|array $data 响应数据
* @param string $message 响应内容
*/
protected function throwSuccess(string|array $data = [], string $message = 'ok'): void
{
if (is_array($data)) {
self::renderThrow(true, $data, $message);
}
self::renderThrow(true, [], $data);
}
/**
* 返回失败响应
*
* @param string|array $data 响应数据
* @param string $message 响应内容
*/
protected function error(string|array $data = [], string $message = ''): JsonResponse
{
if (is_array($data)) {
return self::renderJson(false, $data, $message, ShopTypeEnum::ERROR_MESSAGE);
}
return self::renderJson(false, [], $data, ShopTypeEnum::ERROR_MESSAGE);
}
/**
* 抛出失败响应,中断程序运行
*
* @param string|array $data 响应数据
* @param string $message 响应内容
*/
protected function throwError(string|array $data = [], string $message = ''): void
{
if (is_array($data)) {
self::renderThrow(false, $data, $message, ShopTypeEnum::ERROR_MESSAGE);
}
self::renderThrow(false, [], $data, ShopTypeEnum::ERROR_MESSAGE);
}
/**
* 返回警告响应
*
* @param string|array $data 响应数据
* @param string $message 响应内容
*/
protected function warn(string|array $data = [], string $message = ''): JsonResponse
{
if (is_array($data)) {
return self::renderJson(false, $data, $message, ShopTypeEnum::WARN_MESSAGE);
}
return self::renderJson(false, [], $data, ShopTypeEnum::WARN_MESSAGE);
}
/**
* 抛出失败警告,中断程序运行
*
* @param string|array $data 响应数据
* @param string $message 响应内容
*/
protected function throwWarn(string|array $data = [], string $message = ''): void
{
if (is_array($data)) {
self::renderThrow(false, $data, $message, ShopTypeEnum::WARN_MESSAGE);
}
self::renderThrow(false, [], $data, ShopTypeEnum::WARN_MESSAGE);
}
/**
* 通知响应
*
* @param string $msg 通知标题
* @param string $description 通知描述
* @param string $placement 通知位置 top topLeft topRight bottom bottomLeft bottomRight
* @param ShopTypeEnum $showTypeEnum 通知类型
*/
protected function notification(
string $msg,
string $description,
ShopTypeEnum $showTypeEnum = ShopTypeEnum::SUCCESS_NOTIFICATION,
string $placement = 'topRight'
): JsonResponse {
$showType = $showTypeEnum->value;
$success = false;
return response()->json(compact('description', 'success', 'msg', 'showType', 'placement'));
}
/**
* 返回 Json 响应
*
* @param bool $success 响应状态
* @param array $data 响应数据
* @param string $msg 响应内容
*/
protected static function renderJson(
bool $success = true,
array $data = [],
string $msg = '',
ShopTypeEnum $showTypeEnum = ShopTypeEnum::SUCCESS_MESSAGE
): JsonResponse {
$showType = $showTypeEnum->value;
return response()->json(compact('data', 'success', 'msg', 'showType'));
}
/**
* 抛出 API 数据
*
* @param bool $success 响应状态
* @param mixed $data 返回数据
* @param string $msg 响应内容
* @param ShopTypeEnum $showTypeEnum
*/
public static function renderThrow(
bool $success = true,
array $data = [],
string $msg = '',
ShopTypeEnum $showTypeEnum = ShopTypeEnum::SUCCESS_MESSAGE
) {
$showType = $showTypeEnum->value;
throw new HttpResponseException(compact('data', 'success', 'msg', 'showType'));
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
use Modules\SystemTool\Services\SysSiteConfigService;
if (! function_exists('site_config')) {
/**
* 获取或设置系统配置
*
* @param string|null $name 配置名称,格式:'group.key' 或 'group',为null时返回所有配置
* @param mixed|null $default 默认值(仅在获取时使用)
* @return mixed
*
* @example
* site_config('site.name') // 获取配置
* site_config('site.name', 'Default') // 带默认值获取
* site_config('site') // 获取整个组
* site_config() // 获取所有配置
*/
function site_config(?string $name = null, mixed $default = null): mixed
{
return SysSiteConfigService::getSiteConfig($name, $default);
}
}
if (! function_exists('web_path')) {
/**
* Get the path to the web of the install.
*
* @param string $path
* @return string
*/
function web_path(string $path = ''): string
{
return base_path('web'. DIRECTORY_SEPARATOR . $path);
}
}
if (! function_exists('getTreeData')) {
/**
* 获取树形数据
*
* @param array $list
* @param int $parentId
* @param string[] $params
* @return array
*/
function getTreeData(
array &$list,
int $parentId = 0,
array $params = []
): array
{
$params = array_merge($params, [
'id' => 'id',
'parent_id' => 'parent_id',
'children' => 'children'
]);
$data = [];
foreach ($list as $k => $item) {
if ($item[$params['parent_id']] == $parentId) {
$children = getTreeData($list, $item[$params['id']]);
!empty($children) && $item[$params['children']] = $children;
$data[] = $item;
unset($list[$k]);
}
}
return $data;
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\SystemAgent\Ai\Agents;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Promptable;
use Stringable;
class XinChatAgent implements Agent, Conversational
{
use Promptable, RemembersConversations;
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'You are a helpful, friendly AI assistant. Answer questions clearly and concisely. '
. 'If you don\'t know something, be honest about it. '
. 'Use markdown formatting when it helps readability.';
}
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\SystemAgent\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemAgent\Models\AgentModel;
#[RequestAttribute('/ai/agent', 'ai.agent')]
class AgentController extends BaseController
{
#[GetRoute(authorize: 'query')]
public function query(): JsonResponse
{
$agents = AgentModel::orderBy('id')->get();
return $this->success($agents->toArray());
}
#[GetRoute('/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
public function show(int $id): JsonResponse
{
$agent = AgentModel::find($id);
if (! $agent) {
return $this->error('Agent not found');
}
return $this->success($agent->toArray());
}
#[PutRoute('/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function update(int $id, Request $request): JsonResponse
{
$enabled = $request->boolean('enabled', true);
$model = AgentModel::find($id);
if (! $model) {
return $this->error('Agent not found');
}
$model->enabled = $enabled;
$model->save();
return $this->success();
}
}
@@ -0,0 +1,128 @@
<?php
namespace Modules\SystemAgent\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Ai\Responses\StreamableAgentResponse;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemAgent\Ai\Agents\XinChatAgent;
use Modules\SystemAgent\Models\AgentModel;
#[RequestAttribute('/ai/chat', 'ai.chat')]
class ChatController extends BaseController
{
/**
* 发送消息并返回 SSE 流式响应
*/
#[PostRoute('/send', 'send')]
public function send(Request $request): StreamableAgentResponse|JsonResponse
{
$request->validate([
'message' => 'required|string|max:10000',
'conversation_id' => 'nullable|string|max:36',
'agent_id' => 'nullable|integer|exists:agents,id',
]);
$message = $request->input('message');
$conversationId = $request->input('conversation_id');
$agentId = $request->input('agent_id');
$user = $request->user();
try {
if ($agentId) {
$agentModel = AgentModel::findOrFail($agentId);
$agentClass = $agentModel->namespace;
$agent = $agentClass::make();
} else {
$agent = XinChatAgent::make();
}
if ($conversationId) {
// 继续已有会话
$response = $agent
->continue($conversationId, as: $user)
->stream($message);
} else {
// 新建会话
$response = $agent
->forUser($user)
->stream($message);
}
return $response;
} catch (\Throwable $e) {
return $this->error('AI 响应失败:' . $e->getMessage());
}
}
/**
* 获取当前用户的会话列表
*/
#[GetRoute('/conversations', 'conversations')]
public function conversations(Request $request): JsonResponse
{
$user = $request->user();
$conversations = $user->conversations()
->latest('updated_at')
->get()
->map(fn ($conversation) => [
'key' => $conversation->id,
'label' => $conversation->title,
'updated_at' => $conversation->updated_at->toISOString(),
]);
return $this->success($conversations->toArray());
}
/**
* 获取指定会话的消息列表
*/
#[GetRoute('/messages/{conversationId}', 'messages')]
public function messages(Request $request, string $conversationId): JsonResponse
{
$user = $request->user();
$conversation = $user->conversations()->find($conversationId);
if (! $conversation) {
return $this->error('会话不存在');
}
$messages = $conversation->messages()
->oldest()
->get()
->map(fn ($msg) => [
'key' => $msg->id,
'role' => $msg->role,
'content' => $msg->content,
'created_at' => $msg->created_at->toISOString(),
]);
return $this->success($messages->toArray());
}
/**
* 删除指定会话
*/
#[DeleteRoute('/messages/{conversationId}', 'delete')]
public function deleteConversation(Request $request, string $conversationId): JsonResponse
{
$user = $request->user();
$conversation = $user->conversations()->find($conversationId);
if (! $conversation) {
return $this->error('会话不存在');
}
$conversation->delete();
return $this->success('会话已删除');
}
}
@@ -0,0 +1,119 @@
<?php
namespace Modules\SystemAgent\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Ai\Models\Conversation;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemUser\Models\SysUserModel;
#[RequestAttribute('/ai/conversation', 'ai.conversation')]
class ConversationController extends BaseController
{
protected array $searchField = [
'title' => 'like',
];
protected array $quickSearchField = ['title'];
/**
* 会话列表
*/
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$perPage = (int) ($params['pageSize'] ?? 10);
$query = Conversation::query()->withCount('messages');
$data = $this->buildSearch($params, $query)
->orderBy('updated_at', 'desc')
->paginate($perPage);
$userIds = $data->pluck('user_id')->filter()->unique();
$users = SysUserModel::whereIn('id', $userIds)->pluck('username', 'id');
$data = $data->through(function ($conversation) use ($users) {
return [
'id' => $conversation->id,
'user_id' => $conversation->user_id,
'username' => $users[$conversation->user_id] ?? '',
'title' => $conversation->title,
'message_count' => $conversation->messages_count,
'created_at' => $conversation->created_at?->toISOString(),
'updated_at' => $conversation->updated_at?->toISOString(),
];
});
return $this->success($data->toArray());
}
/**
* 删除会话
*/
#[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[a-zA-Z0-9\-]+'])]
public function delete(string $id): JsonResponse
{
$conversation = Conversation::find($id);
if (! $conversation) {
return $this->error('会话不存在');
}
$conversation->delete();
return $this->success('会话已删除');
}
/**
* 获取会话消息列表
*/
#[GetRoute('/{id}/messages', authorize: 'query', where: ['id' => '[a-zA-Z0-9\-]+'])]
public function messages(string $id, Request $request): JsonResponse
{
$conversation = Conversation::find($id);
if (! $conversation) {
return $this->error('会话不存在');
}
$perPage = (int) $request->input('pageSize', 20);
$data = $conversation->messages()
->orderBy('created_at')
->paginate($perPage)
->toArray();
return $this->success($data);
}
/**
* 获取会话详情
*/
#[GetRoute('/{id}', authorize: 'query', where: ['id' => '[a-zA-Z0-9\-]+'])]
public function show(string $id): JsonResponse
{
$conversation = Conversation::withCount('messages')->find($id);
if (! $conversation) {
return $this->error('会话不存在');
}
$user = $conversation->user_id
? SysUserModel::find($conversation->user_id)
: null;
return $this->success([
'id' => $conversation->id,
'user_id' => $conversation->user_id,
'username' => $user?->username ?? '',
'title' => $conversation->title,
'message_count' => $conversation->messages_count,
'created_at' => $conversation->created_at?->toISOString(),
'updated_at' => $conversation->updated_at?->toISOString(),
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\SystemAgent\Http\Requests;
use Modules\Common\Http\Requests\BaseFormRequest;
class AgentFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'enabled' => 'required|boolean',
'name' => 'nullable|max:100',
'description' => 'nullable|max:1000',
];
}
public function messages(): array
{
return [
'enabled.required' => '启用状态不能为空',
'enabled.boolean' => '启用状态格式错误',
'name.max' => '名称不能超过100个字符',
'description.max' => '描述不能超过1000个字符',
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\SystemAgent\Models;
use Illuminate\Database\Eloquent\Model;
class AgentModel extends Model
{
protected $table = 'agents';
protected $fillable = [
'namespace',
'name',
'icon',
'description',
'tags',
'enabled',
];
protected $casts = [
'tags' => 'array',
'enabled' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\SystemAgent\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\AnnoRoute\AnnoRoute;
class SystemAgentServiceProvider extends ServiceProvider
{
public function boot(AnnoRoute $annoRoute): void
{
$annoRoute->register(base_path('modules/SystemAgent/Http/Controllers'));
}
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\SystemTool\Ai\Agents;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class TestAgent implements Agent, Conversational, HasTools
{
use Promptable, RemembersConversations;
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return '';
}
/**
* Get the list of messages comprising the conversation so far.
*
* @return Message[]
*/
public function messages(): iterable
{
return [];
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Modules\SystemTool\Ai\Boots;
use Laravel\Boost\Contracts\SupportsGuidelines;
use Laravel\Boost\Contracts\SupportsMcp;
use Laravel\Boost\Contracts\SupportsSkills;
use Laravel\Boost\Install\Agents\Agent;
use Laravel\Boost\Install\Enums\McpInstallationStrategy;
use Laravel\Boost\Install\Enums\Platform;
class Reasonix extends Agent implements SupportsGuidelines, SupportsMcp, SupportsSkills
{
public function name(): string
{
return 'reasonix';
}
public function displayName(): string
{
return 'Reasonix';
}
public function systemDetectionConfig(Platform $platform): array
{
return match ($platform) {
Platform::Darwin, Platform::Linux => [
'command' => 'command -v reasonix',
],
Platform::Windows => [
'command' => 'cmd /c where reasonix 2>nul',
],
};
}
public function projectDetectionConfig(): array
{
return [
'paths' => ['.reasonix'],
'files' => ['REASONIX.md'],
];
}
public function mcpInstallationStrategy(): McpInstallationStrategy
{
return McpInstallationStrategy::FILE;
}
public function mcpConfigPath(): string
{
return config('boost.agents.reasonix.mcp_config_path', '.mcp.json');
}
public function guidelinesPath(): string
{
return config('boost.agents.reasonix.guidelines_path', 'REASONIX.md');
}
public function skillsPath(): string
{
return config('boost.agents.reasonix.skills_path', '.reasonix/skills');
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\SystemTool\Attributes;
use Attribute;
use Modules\SystemTool\Enum\ESettingType;
/**
* 在 SettingsDefinition 子类上声明配置项
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class Setting
{
public function __construct(
public string $config,
public ESettingType $type = ESettingType::String,
public ?string $description = null,
) {}
}
@@ -0,0 +1,346 @@
<?php
namespace Modules\SystemTool\Base;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\SystemTool\Attributes\Setting;
use Modules\SystemTool\Enum\ESettingType;
use ReflectionClass;
/**
* 设置定义基类
*/
abstract class SettingsDefinition
{
/**
* 聚合缓存键名 — 全局中间件 LoadAppSettingsMiddleware 使用
* 所有设置一次性缓存在此键中,set() 写入时自动清除。
*/
const AGGREGATE_CACHE_KEY = 'app-settings-all';
/**
* 解析后的定义缓存(类名 → 定义数组)
*
* @var array<string, array<string, array{config: string, type: string, description: string|null}>>
*/
protected static array $definitions = [];
/**
* 从 #[Setting] Attribute 解析设置定义
*
* @return array<string, array{config: string, type: string, description: string|null}>
*/
public static function getDefinition(): array
{
$class = static::class;
if (! isset(self::$definitions[$class])) {
$reflection = new ReflectionClass($class);
$attributes = $reflection->getAttributes(Setting::class);
$definition = [];
foreach ($attributes as $attribute) {
/** @var Setting $instance */
$instance = $attribute->newInstance();
$definition[$instance->config] = [
'config' => $instance->config,
'type' => $instance->type->value,
'description' => $instance->description,
];
}
self::$definitions[$class] = $definition;
}
return self::$definitions[$class];
}
/**
* 判断某个 key 是否在本类的定义中
*/
public static function hasDefinitionKey(string $key): bool
{
return array_key_exists($key, static::getDefinition());
}
/**
* 获取数据库表名
*/
public static function getTableName(): string
{
return config('app_settings.table', 'sys_app_settings');
}
/**
* 将本类的所有配置项定义同步到 应用配置 表
*
* - 新 key 插入,值来自当前 config() 的默认值
* - 已有 key 只更新 description,不覆盖值
* - 部署时在 migration 中调用
*/
public static function init(): void
{
$definition = static::getDefinition();
$table = static::getTableName();
foreach ($definition as $key => $setting) {
$exists = DB::table($table)->where('key', $key)->exists();
if (! $exists) {
$value = static::getConfigValue($key);
static::set($key, $value);
} else {
DB::table($table)
->where('key', $key)
->update([
'description' => $setting['description'] ?? null,
'updated_at' => now(),
]);
}
}
}
/**
* 读取配置值
*
* @throws InvalidArgumentException 当 key 未定义时
*/
public static function get(string $key, mixed $default = null): mixed
{
if (! static::hasDefinitionKey($key)) {
throw new InvalidArgumentException("Setting key '{$key}' is not defined in " . static::class);
}
return static::getCacheValue($key, function () use ($key, $default) {
return static::getDBValue($key, function () use ($key, $default) {
return static::setCacheValue(
key: $key,
value: static::getConfigValue($key, $default),
);
});
});
}
/**
* 写入配置值 → DB + Cache
*
* @throws InvalidArgumentException 当 key 未定义或类型不匹配时
*/
public static function set(string $key, mixed $value): void
{
if (! static::hasDefinitionKey($key)) {
throw new InvalidArgumentException("Setting key '{$key}' is not defined in " . static::class);
}
static::setDBValue($key, $value);
static::setCacheValue($key, $value);
// 当前请求立即生效(中间件在下一个请求才会重新加载)
config([$key => $value]);
// 清除聚合缓存,使下次请求通过中间件重新从 DB 加载
Cache::forget(self::AGGREGATE_CACHE_KEY);
}
/**
* 从 Laravel config 读取(最终 fallback
*/
public static function getConfigValue(string $key, mixed $default = null): mixed
{
return config($key, $default);
}
/**
* 从缓存读取
*
* @param mixed $default 默认值或闭包 (fn() => mixed)
*/
public static function getCacheValue(string $key, mixed $default = null): mixed
{
if ($default instanceof \Closure) {
return Cache::rememberForever('app-setting-' . $key, $default);
}
return Cache::get('app-setting-' . $key, $default);
}
/**
* 写入缓存
*/
public static function setCacheValue(string $key, mixed $value, ?int $ttl = null): mixed
{
if ($ttl !== null) {
Cache::put('app-setting-' . $key, $value, $ttl);
} else {
Cache::forever('app-setting-' . $key, $value);
}
return $value;
}
/**
* 将本类所有配置项从 DB 加载到 Laravel config() 运行时
*
* 调用后 config('filesystems.default') 等可直接返回 DB 值。
* 在 getConfig() 批量读取场景下避免逐个 get() 的开销。
*/
public static function reloadIntoConfig(): void
{
$table = static::getTableName();
$rows = DB::table($table)->whereIn('key', array_keys(static::getDefinition()))->get();
foreach ($rows as $row) {
$value = match ((int) $row->type) {
ESettingType::String->value => $row->s,
ESettingType::Bool->value => is_null($row->n) ? null : (bool) $row->n,
ESettingType::Number->value => is_null($row->n) ? null : (int) $row->n,
ESettingType::Array->value => is_null($row->e) ? null : json_decode($row->e, true),
ESettingType::Object->value => is_null($row->e) ? null : unserialize(base64_decode($row->e)),
ESettingType::EncryptedString->value => is_null($row->e) ? null : \Illuminate\Support\Facades\Crypt::decrypt(base64_decode($row->e)),
default => $row->s ?? null,
};
config([$row->key => $value]);
}
}
/**
* 清除某个 key 的缓存
*/
public static function forgetCache(string $key): void
{
Cache::forget('app-setting-' . $key);
// 同时清除聚合缓存,保持一致性
Cache::forget(self::AGGREGATE_CACHE_KEY);
}
/**
* 从数据库读取(按类型自动转换)
*
* @param mixed $default 默认值或闭包
*/
public static function getDBValue(string $key, mixed $default = null): mixed
{
$rec = DB::table(static::getTableName())->where('key', $key)->first();
if (! $rec) {
return $default instanceof \Closure ? $default() : value($default);
}
return match ((int) $rec->type) {
ESettingType::String->value => $rec->s,
ESettingType::Bool->value => is_null($rec->n) ? null : (bool) $rec->n,
ESettingType::Number->value => is_null($rec->n) ? null : (int) $rec->n,
ESettingType::Array->value => is_null($rec->e) ? null : json_decode($rec->e, true),
ESettingType::Object->value => is_null($rec->e) ? null : unserialize(base64_decode($rec->e)),
ESettingType::EncryptedString->value => is_null($rec->e) ? null : Crypt::decrypt(base64_decode($rec->e)),
default => $rec->s ?? $rec->value ?? null,
};
}
/**
* 写入数据库(按类型选择列)
*
* @throws InvalidArgumentException
*/
public static function setDBValue(string $key, mixed $value): void
{
$definition = static::getDefinition();
if (! isset($definition[$key])) {
throw new InvalidArgumentException("Setting key '{$key}' is not defined in " . static::class);
}
$type = $definition[$key]['type'];
$description = $definition[$key]['description'] ?? null;
$table = static::getTableName();
$data = [
'key' => $key,
'type' => $type,
'description' => $description,
'updated_at' => now(),
];
switch ((int) $type) {
case ESettingType::String->value:
if (! is_string($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be a string.");
}
$data['s'] = $value;
$data['n'] = null;
$data['e'] = null;
break;
case ESettingType::Bool->value:
if (! is_bool($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be a boolean.");
}
$data['n'] = is_null($value) ? null : (int) $value;
$data['s'] = null;
$data['e'] = null;
break;
case ESettingType::Number->value:
if (! is_numeric($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be a number.");
}
$data['n'] = $value;
$data['s'] = null;
$data['e'] = null;
break;
case ESettingType::Array->value:
if (! is_array($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be an array.");
}
$data['e'] = is_null($value) ? null : json_encode($value, JSON_UNESCAPED_UNICODE);
$data['s'] = null;
$data['n'] = null;
break;
case ESettingType::Object->value:
if (! is_object($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be an object.");
}
$data['e'] = is_null($value) ? null : base64_encode(serialize($value));
$data['s'] = null;
$data['n'] = null;
break;
case ESettingType::EncryptedString->value:
if (! is_string($value) && ! is_null($value)) {
throw new InvalidArgumentException("Value for '{$key}' must be a string.");
}
$data['e'] = is_null($value) ? null : base64_encode(Crypt::encrypt($value));
$data['s'] = null;
$data['n'] = null;
break;
default:
throw new InvalidArgumentException("Unknown setting type '{$type}' for key '{$key}'.");
}
$exists = DB::table($table)->where('key', $key)->exists();
if ($exists) {
DB::table($table)->where('key', $key)->update($data);
} else {
$data['created_at'] = now();
DB::table($table)->insert($data);
}
}
/**
* 从数据库删除某个配置项
*/
public static function deleteDBValue(string $key): void
{
DB::table(static::getTableName())->where('key', $key)->delete();
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Modules\SystemTool\Enum;
/**
* 设置类型枚举
*
* 对应 应用设置 表的 type 列,决定值存储在 s / n / e 哪个字段。
*/
enum ESettingType: int
{
case String = 10;
case Bool = 15;
case Number = 20;
case Array = 30;
case Object = 40;
case EncryptedString = 50;
/**
* 是否为字符串类型
*/
public function isString(): bool
{
return $this === self::String;
}
/**
* 是否为数字/布尔类型(存储在 n 列)
*/
public function isNumeric(): bool
{
return in_array($this, [self::Bool, self::Number], true);
}
/**
* 是否为扩展类型(存储在 e 列)
*/
public function isExtended(): bool
{
return in_array($this, [self::Array, self::Object, self::EncryptedString], true);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace Modules\SystemTool\Enum;
/**
* 枚举类:文件类型
* Class FileType
*/
enum FileType: int
{
// 图片
case IMAGE = 10;
// 音频
case AUDIO = 20;
// 视频
case VIDEO = 30;
// 压缩包
case ZIP = 40;
// 文档
case DOCUMENT = 50;
// 未知文件
case ANNEX = 99;
/**
* 获取类型名称
*/
public function name(): string
{
return match ($this) {
self::IMAGE => __('system.file.image'),
self::AUDIO => __('system.file.audio'),
self::VIDEO => __('system.file.video'),
self::ZIP => __('system.file.zip'),
self::DOCUMENT => __('system.file.document'),
self::ANNEX => __('system.file.annex'),
};
}
/**
* 获取预览地址
*/
public function previewPath(): string
{
return match ($this) {
self::IMAGE => 'static/image.svg',
self::AUDIO => 'static/audio.svg',
self::VIDEO => 'static/video.svg',
self::ZIP => 'static/zip.svg',
self::DOCUMENT => 'static/document.svg',
self::ANNEX => 'static/annex.svg',
};
}
/**
* 根据扩展名推断文件类型
*/
public static function guessFromExtension(string $extension): self
{
$extension = strtolower($extension);
foreach (self::cases() as $case) {
if ($case === self::ANNEX) continue; // 跳过 OTHER
if (in_array($extension, $case->fileExt())) {
return $case;
}
}
return self::ANNEX;
}
/**
* 文件扩展名
*/
public function fileExt(): array|string
{
return match ($this) {
self::IMAGE => ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'avif', 'webp', 'svg', 'ico'],
self::AUDIO => ['mp3', 'wma', 'wav', 'ape', 'flac', 'ogg', 'aac'],
self::VIDEO => ['mp4', 'mov', 'wmv', 'flv', 'avl', 'webm', 'mkv'],
self::DOCUMENT => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv'],
self::ZIP => ['zip', 'rar', '7z', 'tar', 'gz'],
self::ANNEX => '*',
};
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace Modules\SystemTool\Enum;
/**
* 系统设置类型枚举
*/
enum SiteConfigType: string
{
// 前端表单组件类型(新版,与前端保持一致)
case INPUT = 'Input'; // 输入框
case TEXTAREA = 'TextArea'; // 文本域
case INPUT_NUMBER = 'InputNumber'; // 数字输入框
case SWITCH = 'Switch'; // 开关
case RADIO = 'Radio'; // 单选框
case CHECKBOX = 'Checkbox'; // 复选框
/**
* 从字符串创建枚举实例(宽松匹配)
*/
public static function fromString(string $type): ?self
{
return self::tryFrom($type);
}
/**
* 根据类型自动转换值
*
* @param mixed $value 原始值
* @return mixed 转换后的值
*/
public function castValue(mixed $value): mixed
{
// 如果值为 null,直接返回 null
if ($value === null) {
return null;
}
return match($this) {
// 数字类型:转换为整数或浮点数
self::INPUT_NUMBER => $this->castToNumber($value),
// 布尔类型:转换为布尔值
self::SWITCH => $this->castToBoolean($value),
// 数组类型:转换为数组
self::CHECKBOX => $this->castToArray($value),
// 默认:保持字符串(去除首尾空白)
self::INPUT, self::TEXTAREA, self::RADIO => $value,
};
}
/**
* 转换为数字(整数或浮点数)
*/
private function castToNumber(mixed $value): int|float|null
{
// 如果是数字字符串或数字
if (is_numeric($value)) {
// 如果包含小数点,转换为浮点数,否则转换为整数
return str_contains((string)$value, '.')
? (float)$value
: (int)$value;
}
// 如果是布尔值
if (is_bool($value)) {
return (int)$value;
}
// 其他情况返回 null 或 0?这里返回 null 表示无效转换
return null;
}
/**
* 转换为布尔值
*/
private function castToBoolean(mixed $value): bool
{
// 如果是字符串
if (is_string($value)) {
$lowerValue = strtolower(trim($value));
// 检查是否为 true 相关的字符串
if (in_array($lowerValue, ['true', '1', 'on', 'yes', 'y'], true)) {
return true;
}
// 检查是否为 false 相关的字符串
if (in_array($lowerValue, ['false', '0', 'off', 'no', 'n', ''], true)) {
return false;
}
// 默认转换为布尔值
return (bool)$value;
}
// 如果是数字
if (is_numeric($value)) {
return (bool)$value;
}
// 其他类型直接转换为布尔值
return (bool)$value;
}
/**
* 转换为数组
*/
private function castToArray(mixed $value): array
{
// 如果已经是数组,直接返回
if (is_array($value)) {
return $value;
}
// 如果是 JSON 字符串
if (is_string($value)) {
$trimmed = trim($value);
// 检查是否为 JSON 数组格式
if ((str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']')) ||
(str_starts_with($trimmed, '{') && str_ends_with($trimmed, '}'))) {
$decoded = json_decode($trimmed, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
return $decoded;
}
}
// 尝试按逗号分割(支持中文逗号和英文逗号)
if (str_contains($trimmed, ',') || str_contains($trimmed, '')) {
$separator = str_contains($trimmed, '') ? '' : ',';
return array_map('trim', explode($separator, $trimmed));
}
// 单个值转为数组
return [$trimmed];
}
// 其他类型转为数组
return (array)$value;
}
/**
* 转换为字符串
*/
private function castToString(mixed $value): string
{
if (is_array($value)) {
// 数组转为 JSON 字符串
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value === null) {
return '';
}
return trim((string)$value);
}
}
@@ -0,0 +1,183 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\Request;
use Laravel\Ai\Enums\Lab;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Artisan;
use Laravel\Ai\Responses\AgentResponse;
use Laravel\Ai\Responses\StreamableAgentResponse;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Ai\Agents\TestAgent;
use Modules\SystemTool\Settings\AiSettings;
/**
* AI 服务配置管理
*/
#[RequestAttribute('/system/ai', 'system.ai')]
class SysAiController extends BaseController
{
/** 获取支持的AI */
#[GetRoute('/list', 'list')]
public function index(): JsonResponse
{
$default = [Lab::Anthropic, Lab::OpenAI, Lab::Gemini, Lab::Azure, Lab::Bedrock, Lab::Groq, Lab::xAI, Lab::DeepSeek, Lab::Mistral, Lab::Ollama, Lab::OpenRouter ];
return $this->success(compact('default'));
}
/**
* 获取 AI 配置(从 DB 加载已保存的值,fallback 到 config 文件)
*/
#[GetRoute('/config', 'config')]
public function getConfig(): JsonResponse
{
$ai = config('ai');
return $this->success([
'default' => $ai['default'] ?? 'openai',
'providers' => $ai['providers'] ?? [],
]);
}
/**
* 保存 AI 配置到数据库
*/
#[PostRoute('/save', 'save')]
public function saveConfig(): JsonResponse
{
$data = request()->all();
try {
// 默认驱动
AiSettings::set('ai.default', $data['default'] ?? 'openai');
// 各供应商配置
$providers = $data['providers'] ?? [];
// Anthropic
if (isset($providers['anthropic'])) {
$p = $providers['anthropic'];
AiSettings::set('ai.providers.anthropic.key', $p['key'] ?? '');
if (isset($p['url'])) {
AiSettings::set('ai.providers.anthropic.url', $p['url']);
}
}
// Azure
if (isset($providers['azure'])) {
$p = $providers['azure'];
AiSettings::set('ai.providers.azure.key', $p['key'] ?? '');
AiSettings::set('ai.providers.azure.url', $p['url'] ?? '');
AiSettings::set('ai.providers.azure.api_version', $p['api_version'] ?? '2025-04-01-preview');
AiSettings::set('ai.providers.azure.deployment', $p['deployment'] ?? 'gpt-4o');
AiSettings::set('ai.providers.azure.embedding_deployment', $p['embedding_deployment'] ?? 'text-embedding-3-small');
AiSettings::set('ai.providers.azure.image_deployment', $p['image_deployment'] ?? 'gpt-image-1');
}
// Bedrock
if (isset($providers['bedrock'])) {
$p = $providers['bedrock'];
AiSettings::set('ai.providers.bedrock.region', $p['region'] ?? 'us-east-1');
AiSettings::set('ai.providers.bedrock.key', $p['key'] ?? '');
AiSettings::set('ai.providers.bedrock.access_key_id', $p['access_key_id'] ?? '');
AiSettings::set('ai.providers.bedrock.secret_access_key', $p['secret_access_key'] ?? '');
AiSettings::set('ai.providers.bedrock.session_token', $p['session_token'] ?? '');
}
// Cohere
if (isset($providers['cohere'])) {
AiSettings::set('ai.providers.cohere.key', $providers['cohere']['key'] ?? '');
}
// DeepSeek
if (isset($providers['deepseek'])) {
AiSettings::set('ai.providers.deepseek.key', $providers['deepseek']['key'] ?? '');
}
// ElevenLabs
if (isset($providers['eleven'])) {
AiSettings::set('ai.providers.eleven.key', $providers['eleven']['key'] ?? '');
}
// Gemini
if (isset($providers['gemini'])) {
$p = $providers['gemini'];
AiSettings::set('ai.providers.gemini.key', $p['key'] ?? '');
if (isset($p['url'])) {
AiSettings::set('ai.providers.gemini.url', $p['url']);
}
}
// Groq
if (isset($providers['groq'])) {
AiSettings::set('ai.providers.groq.key', $providers['groq']['key'] ?? '');
}
// Jina
if (isset($providers['jina'])) {
AiSettings::set('ai.providers.jina.key', $providers['jina']['key'] ?? '');
}
// Mistral
if (isset($providers['mistral'])) {
AiSettings::set('ai.providers.mistral.key', $providers['mistral']['key'] ?? '');
}
// Ollama
if (isset($providers['ollama'])) {
$p = $providers['ollama'];
AiSettings::set('ai.providers.ollama.key', $p['key'] ?? '');
AiSettings::set('ai.providers.ollama.url', $p['url'] ?? 'http://localhost:11434');
}
// OpenAI
if (isset($providers['openai'])) {
$p = $providers['openai'];
AiSettings::set('ai.providers.openai.key', $p['key'] ?? '');
if (isset($p['url'])) {
AiSettings::set('ai.providers.openai.url', $p['url']);
}
}
// OpenRouter
if (isset($providers['openrouter'])) {
AiSettings::set('ai.providers.openrouter.key', $providers['openrouter']['key'] ?? '');
}
// VoyageAI
if (isset($providers['voyageai'])) {
AiSettings::set('ai.providers.voyageai.key', $providers['voyageai']['key'] ?? '');
}
// xAI
if (isset($providers['xai'])) {
AiSettings::set('ai.providers.xai.key', $providers['xai']['key'] ?? '');
}
// 清除 Laravel 配置缓存
Artisan::call('config:clear');
return $this->success('保存成功');
} catch (\Throwable $e) {
return $this->error('保存失败:' . $e->getMessage());
}
}
/**
* 测试 AI 供应商连接
*/
#[PostRoute('/test', 'test')]
public function testConnection(Request $request): StreamableAgentResponse | JsonResponse
{
try {
$agent = TestAgent::make()->forUser($request->user());
return $agent->stream('Hello, Who are you?');
} catch (\Throwable $e) {
return $this->error('连接测试失败: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,85 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysCarouselFormRequest;
use Modules\SystemTool\Models\SysCarouselModel;
/**
* 首页轮播图管理
*/
#[RequestAttribute('/system/carousel', 'system.carousel')]
class SysCarouselController extends BaseController
{
protected array $searchField = [
'status' => '=',
];
protected array $quickSearchField = ['title'];
/** 查询轮播图列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$query = SysCarouselModel::query();
$data = $this->buildSearch($request->all(), $query)
->orderBy('sort', 'desc')
->orderBy('id', 'desc')
->paginate($request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 创建轮播图 */
#[PostRoute(authorize: 'create')]
public function create(SysCarouselFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysCarouselModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑轮播图 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysCarouselFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysCarouselModel::find($id);
if (empty($model)) {
return $this->error('轮播图不存在');
}
$model->update($validated);
return $this->success();
}
/** 删除轮播图 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysCarouselModel::find($id);
if (empty($model)) {
return $this->error('轮播图不存在');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysDictFormRequest;
use Modules\SystemTool\Models\SysDictModel;
/**
* 字典管理
*/
#[RequestAttribute('/system/dict/list', 'system.dict.list')]
class SysDictController extends BaseController
{
protected array $quickSearchField = ['name', 'code', 'describe'];
protected array $searchField = [
'status' => '='
];
/** 查询字典列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = SysDictModel::query();
$data = $this->buildSearch($params, $query)
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建字典 */
#[PostRoute(authorize: 'create')]
public function create(SysDictFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDictModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑字典 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysDictFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDictModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除字典 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysDictModel::find($id);
if (empty($model)) {
throw new RepositoryException('字典不存在');
}
$count = $model->dictItems()->count();
if ($count > 0) {
throw new RepositoryException('字典包含子项,请先删除子项!');
}
$model->delete();
return $this->success();
}
/** 获取所有字典数据 */
#[GetRoute('/all', false)]
public function all(): JsonResponse
{
$data = SysDictModel::getAllDictWithItems();
return $this->success($data);
}
}
@@ -0,0 +1,87 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysDictItemFormRequest;
use Modules\SystemTool\Models\SysDictItemModel;
/**
* 字典项控制器
*/
#[RequestAttribute('/system/dict/item', 'system.dict.item')]
class SysDictItemController extends BaseController
{
protected array $quickSearchField = ['label', 'value'];
protected array $searchField = [
'dict_id' => '=',
'status' => '='
];
public function __construct() {}
/** 查询字典项列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = SysDictItemModel::query();
$data = $this->buildSearch($params, $query)
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建字典项 */
#[PostRoute(authorize: 'create')]
public function create(SysDictItemFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDictItemModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑字典项 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysDictItemFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDictItemModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除字典项 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysDictItemModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,184 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysFileMoveOrCopyRequest;
use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemTool\Services\SysFileService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 文件列表
*/
#[RequestAttribute('/system/file/list', 'system.file.list')]
class SysFileController extends BaseController
{
protected array $searchField = [
'group_id' => '=',
'name' => 'like',
'file_type' => '=',
];
public function __construct(
protected SysFileService $service
) {}
/** 查询文件列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = SysFileModel::query();
$data = $this->buildSearch($params, $query)
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 上传文件 */
#[PostRoute('/upload', 'upload')]
public function uploadImage(Request $request): JsonResponse
{
$data = $request->validate([
'file' => 'required|file',
'group_id' => [
'nullable', 'integer',
function ($attribute, $value, $fail) {
if ($value == 0) {
return;
}
if (!DB::table('sys_file_group')->where('id', $value)->exists()) {
$fail('所选的分组 ID 不存在。');
}
},
],
]);
$result = $this->service->upload(
$data['file'],
$data['group_id'] ?? 0,
10,
Auth::id()
);
return $this->success($result);
}
/** 获取回收站文件列表 */
#[GetRoute('/trashed', 'trashed')]
public function trashed(): JsonResponse
{
$list = $this->service->getTrashedList(request()->all());
return $this->success($list);
}
/** 删除文件(软删除) */
#[DeleteRoute('/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])]
public function delete(int $id): JsonResponse
{
$this->service->delete($id);
return $this->success();
}
/** 批量删除文件 */
#[DeleteRoute('/batch/delete', 'delete')]
public function batchDelete(Request $request): JsonResponse
{
$ids = $request->input('ids', []);
$count = $this->service->batchDelete($ids);
return $this->success(['count' => $count]);
}
/** 彻底删除文件 */
#[DeleteRoute('/force-delete/{id}', 'force-delete', where: ['id' => '[0-9]+'])]
public function forceDelete(int $id): JsonResponse
{
$this->service->forceDelete($id);
return $this->success();
}
/** 批量彻底删除文件 */
#[DeleteRoute('/batch/force-delete', 'force-delete')]
public function batchForceDelete(Request $request): JsonResponse
{
$ids = $request->input('ids', []);
$count = $this->service->batchForceDelete($ids);
return $this->success(['count' => $count]);
}
/** 恢复文件 */
#[PostRoute('/restore/{id}', 'restore', where: ['id' => '[0-9]+'])]
public function restore(int $id): JsonResponse
{
$this->service->restore($id);
return $this->success();
}
/** 批量恢复文件 */
#[PostRoute('/batch/restore', 'restore')]
public function batchRestore(Request $request): JsonResponse
{
$ids = $request->input('ids', []);
$count = $this->service->batchRestore($ids);
return $this->success(['count' => $count]);
}
/** 复制文件 */
#[PostRoute('/copy', 'copy')]
public function copy(SysFileMoveOrCopyRequest $request): JsonResponse
{
$data = $request->validated();
if(! is_array($data['ids'])) {
$result = $this->service->copy($data['ids'], $data['group_id']);
} else {
$result = $this->service->batchCopy($data['ids'], $data['group_id']);
}
return $this->success($result);
}
/** 移动文件 */
#[PostRoute('/move', 'move')]
public function move(SysFileMoveOrCopyRequest $request): JsonResponse
{
$data = $request->validated();
if(! is_array($data['ids'])) {
$result = $this->service->move($data['ids'], $data['group_id']);
} else {
$result = $this->service->batchMove($data['ids'], $data['group_id']);
}
return $this->success($result);
}
/** 重命名文件 */
#[PutRoute('/rename/{id}', 'rename', where: ['id' => '[0-9]+'])]
public function rename(int $id, Request $request): JsonResponse
{
$newName = $request->input('name');
$this->service->rename($id, $newName);
return $this->success();
}
/** 下载文件 */
#[GetRoute('/download/{id}', false, where: ['id' => '[0-9]+'])]
public function download(int $id): StreamedResponse
{
return $this->service->download($id);
}
/** 清空回收站文件 */
#[DeleteRoute('/clean/trashed', 'clean-trashed')]
public function cleanTrashed(Request $request): JsonResponse
{
$count = $this->service->cleanTrashed();
return $this->success(['count' => $count]);
}
}
@@ -0,0 +1,89 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysFileGroupFormRequest;
use Modules\SystemTool\Models\SysFileGroupModel;
/**
* 文件分组控制器
*/
#[RequestAttribute('/system/file/group', 'system.file.group')]
class SysFileGroupController extends BaseController
{
public function __construct() {}
/** 获取文件分组列表 */
#[GetRoute(authorize: 'query')]
public function list(): JsonResponse
{
$query = SysFileGroupModel::query()->orderBy('sort', 'asc');
$keywordSearch = request()->input('keywordSearch', '');
if (isset($keywordSearch) && $keywordSearch != '') {
$query->whereAny(
['name'],
'like',
'%' . str_replace('%', '\%', $keywordSearch) . '%'
);
return $this->success($query->get()->toArray());
}
$group = $query->get()->toArray();
return $this->success(getTreeData($group));
}
/** 创建文件分组 */
#[PostRoute(authorize: 'create')]
public function create(SysFileGroupFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysFileGroupModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑文件分组 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysFileGroupFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysFileGroupModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除文件分组 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysFileGroupModel::find($id);
if (empty($model)) {
throw new RepositoryException('Model not found');
}
if ($model->countFiles > 0) {
throw new RepositoryException('该文件夹下存在文件,无法删除');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,85 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysGridNavFormRequest;
use Modules\SystemTool\Models\SysGridNavModel;
/**
* 首页宫格导航管理
*/
#[RequestAttribute('/system/grid-nav', 'system.grid-nav')]
class SysGridNavController extends BaseController
{
protected array $searchField = [
'status' => '=',
];
protected array $quickSearchField = ['title'];
/** 查询宫格导航列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$query = SysGridNavModel::query();
$data = $this->buildSearch($request->all(), $query)
->orderBy('sort', 'desc')
->orderBy('id', 'desc')
->paginate($request->input('pageSize', 10))
->toArray();
return $this->success($data);
}
/** 创建宫格导航 */
#[PostRoute(authorize: 'create')]
public function create(SysGridNavFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysGridNavModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑宫格导航 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysGridNavFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysGridNavModel::find($id);
if (empty($model)) {
return $this->error('宫格导航不存在');
}
$model->update($validated);
return $this->success();
}
/** 删除宫格导航 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysGridNavModel::find($id);
if (empty($model)) {
return $this->error('宫格导航不存在');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
#[RequestAttribute]
class SysIndexController extends BaseController
{
/** 获取首页信息 */
#[GetRoute('/index', false)]
public function index(): JsonResponse
{
$web_setting = site_config('web');
return $this->success($web_setting);
}
}
@@ -0,0 +1,150 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Settings\MailSettings;
/**
* 邮件配置管理
*/
#[RequestAttribute('/system/mail', 'system.mail')]
class SysMailController extends BaseController
{
/**
* 获取邮件配置(从 DB 加载已保存的值,fallback 到 config 文件)
*/
#[GetRoute('/config', 'config')]
public function getConfig(): JsonResponse
{
$mail = config('mail');
$mode = $mail['default'] ?? 'single';
$mailers = [];
if($mode === 'failover') {
$mailers = $mail['mailers']['failover']['mailers'] ?? [];
} elseif ($mode === 'roundrobin') {
$mailers = $mail['mailers']['roundrobin']['mailers'] ?? [];
}
$other = [
'mode' => 'single',
'mailers' => $mailers
];
if($mode === 'failover' || $mode == 'roundrobin') {
$other['mode'] = $mode;
$mail['default'] = $mailers[0] ?? 'smtp';
}
return $this->success([
'other' => $other,
'mail' => $mail,
'services' => config('services')
]);
}
/**
* 保存邮件配置到数据库
*
* 使用 MailSettings::set() 将每个配置项写入 应用配置 表,
* 写入后自动更新缓存,并通过全局中间件同步到 config() 运行时。
*/
#[PostRoute('/save', 'save')]
public function saveConfig(): JsonResponse
{
$data = request()->all();
// 解析前端提交的数据结构
$other = $data['other'] ?? [];
$mail = $data['mail'] ?? [];
$services = $data['services'] ?? [];
// 确定模式并构建 mailers 配置
$mode = $other['mode'] ?? 'single';
$selectedMailers = $other['mailers'] ?? [];
try {
// 邮件默认驱动
if ($mode === 'single') {
MailSettings::set('mail.default', $mail['default'] ?? 'smtp');
} elseif ($mode === 'failover') {
MailSettings::set('mail.default', 'failover');
MailSettings::set('mail.mailers.failover.mailers', $selectedMailers);
} elseif ($mode === 'roundrobin') {
MailSettings::set('mail.default', 'roundrobin');
MailSettings::set('mail.mailers.roundrobin.mailers', $selectedMailers);
}
// SMTP 配置
if (isset($mail['mailers']['smtp'])) {
$smtp = $mail['mailers']['smtp'];
MailSettings::set('mail.mailers.smtp.host', $smtp['host'] ?? '127.0.0.1');
MailSettings::set('mail.mailers.smtp.port', (int) ($smtp['port'] ?? 587));
MailSettings::set('mail.mailers.smtp.username', $smtp['username'] ?? '');
MailSettings::set('mail.mailers.smtp.password', $smtp['password'] ?? '');
}
// 发件人配置
if (isset($mail['from'])) {
MailSettings::set('mail.from.address', $mail['from']['address'] ?? '');
MailSettings::set('mail.from.name', $mail['from']['name'] ?? '');
}
// 日志驱动配置
if (isset($mail['mailers']['log'])) {
MailSettings::set('mail.mailers.log.channel', $mail['mailers']['log']['channel'] ?? 'stack');
}
// 第三方服务配置
if (isset($services['postmark'])) {
MailSettings::set('services.postmark.token', $services['postmark']['token'] ?? '');
}
if (isset($services['ses'])) {
MailSettings::set('services.ses.key', $services['ses']['key'] ?? '');
MailSettings::set('services.ses.secret', $services['ses']['secret'] ?? '');
MailSettings::set('services.ses.region', $services['ses']['region'] ?? 'us-east-1');
MailSettings::set('services.ses.token', $services['ses']['token'] ?? '');
}
if (isset($services['resend'])) {
MailSettings::set('services.resend.key', $services['resend']['key'] ?? '');
}
if (isset($services['mailgun'])) {
MailSettings::set('services.mailgun.domain', $services['mailgun']['domain'] ?? '');
MailSettings::set('services.mailgun.secret', $services['mailgun']['secret'] ?? '');
MailSettings::set('services.mailgun.endpoint', $services['mailgun']['endpoint'] ?? 'api.mailgun.net');
}
// 清除 Laravel 配置缓存
Artisan::call('config:clear');
return $this->success('保存成功');
} catch (\Throwable $e) {
return $this->error('保存失败:' . $e->getMessage());
}
}
/**
* 发送测试邮件
*/
#[PostRoute('/test', 'test')]
public function sendTest(): JsonResponse
{
$to = request()->input('to');
if (empty($to)) {
return $this->error('请输入收件人邮箱');
}
try {
Mail::raw('这是一封来自 Xin Admin 的测试邮件,用于验证邮件服务配置是否正确。', function ($message) use ($to) {
$message->to($to)
->subject('Xin Admin 邮件配置测试');
});
return $this->success('测试邮件发送成功');
} catch (\Throwable $e) {
return $this->error('发送失败: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,90 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysSiteConfigGroupFormRequest;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
/**
* 设置分组控制器
*/
#[RequestAttribute('/system/config/group', 'system.config.group')]
class SysSiteConfigGroupController extends BaseController
{
/** 查询设置分组列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$query = SysSiteConfigGroupModel::query();
if (!empty($params['keywordSearch'])) {
$query->whereAny(
['title', 'remark', 'key'],
'like',
'%' . str_replace('%', '\%', $params['keywordSearch']) . '%'
);
}
$data = $query->get()->toArray();
return $this->success($data);
}
/** 创建设置分组 */
#[PostRoute(authorize: 'create')]
public function create(SysSiteConfigGroupFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysSiteConfigGroupModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑设置分组 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysSiteConfigGroupFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysSiteConfigGroupModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除设置分组 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysSiteConfigGroupModel::find($id);
if (empty($model)) {
return $this->error();
}
$count = $model->configs()->count();
if ($count > 0) {
throw new RepositoryException('当前分组有未删除的设置项!');
}
$model->delete();
return $this->success();
}
}
@@ -0,0 +1,115 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Http\Requests\SysSiteConfigItemsFormRequest;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Modules\SystemTool\Services\SysSiteConfigService;
/**
* 系统设置
*/
#[RequestAttribute('/system/config/items', 'system.config.items')]
class SysSiteConfigItemsController extends BaseController
{
protected array $searchField = [
'group_id' => '=',
];
/** 查询设置项列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
if (empty($params['group_id'])) {
throw new RepositoryException('请选择设置分组');
}
$query = SysSiteConfigItemsModel::query();
$data = $this->buildSearch($params, $query)
->orderBy('sort', 'desc')
->get()
->toArray();
return $this->success($data);
}
/** 创建设置项 */
#[PostRoute(authorize: 'create')]
public function create(SysSiteConfigItemsFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysSiteConfigItemsModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑设置项 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysSiteConfigItemsFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysSiteConfigItemsModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除设置项 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysSiteConfigItemsModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->delete();
return $this->success();
}
/** 批量保存设置 */
#[PutRoute('/save', 'save')]
public function save(): JsonResponse
{
$configs = request()->input('configs');
if (empty($configs) || !is_array($configs)) {
return $this->error('请提供设置数据');
}
$result = SysSiteConfigService::batchSaveSiteConfig($configs);
if ($result['success']) {
SysSiteConfigService::refreshSiteConfig();
return $this->success();
}
$message = '设置保存失败:' . $result['message'];
return $this->error($message);
}
/** 刷新设置 */
#[PostRoute('/refreshCache', 'refresh')]
public function refreshCache(): JsonResponse
{
SysSiteConfigService::refreshSiteConfig();
return $this->success();
}
}
@@ -0,0 +1,184 @@
<?php
namespace Modules\SystemTool\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Storage;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Settings\StorageSettings;
/**
* 文件存储配置管理
*/
#[RequestAttribute('/system/storage', 'system.storage')]
class SysStorageController extends BaseController
{
/**
* 获取存储配置(从 DB 加载已保存的值,fallback 到 config 文件)
*/
#[GetRoute('/config', 'config')]
public function getConfig(): JsonResponse
{
$filesystems = config('filesystems');
$default = $filesystems['default'] ?? 'local';
// 获取各驱动配置
$disks = $filesystems['disks'] ?? [];
// 本地存储配置
$local = [
'root' => $disks['local']['root'] ?? storage_path('app/public'),
'url' => $disks['local']['url'] ?? config('app.url') . '/storage',
'visibility' => $disks['local']['visibility'] ?? 'public',
];
// S3 / OSS 配置
$s3 = [
'key' => $disks['s3']['key'] ?? '',
'secret' => $disks['s3']['secret'] ?? '',
'region' => $disks['s3']['region'] ?? '',
'bucket' => $disks['s3']['bucket'] ?? '',
'url' => $disks['s3']['url'] ?? '',
'endpoint' => $disks['s3']['endpoint'] ?? '',
'use_path_style_endpoint' => $disks['s3']['use_path_style_endpoint'] ?? false,
];
// FTP 配置
$ftp = [
'host' => $disks['ftp']['host'] ?? '',
'username' => $disks['ftp']['username'] ?? '',
'password' => $disks['ftp']['password'] ?? '',
'port' => $disks['ftp']['port'] ?? 21,
'root' => $disks['ftp']['root'] ?? '',
'passive' => $disks['ftp']['passive'] ?? true,
'ssl' => $disks['ftp']['ssl'] ?? false,
'timeout' => $disks['ftp']['timeout'] ?? 30,
];
// SFTP 配置
$sftp = [
'host' => $disks['sftp']['host'] ?? '',
'username' => $disks['sftp']['username'] ?? '',
'password' => $disks['sftp']['password'] ?? '',
'port' => $disks['sftp']['port'] ?? 22,
'root' => $disks['sftp']['root'] ?? '',
'timeout' => $disks['sftp']['timeout'] ?? 30,
'private_key' => $disks['sftp']['privateKey'] ?? '',
'passphrase' => $disks['sftp']['passphrase'] ?? '',
];
return $this->success([
'default' => $default,
'local' => $local,
's3' => $s3,
'ftp' => $ftp,
'sftp' => $sftp,
]);
}
/**
* 保存存储配置到数据库
*
* 使用 StorageSettings::set() 将每个配置项写入 应用配置 表,
* 写入后自动更新缓存,并通过全局中间件同步到 config() 运行时。
*/
#[PostRoute('/save', 'save')]
public function saveConfig(): JsonResponse
{
$data = request()->all();
$default = $data['default'] ?? 'local';
$local = $data['local'] ?? [];
$s3 = $data['s3'] ?? [];
$ftp = $data['ftp'] ?? [];
$sftp = $data['sftp'] ?? [];
try {
// 默认存储驱动
StorageSettings::set('filesystems.default', $default);
// 本地存储
if (!empty($local['url'])) {
StorageSettings::set('filesystems.disks.local.url', $local['url']);
}
// S3
if ($default === 's3' || !empty($s3['key'])) {
StorageSettings::set('filesystems.disks.s3.key', $s3['key'] ?? '');
StorageSettings::set('filesystems.disks.s3.secret', $s3['secret'] ?? '');
StorageSettings::set('filesystems.disks.s3.region', $s3['region'] ?? '');
StorageSettings::set('filesystems.disks.s3.bucket', $s3['bucket'] ?? '');
StorageSettings::set('filesystems.disks.s3.url', $s3['url'] ?? '');
StorageSettings::set('filesystems.disks.s3.endpoint', $s3['endpoint'] ?? '');
StorageSettings::set('filesystems.disks.s3.use_path_style_endpoint', (bool) ($s3['use_path_style_endpoint'] ?? false));
}
// FTP
if ($default === 'ftp' || !empty($ftp['host'])) {
StorageSettings::set('filesystems.disks.ftp.host', $ftp['host'] ?? '');
StorageSettings::set('filesystems.disks.ftp.username', $ftp['username'] ?? '');
StorageSettings::set('filesystems.disks.ftp.password', $ftp['password'] ?? '');
StorageSettings::set('filesystems.disks.ftp.port', (int) ($ftp['port'] ?? 21));
StorageSettings::set('filesystems.disks.ftp.root', $ftp['root'] ?? '');
StorageSettings::set('filesystems.disks.ftp.passive', (bool) ($ftp['passive'] ?? true));
StorageSettings::set('filesystems.disks.ftp.ssl', (bool) ($ftp['ssl'] ?? false));
StorageSettings::set('filesystems.disks.ftp.timeout', (int) ($ftp['timeout'] ?? 30));
}
// SFTP
if ($default === 'sftp' || !empty($sftp['host'])) {
StorageSettings::set('filesystems.disks.sftp.host', $sftp['host'] ?? '');
StorageSettings::set('filesystems.disks.sftp.username', $sftp['username'] ?? '');
StorageSettings::set('filesystems.disks.sftp.password', $sftp['password'] ?? '');
StorageSettings::set('filesystems.disks.sftp.port', (int) ($sftp['port'] ?? 22));
StorageSettings::set('filesystems.disks.sftp.root', $sftp['root'] ?? '');
StorageSettings::set('filesystems.disks.sftp.timeout', (int) ($sftp['timeout'] ?? 30));
StorageSettings::set('filesystems.disks.sftp.privateKey', $sftp['private_key'] ?? '');
StorageSettings::set('filesystems.disks.sftp.passphrase', $sftp['passphrase'] ?? '');
}
// 清除 Laravel 配置缓存
Artisan::call('config:clear');
return $this->success('保存成功');
} catch (\Throwable $e) {
return $this->error('保存失败:' . $e->getMessage());
}
}
/**
* 测试存储连接
*/
#[PostRoute('/test', 'test')]
public function testConnection(): JsonResponse
{
$disk = request()->input('disk', 'local');
try {
$storage = Storage::disk($disk);
$testFile = 'storage_test_' . time() . '.txt';
$testContent = 'XinAdmin 存储测试文件 - ' . date('Y-m-d H:i:s');
// 测试写入
$storage->put($testFile, $testContent);
// 测试读取
$readContent = $storage->get($testFile);
if ($readContent !== $testContent) {
return $this->error('读取测试失败:内容不匹配');
}
// 测试删除
$storage->delete($testFile);
return $this->success('存储连接测试成功');
} catch (\Throwable $e) {
return $this->error('连接测试失败: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\SystemTool\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Modules\SystemTool\Base\SettingsDefinition;
use Modules\SystemTool\Enum\ESettingType;
use Symfony\Component\HttpFoundation\Response;
/**
* 全局中间件 — 从缓存加载 DB 应用设置到 config() 运行时
*
* 每次请求自动执行,确保 config('mail.*') / config('filesystems.*') 等
* 返回的是数据库中的值而非配置文件的默认值。
*
* 聚合缓存键名: SettingsDefinition::AGGREGATE_CACHE_KEY
* 在 SettingsDefinition::set() 写入时会自动清除该缓存,
* 使下一次请求重新从 DB 加载最新值。
*/
class LoadAppSettingsMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// 从聚合缓存加载(rememberForever = 永不过期,仅 set() 时清除)
$settings = Cache::rememberForever(SettingsDefinition::AGGREGATE_CACHE_KEY, function () {
return $this->loadAllFromDB();
});
// 写入 Laravel config() 运行时
foreach ($settings as $key => $value) {
config([$key => $value]);
}
return $next($request);
}
/**
* 从数据库加载所有应用设置,按类型转换后返回 key → value 数组
*/
protected function loadAllFromDB(): array
{
$rows = DB::table(SettingsDefinition::getTableName())->get();
$result = [];
foreach ($rows as $row) {
$result[$row->key] = match ((int) $row->type) {
ESettingType::String->value => $row->s,
ESettingType::Bool->value => is_null($row->n) ? null : (bool) $row->n,
ESettingType::Number->value => is_null($row->n) ? null : (int) $row->n,
ESettingType::Array->value => is_null($row->e) ? null : json_decode($row->e, true),
ESettingType::Object->value => is_null($row->e) ? null : unserialize(base64_decode($row->e)),
ESettingType::EncryptedString->value => is_null($row->e) ? null : Crypt::decrypt(base64_decode($row->e)),
default => $row->s ?? null,
};
}
return $result;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rules\Exists;
use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysFileModel;
class SysCarouselFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'title' => 'required|string|max:100',
'image_id' => ['required', 'integer', new Exists(SysFileModel::class, 'id')],
'link' => 'nullable|string|max:500',
'status' => 'nullable|integer|in:0,1',
'sort' => 'nullable|integer',
];
}
public function messages(): array
{
return [
'title.required' => '轮播图标题是必填的',
'title.max' => '轮播图标题不能超过 :max 个字符',
'image_id.required' => '轮播图图片是必填的',
'link.max' => '跳转链接不能超过 :max 个字符',
'status.in' => '状态值只能是 0(启用)或 1(禁用)',
'sort.integer' => '排序必须是整数',
];
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysDictFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
return [
'name' => 'required|max:100',
'code' => 'required|max:100|unique:sys_dict,code',
'describe' => 'nullable|max:500',
'status' => 'required|in:0,1',
'sort' => 'nullable|integer|min:0',
];
} else {
$id = $this->route('id');
return [
'name' => 'required|max:100',
'code' => [
'required',
'max:100',
Rule::unique('sys_dict', 'code')->ignore($id)
],
'describe' => 'nullable|max:500',
'status' => 'required|in:0,1',
'sort' => 'nullable|integer|min:0',
];
}
}
public function messages(): array
{
return [
'name.required' => '字典名称不能为空',
'name.max' => '字典名称不能超过100个字符',
'code.required' => '字典编码不能为空',
'code.max' => '字典编码不能超过100个字符',
'code.unique' => '字典编码已存在',
'status.required' => '状态不能为空',
'status.in' => '状态格式错误',
'sort.integer' => '排序必须为整数',
'sort.min' => '排序不能小于0',
];
}
}
@@ -0,0 +1,67 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysDictItemFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
$dict_id = $this->input('dict_id');
return [
'dict_id' => 'required|exists:sys_dict,id',
'label' => 'required|max:100',
'value' => [
'required',
'max:100',
Rule::unique('sys_dict_item')->where(function ($query) use ($dict_id) {
return $query->where('dict_id', $dict_id);
})
],
'color' => 'nullable|string',
'status' => 'required|in:0,1',
'sort' => 'nullable|integer|min:0',
];
} else {
$id = $this->route('id');
$dict_id = $this->input('dict_id');
return [
'dict_id' => 'required|exists:sys_dict,id',
'label' => 'required|max:100',
'value' => [
'required',
'max:100',
Rule::unique('sys_dict_item')->where(function ($query) use ($dict_id) {
return $query->where('dict_id', $dict_id);
})->ignore($id)
],
'color' => 'nullable|string',
'status' => 'required|in:0,1',
'sort' => 'nullable|integer|min:0',
];
}
}
public function messages(): array
{
return [
'dict_id.required' => '字典ID不能为空',
'dict_id.exists' => '字典不存在',
'label.required' => '字典标签不能为空',
'label.max' => '字典标签不能超过100个字符',
'value.required' => '字典键值不能为空',
'value.max' => '字典键值不能超过100个字符',
'value.unique' => '该字典下已存在相同的键值',
'color.in' => '颜色格式错误',
'status.required' => '状态不能为空',
'status.in' => '状态格式错误',
'sort.integer' => '排序必须为整数',
'sort.min' => '排序不能小于0',
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Support\Facades\DB;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysFileGroupFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
return [
'parent_id' => [
'required',
'integer',
function ($attribute, $value, $fail) {
if ($value != 0 && !DB::table('sys_file_group')->where('id', $value)->exists()) {
$fail('选择的上级分组不存在。');
}
},
],
'name' => 'required|string|max:255',
'describe' => 'sometimes|string|max:500',
'sort' => 'sometimes|integer|min:0',
];
} else {
return [
'name' => 'required|string|max:255',
'describe' => 'sometimes|string|max:500',
'sort' => 'sometimes|integer|min:0',
];
}
}
public function messages(): array
{
return [
'name.required' => '分组名称不能为空',
'name.string' => '分组名称必须是字符串',
'name.max' => '分组名称不能超过50个字符',
'sort.integer' => '分组排序必须是整数',
'sort.min' => '分组排序不能为负数',
'describe.string' => '分组描述必须是字符串',
'describe.max' => '分组描述不能超过500个字符',
];
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
class SysFileMoveOrCopyRequest extends FormRequest
{
public function rules(): array
{
return [
'group_id' => [
'required',
'integer',
function ($attribute, $value, $fail) {
if ($value != 0 && !DB::table('sys_file_group')->where('id', $value)->exists()) {
$fail('选择的上级部门不存在。');
}
},
],
'ids' => [
'required',
function ($attribute, $value, $fail) {
// 检查是否为数字
if (is_numeric($value)) {
return;
}
// 检查是否为数字数组
if (is_array($value)) {
foreach ($value as $item) {
if (!is_numeric($item)) {
$fail("$attribute 中的元素必须全部是数字");
return;
}
}
return;
}
$fail("$attribute 必须是数字或数字数组");
},
]
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rules\Exists;
use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysFileModel;
class SysGridNavFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'title' => 'required|string|max:100',
'image_id' => ['required', 'integer', new Exists(SysFileModel::class, 'id')],
'link' => 'nullable|string|max:500',
'status' => 'nullable|integer|in:0,1',
'sort' => 'nullable|integer',
];
}
public function messages(): array
{
return [
'title.required' => '导航标题是必填的',
'title.max' => '导航标题不能超过 :max 个字符',
'image_id.required' => '导航图标是必填的',
'link.max' => '跳转链接不能超过 :max 个字符',
'status.in' => '状态值只能是 0(启用)或 1(禁用)',
'sort.integer' => '排序必须是整数',
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
class SysSiteConfigGroupFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
return [
'key' => ['required', Rule::unique(SysSiteConfigGroupModel::class, 'key')],
'title' => 'required',
'remark' => 'sometimes|required',
];
} else {
$id = $this->route('id');
return [
'key' => ['required', Rule::unique(SysSiteConfigGroupModel::class, 'key')->ignore($id)],
'title' => 'required',
'remark' => 'sometimes|required',
];
}
}
public function messages(): array
{
return [
'key.required' => '键名字段是必填的',
'key.unique' => '键名已存在',
'title.required' => '标题字段是必填的',
'remark.required' => '备注字段是必填的',
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\SystemTool\Http\Requests;
use Illuminate\Validation\Rules\Exists;
use Modules\Common\Http\Requests\BaseFormRequest;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Modules\SystemTool\Rules\SysSiteConfigTypeRule;
class SysSiteConfigItemsFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
$rules = [
'title' => 'required|string',
'key' => ['required', 'string', 'min:2', 'max:255'],
'group_id' => ['required', 'integer', new Exists(SysSiteConfigGroupModel::class, 'id')],
'type' => ['required', 'string', new SysSiteConfigTypeRule],
'describe' => 'nullable|string',
'options' => [
'sometimes',
'nullable',
'string',
'regex:/^(?:[^=\n]+=[^=\n]+)(?:\n[^=\n]+=[^=\n]+)*$/',
],
'props' => [
'sometimes',
'nullable',
'string',
'regex:/^(?:[^=\n]+=[^=\n]+)(?:\n[^=\n]+=[^=\n]+)*$/',
],
'sort' => 'nullable|integer',
'values' => 'nullable|string',
];
if (!$this->isUpdate()) {
$rules['key'][] = function ($attribute, $value, $fail) {
$groupId = $this->input('group_id');
$exists = SysSiteConfigItemsModel::query()
->where('group_id', $groupId)
->where('key', $value)
->exists();
if ($exists) {
$fail('该键名在此分组中已存在');
}
};
}
return $rules;
}
public function messages(): array
{
return [
'title.required' => '标题字段是必填的',
'title.string' => '标题字段必须是字符串',
'key.required' => '键名字段是必填的',
'key.string' => '键名字段必填是字符串',
'key.min' => '键名至少需要 :min 个字符',
'key.max' => '键名不能超过 :max 个字符',
'group_id.required' => '分组ID是必填的',
'group_id.exists' => '选择的分组不存在',
'type.required' => '类型字段是必填的',
'describe.string' => '描述必须是字符串',
'options.regex' => '选项格式不正确,应为 key=value 格式,多个用换行分隔',
'props.regex' => '属性格式不正确,应为 key=value 格式,多个用换行分隔',
'sort.integer' => '排序必须是整数',
'values.string' => '值必须是字符串',
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* 首页轮播图模型
*/
class SysCarouselModel extends Model
{
protected $table = 'sys_carousel';
protected $casts = [
'status' => 'int',
'sort' => 'int',
'image_id' => 'array',
];
protected $fillable = [
'title',
'image_id',
'link',
'status',
'sort',
];
protected $with = ['image'];
/**
* 关联图片
* @return HasOne
*/
public function image(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 字典数据模型
*/
class SysDictItemModel extends Model
{
protected $table = 'sys_dict_item';
protected $fillable = [
'dict_id',
'label',
'value',
'color',
'status',
'sort',
];
protected $casts = [
'dict_id' => 'integer',
'status' => 'integer',
'sort' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime'
];
/**
* 字典项关联字典表
*/
public function dict(): BelongsTo
{
return $this->belongsTo(SysDictModel::class, 'dict_id', 'id');
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 字典类型模型
*/
class SysDictModel extends Model
{
protected $table = 'sys_dict';
protected $fillable = [
'name',
'code',
'describe',
'status',
'sort',
];
protected $casts = [
'status' => 'integer',
'sort' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime'
];
/**
* 关联字典子项
*/
public function dictItems(): HasMany
{
return $this->hasMany(SysDictItemModel::class, 'dict_id', 'id')
->orderBy('sort')
->orderBy('id');
}
/**
* 获取所有字典及其子项
* @return array
*/
public static function getAllDictWithItems(): array
{
return static::with('dictItems')
->where('status', 0)
->orderBy('sort')
->orderBy('id')
->get()
->map(function ($dict) {
return [
'id' => $dict->id,
'name' => $dict->name,
'code' => $dict->code,
'describe' => $dict->describe,
'status' => $dict->status,
'sort' => $dict->sort,
'dict_items' => $dict->dictItems->filter(fn($item) => $item->status === 0)->map(function ($item) {
return [
'id' => $item->id,
'label' => $item->label,
'value' => $item->value,
'color' => $item->color,
'sort' => $item->sort,
];
})->values()->toArray(),
];
})->toArray();
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class FileGroup
*/
class SysFileGroupModel extends Model
{
protected $table = 'sys_file_group';
protected $primaryKey = 'id';
protected $casts = [
'sort' => 'int',
'parent_id' => 'int'
];
protected $fillable = [
'name',
'parent_id',
'sort',
'describe',
];
protected $appends = ['countFiles'];
/**
* 获取父级分组
*/
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id', 'id');
}
/**
* 获取子级分组
*/
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id', 'id');
}
/**
* 获取分组下的文件
*/
public function files(): HasMany
{
return $this->hasMany(SysFileModel::class, 'group_id', 'id');
}
/**
* 获取分组下的文件数量
*/
public function getCountFilesAttribute(): int
{
return $this->files()->count();
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Modules\SystemTool\Models;
use App\Models\UserModel;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Modules\SystemTool\Enum\FileType;
use Modules\SystemUser\Models\SysUserModel;
/**
* Class File
*/
class SysFileModel extends Model
{
use SoftDeletes;
protected $table = 'sys_file';
protected $primaryKey = 'id';
protected $casts = [
'group_id' => 'int',
'channel' => 'int',
'file_type' => 'int',
'file_size' => 'int',
'uploader_id' => 'int',
];
protected $fillable = [
'group_id',
'disk',
'channel',
'file_type',
'file_name',
'file_path',
'file_size',
'file_ext',
'uploader_id',
];
protected $appends = ['preview_url', 'file_url'];
/**
* 获取文件所属分组
*/
public function group(): BelongsTo
{
return $this->belongsTo(SysFileGroupModel::class, 'group_id', 'id');
}
/**
* 获取上传者
* 根据channel字段判断是系统用户还是App用户
*/
public function uploader(): BelongsTo
{
// channel 10:系统用户 20:App用户端
if ($this->channel == 10) {
return $this->belongsTo(SysUserModel::class, 'uploader_id', 'id');
} else {
return $this->belongsTo(UserModel::class, 'uploader_id', 'id');
}
}
protected function previewUrl(): Attribute
{
return new Attribute(
get: function ($value, array $data) {
try {
// 图片类型:直接返回图片URL作为预览
if ($data['file_type'] === FileType::IMAGE->value) {
if($data['disk'] === 'local') {
return Storage::disk($data['disk'])->url($data['file_path']);
}
return Storage::disk($data['disk'])->temporaryUrl(
$data['file_path'], now()->plus(minutes: 5)
);
}
$fileType = FileType::tryFrom($data['file_type']);
// 其他类型:返回默认类型图标
$previewPath = $fileType?->previewPath() ?? FileType::ANNEX->previewPath();
return config('app.url') . '/' . $previewPath;
} catch (\Throwable $e) {
// 发生异常时返回默认图标
return config('app.url') . '/' . FileType::ANNEX->previewPath();
}
}
);
}
/**
* 获取文件访问URL
*/
protected function fileUrl(): Attribute
{
return new Attribute(
get: function ($value, array $data) {
try {
return Storage::disk($data['disk'])->url($data['file_path']);
} catch (\Throwable $e) {
return null;
}
}
);
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* 首页宫格导航模型
*/
class SysGridNavModel extends Model
{
protected $table = 'sys_grid_nav';
protected $casts = [
'status' => 'int',
'sort' => 'int',
'image_id' => 'int',
];
protected $fillable = [
'title',
'image_id',
'link',
'status',
'sort',
];
protected $with = ['image'];
/**
* 关联图片
* @return HasOne
*/
public function image(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'image_id');
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class SiteConfig Group
*/
class SysSiteConfigGroupModel extends Model
{
protected $table = 'sys_site_config_group';
protected $fillable = [
'title',
'key',
'remark',
];
/**
* 关联设置项目
* @return HasMany
*/
public function configs(): HasMany
{
return $this->hasMany(SysSiteConfigItemsModel::class ,'group_id', 'id');
}
}
@@ -0,0 +1,101 @@
<?php
namespace Modules\SystemTool\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemTool\Enum\SiteConfigType;
/**
* Class Setting
*/
class SysSiteConfigItemsModel extends Model
{
protected $table = 'sys_site_config_items';
protected $casts = [
'group_id' => 'int',
'sort' => 'int',
];
protected $fillable = [
'key',
'title',
'describe',
'values',
'type',
'options',
'props',
'group_id',
'sort',
];
protected $appends = ['options_json', 'props_json'];
/**
* 关联设置
* @return BelongsTo
*/
public function group(): BelongsTo
{
return $this->belongsTo(SysSiteConfigGroupModel::class, 'id', 'group_id');
}
protected function values(): Attribute
{
return Attribute::make(
get: function (mixed $value, array $attributes) {
$type_enum = SiteConfigType::tryFrom($attributes['type']);
if($type_enum) {
return $type_enum->castValue($value);
} else {
return $value;
}
},
);
}
public function getOptionsJsonAttribute(): string
{
if(empty($this->options)) {
return "{}";
}
$data = [];
$value = explode("\n", $this->options);
foreach ($value as $item) {
$item = explode('=',$item);
if(count($item) < 2) {
continue;
}
$data[] = [
'label' => $item[1],
'value' => $item[0]
];
}
return json_encode($data);
}
public function getPropsJsonAttribute(): string
{
if(empty($this->props)) {
return "{}";
}
$data = [];
$value = explode("\n",$this->props);
foreach ($value as $item) {
$item = explode('=',$item);
if(count($item) < 2) {
continue;
}
if($item[1] === 'false') {
$data[$item[0]] = false;
}elseif ($item[1] === 'true') {
$data[$item[0]] = true;
}else {
$data[$item[0]] = $item[1];
}
}
return json_encode($data);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\SystemTool\Providers;
use Laravel\Boost\Boost;
use Illuminate\Support\ServiceProvider;
use Modules\AnnoRoute\AnnoRoute;
use Modules\SystemTool\Ai\Boots\Reasonix;
use Modules\SystemTool\Services\SysSiteConfigService;
class SystemToolServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(SysSiteConfigService::class, SysSiteConfigService::class);
}
/**
* Bootstrap any application services.
*/
public function boot(AnnoRoute $annoRoute): void
{
Boost::registerAgent('reasonix', Reasonix::class);
// 注册路由
$annoRoute->register(base_path('modules/SystemTool/Http/Controllers'));
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\SystemTool\Rules;
use Closure;
use Modules\SystemTool\Enum\SiteConfigType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class SysSiteConfigTypeRule implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (SiteConfigType::tryFrom($value) == null) {
$fail('设置类型不存在!');
}
}
}
@@ -0,0 +1,477 @@
<?php
namespace Modules\SystemTool\Services;
use App\Exceptions\HttpResponseException;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Modules\SystemTool\Enum\FileType;
use Modules\SystemTool\Models\SysFileModel;
use Modules\SystemTool\Settings\StorageSettings;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 文件服务类
*/
class SysFileService
{
/**
* 获取回收站列表
* @param array $params
* @return array
*/
public function getTrashedList(array $params = []): array
{
$pageSize = $params['pageSize'] ?? 10;
return SysFileModel::onlyTrashed()
->paginate($pageSize)
->toArray();
}
/**
* 获取存储磁盘实例
*/
protected function disk($disk = null): FilesystemAdapter
{
if(!$disk) {
$disk = config('filesystems.default');
}
if ($disk === 's3' && !self::isS3Configured()) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.storage.s3_not_configured')]);
}
/** @var FilesystemAdapter */
return Storage::disk($disk);
}
/**
* 生成存储路径
*/
protected function generateStoragePath(string $extension): string
{
return date('Ymd') . '/' . uniqid() . '.' . $extension;
}
/**
* 上传文件
* @param UploadedFile $file 文件
* @param int $groupId 分组 ID
* @param int $channel 上传来源 0:匿名用户,10:后台用户,20:APP用户
* @param int|null $user_id 上传用户 ID
* @return array
*/
public function upload(UploadedFile $file, int $groupId = 0, int $channel = 0, ?int $user_id = null): array
{
// 文件扩展名
$fileExt = strtolower($file->getClientOriginalExtension() ?: $file->extension());
if (empty($fileExt)) {
throw new HttpResponseException(['success' => false, 'msg' => '无法识别的文件扩展名']);
}
// 推断文件类型
$fileType = FileType::guessFromExtension($fileExt);
// 获取储存路径
$storagePath = $this->generateStoragePath($fileExt);
// 获取磁盘
$disk = StorageSettings::get('filesystems.default', 'local');
// 存储文件并设置可见性
$stored = $this->disk($disk)->put($storagePath, $file->getContent(), 'public');
if (!$stored) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.upload_failed')]);
}
// 保存到数据库
$model = new SysFileModel();
$model->disk = $disk;
$model->group_id = $groupId;
$model->channel = $channel;
$model->file_type = $fileType->value;
$model->file_path = $storagePath;
$model->file_name = $file->getClientOriginalName();
$model->file_size = $file->getSize();
$model->file_ext = $fileExt;
$model->uploader_id = $user_id;
$model->save();
return $model->toArray();
}
/**
* 软删除文件(移入回收站)
*/
public function delete(int $id): bool
{
$file = SysFileModel::find($id);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
return (bool) $file->delete();
}
/**
* 批量软删除文件
*/
public function batchDelete(array $fileIds): int
{
return SysFileModel::whereIn('id', $fileIds)->delete();
}
/**
* 恢复已删除的文件
*/
public function restore(int $fileId): bool
{
$file = SysFileModel::withTrashed()->find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
return $file->restore();
}
/**
* 批量恢复文件
*/
public function batchRestore(array $fileIds): int
{
return SysFileModel::withTrashed()->whereIn('id', $fileIds)->restore();
}
/**
* 彻底删除文件(含物理删除)
*/
public function forceDelete(int $fileId): bool
{
$file = SysFileModel::withTrashed()->find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
// 删除物理文件
$this->disk($file->disk)->delete($file->file_path);
return (bool) $file->forceDelete();
}
/**
* 批量彻底删除文件
*/
public function batchForceDelete(array $fileIds): int
{
$files = SysFileModel::withTrashed()->whereIn('id', $fileIds)->get();
$count = 0;
foreach ($files as $file) {
$this->disk($file->disk)->delete($file->file_path);
$file->forceDelete();
$count++;
}
return $count;
}
/**
* 下载文件
*/
public function download(int $fileId, ?string $filename = null): StreamedResponse
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
$downloadName = $filename ?? $file->file_name;
return $this->disk($file->disk)->download($file->file_path, $downloadName);
}
/**
* 获取文件流式响应(用于在线预览等场景)
*/
public function stream(int $fileId): StreamedResponse
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
$mimeType = $this->getMimeType($file->file_ext);
return response()->stream(
function () use ($file) {
$stream = $this->disk($file->disk)->readStream($file->file_path);
fpassthru($stream);
if (is_resource($stream)) {
fclose($stream);
}
},
200,
[
'Content-Type' => $mimeType,
'Content-Disposition' => 'inline; filename="' . $file->file_name . '"',
]
);
}
/**
* 获取文件内容
*/
public function getContent(int $fileId): string
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
return $this->disk($file->disk)->get($file->file_path);
}
/**
* 获取文件访问URL
*/
public function getUrl(int $fileId): ?string
{
$file = SysFileModel::find($fileId);
if (!$file) {
return null;
}
return $this->disk($file->disk)->url($file->file_path);
}
/**
* 根据路径获取访问URL
*/
public function getUrlByPath(string $path, ?string $disk = null): string
{
return $this->disk($disk)->url($path);
}
/**
* 获取文件元数据信息
*/
public function getMetadata(int $fileId): ?array
{
$file = SysFileModel::find($fileId);
if (!$file) {
return null;
}
$disk = $this->disk($file->disk);
return [
'id' => $file->id,
'name' => $file->file_name,
'path' => $file->file_path,
'disk' => $file->disk,
'size' => $file->file_size,
'extension' => $file->file_ext,
'mime_type' => $this->getMimeType($file->file_ext),
'last_modified' => $disk->exists($file->file_path)
? date('Y-m-d H:i:s', $disk->lastModified($file->file_path))
: null,
'url' => $this->getUrl($file->id),
'group_id' => $file->group_id,
'uploader_id' => $file->uploader_id,
'created_at' => $file->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $file->updated_at?->format('Y-m-d H:i:s'),
];
}
/**
* 复制文件
*/
public function copy(int $fileId, int $targetGroupId = 0): array
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
$newPath = $this->generateStoragePath($file->file_ext);
$this->disk($file->disk)->copy($file->file_path, $newPath);
// 创建新的文件记录
$newFile = SysFileModel::create([
'disk' => $file->disk,
'group_id' => $targetGroupId,
'channel' => $file->channel,
'file_name' => $file->file_name,
'file_type' => $file->file_type,
'file_path' => $newPath,
'file_size' => $file->file_size,
'file_ext' => $file->file_ext,
'uploader_id' => Auth::id(),
]);
return $newFile->toArray();
}
/**
* 批量复制文件
*/
public function batchCopy(array $fileIds, int $targetGroupId = 0): bool
{
$files = SysFileModel::whereIn('id', $fileIds)->get();
$fileArray = [];
foreach ($files as $file) {
$newPath = $this->generateStoragePath($file->file_ext);
$this->disk($file->disk)->copy($file->file_path, $newPath);
$fileArray[] =[
'disk' => $file->disk,
'group_id' => $targetGroupId,
'channel' => $file->channel,
'file_name' => $file->file_name,
'file_type' => $file->file_type,
'file_path' => $newPath,
'file_size' => $file->file_size,
'file_ext' => $file->file_ext,
'uploader_id' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
];
}
return SysFileModel::insert($fileArray);
}
/**
* 移动文件
*/
public function move(int $fileId, int $groupId): bool
{
$file = SysFileModel::find($fileId);
return $file->update(['group_id' => $groupId]);
}
/**
* 批量移动
*/
public function batchMove(array $fileIds, int $groupId): int
{
return SysFileModel::whereIn('id', $fileIds)->update(['group_id' => $groupId]);
}
/**
* 重命名文件
*/
public function rename(int $fileId, string $newName): bool
{
$file = SysFileModel::find($fileId);
if (!$file) {
throw new HttpResponseException(['success' => false, 'msg' => __('system.file.not_found')]);
}
return $file->update(['file_name' => $newName]);
}
/**
* 检查文件是否存在
*/
public function exists(int $fileId): bool
{
$file = SysFileModel::find($fileId);
if (!$file) {
return false;
}
return $this->disk($file->disk)->exists($file->file_path);
}
/**
* 获取文件大小(字节)
*/
public function getSize(int $fileId): ?int
{
$file = SysFileModel::find($fileId);
if (!$file) {
return null;
}
return $this->disk($file->disk)->size($file->file_path);
}
/**
* 获取MIME类型
*/
protected function getMimeType(string $extension): string
{
$mimeTypes = [
// 图片
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
'bmp' => 'image/bmp',
'svg' => 'image/svg+xml',
// 音频
'mp3' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg' => 'audio/ogg',
'flac' => 'audio/flac',
'aac' => 'audio/aac',
// 视频
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mkv' => 'video/x-matroska',
'mov' => 'video/quicktime',
'avi' => 'video/x-msvideo',
// 文档
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// 压缩包
'zip' => 'application/zip',
'rar' => 'application/vnd.rar',
'7z' => 'application/x-7z-compressed',
// 其他
'json' => 'application/json',
'xml' => 'application/xml',
'txt' => 'text/plain',
];
return $mimeTypes[strtolower($extension)] ?? 'application/octet-stream';
}
/**
* 清空回收站文件
*/
public function cleanTrashed(): int
{
$expiredFiles = SysFileModel::onlyTrashed()->get();
$count = 0;
foreach ($expiredFiles as $file) {
$this->disk($file->disk)->delete($file->file_path);
$file->forceDelete();
$count++;
}
return $count;
}
/**
* 检查 S3 配置是否有效
* @return bool
*/
public static function isS3Configured(): bool
{
$storageConfig = config('filesystems.disks.s3');
if (empty($storageConfig)) {
return false;
}
return !empty($storageConfig['key'])
&& !empty($storageConfig['secret'])
&& !empty($storageConfig['bucket'])
&& !empty($storageConfig['region']);
}
}
@@ -0,0 +1,130 @@
<?php
namespace Modules\SystemTool\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\SystemTool\Enum\SiteConfigType;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
use Throwable;
/**
* 系统设置服务
*/
class SysSiteConfigService
{
/**
* 缓存过期时间(秒),默认30天
*/
private const int|float CACHE_TTL = 60 * 60 * 24 * 30;
/**
* 缓存键
*/
private const string CACHE_KEY = 'site_config';
/**
* 刷新系统设置缓存
* @return bool
*/
public static function refreshSiteConfig(): bool
{
try {
$configs = SysSiteConfigGroupModel::with('configs')
->get()
->mapWithKeys(fn($group) => [
$group->key => $group->configs
->mapWithKeys(fn($config) => [ $config->key => $config->values ])
->toArray()
])
->toArray();
Cache::put(self::CACHE_KEY, $configs, self::CACHE_TTL);
Log::info('系统配置缓存已刷新', ['settings_count' => $configs]);
return true;
} catch (Throwable $e) {
Log::error('刷新系统配置缓存失败', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
}
/**
* 获取设置
* 格式:'group.key' 或 'group',为null时返回所有配置
*/
public static function getSiteConfig(?string $name = null, mixed $default = null): mixed
{
$configs = Cache::get(self::CACHE_KEY);
// 缓存不存在时重新加载
if (empty($configs)) {
self::refreshSiteConfig();
$configs = Cache::get(self::CACHE_KEY, []);
}
// 返回所有配置
if (is_null($name)) {
return $configs;
}
// 解析配置路径
$keys = explode('.', $name);
// 支持多级获取:group.key 或 group
if (count($keys) === 2) {
return $configs[$keys[0]][$keys[1]] ?? $default;
} elseif (count($keys) === 1) {
return $configs[$keys[0]] ?? $default;
}
return $default;
}
/**
* 批量保存设置项
* [['id' => int, 'values' => mixed], ...]
*/
public static function batchSaveSiteConfig(array $configs): array
{
$errors = [];
$ids = array_column($configs, 'id');
$items = SysSiteConfigItemsModel::whereIn('id', $ids)->get()->keyBy('id');
// 先验证所有 ID 是否存在,收集缺失项信息
foreach ($configs as $item) {
$id = (int)$item['id'];
if (!$items->has($id)) {
$errors[] = [
'key' => "unknown_{$id}",
'title' => "未知设置(ID: {$id})",
];
}
}
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
try {
DB::transaction(function () use ($configs, $items) {
foreach ($configs as $item) {
$id = (int)$item['id'];
$model = $items->get($id);
$value = $item['value'];
$model->values = is_array($value) || is_object($value) ? json_encode($value) : (string)$value;
$model->save();
}
});
return ['success' => true, 'errors' => []];
} catch (Throwable $e) {
return ['success' => false, 'errors' => $e->getMessage()];
}
}
}
@@ -0,0 +1,79 @@
<?php
namespace Modules\SystemTool\Settings;
use Modules\SystemTool\Attributes\Setting;
use Modules\SystemTool\Base\SettingsDefinition;
use Modules\SystemTool\Enum\ESettingType;
/**
* AI 服务配置定义
*
* 对应 config/ai.php 中的可配置项。
* 敏感值(API Key、Token)使用 EncryptedString 类型存储。
*/
#[Setting(config: 'ai.default', type: ESettingType::String, description: '默认 AI 供应商')]
#[Setting(config: 'ai.default_for_images', type: ESettingType::String, description: '默认图片生成供应商')]
#[Setting(config: 'ai.default_for_audio', type: ESettingType::String, description: '默认音频生成供应商')]
#[Setting(config: 'ai.default_for_transcription', type: ESettingType::String, description: '默认语音转文字供应商')]
// Anthropic
#[Setting(config: 'ai.providers.anthropic.key', type: ESettingType::EncryptedString, description: 'Anthropic API Key')]
#[Setting(config: 'ai.providers.anthropic.url', type: ESettingType::String, description: 'Anthropic API URL')]
// Azure
#[Setting(config: 'ai.providers.azure.key', type: ESettingType::EncryptedString, description: 'Azure OpenAI API Key')]
#[Setting(config: 'ai.providers.azure.url', type: ESettingType::String, description: 'Azure OpenAI URL')]
#[Setting(config: 'ai.providers.azure.api_version', type: ESettingType::String, description: 'Azure API 版本')]
#[Setting(config: 'ai.providers.azure.deployment', type: ESettingType::String, description: 'Azure 模型部署名')]
#[Setting(config: 'ai.providers.azure.embedding_deployment', type: ESettingType::String, description: 'Azure Embedding 模型部署名')]
#[Setting(config: 'ai.providers.azure.image_deployment', type: ESettingType::String, description: 'Azure 图片模型部署名')]
// Bedrock
#[Setting(config: 'ai.providers.bedrock.region', type: ESettingType::String, description: 'Bedrock 区域')]
#[Setting(config: 'ai.providers.bedrock.key', type: ESettingType::EncryptedString, description: 'Bedrock API Key')]
#[Setting(config: 'ai.providers.bedrock.access_key_id', type: ESettingType::EncryptedString, description: 'Bedrock Access Key ID')]
#[Setting(config: 'ai.providers.bedrock.secret_access_key', type: ESettingType::EncryptedString, description: 'Bedrock Secret Access Key')]
#[Setting(config: 'ai.providers.bedrock.session_token', type: ESettingType::EncryptedString, description: 'Bedrock Session Token')]
// Cohere
#[Setting(config: 'ai.providers.cohere.key', type: ESettingType::EncryptedString, description: 'Cohere API Key')]
// DeepSeek
#[Setting(config: 'ai.providers.deepseek.key', type: ESettingType::EncryptedString, description: 'DeepSeek API Key')]
// ElevenLabs
#[Setting(config: 'ai.providers.eleven.key', type: ESettingType::EncryptedString, description: 'ElevenLabs API Key')]
// Gemini
#[Setting(config: 'ai.providers.gemini.key', type: ESettingType::EncryptedString, description: 'Gemini API Key')]
#[Setting(config: 'ai.providers.gemini.url', type: ESettingType::String, description: 'Gemini API URL')]
// Groq
#[Setting(config: 'ai.providers.groq.key', type: ESettingType::EncryptedString, description: 'Groq API Key')]
// Jina
#[Setting(config: 'ai.providers.jina.key', type: ESettingType::EncryptedString, description: 'Jina API Key')]
// Mistral
#[Setting(config: 'ai.providers.mistral.key', type: ESettingType::EncryptedString, description: 'Mistral API Key')]
// Ollama
#[Setting(config: 'ai.providers.ollama.key', type: ESettingType::EncryptedString, description: 'Ollama API Key')]
#[Setting(config: 'ai.providers.ollama.url', type: ESettingType::String, description: 'Ollama API URL')]
// OpenAI
#[Setting(config: 'ai.providers.openai.key', type: ESettingType::EncryptedString, description: 'OpenAI API Key')]
#[Setting(config: 'ai.providers.openai.url', type: ESettingType::String, description: 'OpenAI API URL')]
// OpenRouter
#[Setting(config: 'ai.providers.openrouter.key', type: ESettingType::EncryptedString, description: 'OpenRouter API Key')]
// VoyageAI
#[Setting(config: 'ai.providers.voyageai.key', type: ESettingType::EncryptedString, description: 'VoyageAI API Key')]
// xAI
#[Setting(config: 'ai.providers.xai.key', type: ESettingType::EncryptedString, description: 'xAI API Key')]
class AiSettings extends SettingsDefinition
{
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\SystemTool\Settings;
use Modules\SystemTool\Attributes\Setting;
use Modules\SystemTool\Base\SettingsDefinition;
use Modules\SystemTool\Enum\ESettingType;
/**
* 邮件与服务配置定义
*
* 对应 config/mail.php 和 config/services.php 中的可配置项。
* 敏感值(密码、Token、API Key)使用 EncryptedString 类型存储。
*/
#[Setting(config: 'mail.default', type: ESettingType::String, description: '默认邮件驱动(smtp / failover / roundrobin / log 等)')]
#[Setting(config: 'mail.mailers.smtp.host', type: ESettingType::String, description: 'SMTP 主机')]
#[Setting(config: 'mail.mailers.smtp.port', type: ESettingType::Number, description: 'SMTP 端口')]
#[Setting(config: 'mail.mailers.smtp.username', type: ESettingType::String, description: 'SMTP 用户名')]
#[Setting(config: 'mail.mailers.smtp.password', type: ESettingType::EncryptedString, description: 'SMTP 密码')]
#[Setting(config: 'mail.from.address', type: ESettingType::String, description: '发件人邮箱')]
#[Setting(config: 'mail.from.name', type: ESettingType::String, description: '发件人名称')]
#[Setting(config: 'mail.mailers.log.channel', type: ESettingType::String, description: '日志驱动通道')]
// Failover / RoundRobin
#[Setting(config: 'mail.mailers.failover.mailers', type: ESettingType::Array, description: 'Failover 邮件驱动列表')]
#[Setting(config: 'mail.mailers.roundrobin.mailers', type: ESettingType::Array, description: 'RoundRobin 邮件驱动列表')]
// 第三方服务
#[Setting(config: 'services.postmark.token', type: ESettingType::EncryptedString, description: 'Postmark Token')]
#[Setting(config: 'services.ses.key', type: ESettingType::EncryptedString, description: 'SES Access Key ID')]
#[Setting(config: 'services.ses.secret', type: ESettingType::EncryptedString, description: 'SES Secret Access Key')]
#[Setting(config: 'services.ses.region', type: ESettingType::String, description: 'SES 区域')]
#[Setting(config: 'services.ses.token', type: ESettingType::EncryptedString, description: 'SES Session Token')]
#[Setting(config: 'services.resend.key', type: ESettingType::EncryptedString, description: 'Resend API Key')]
#[Setting(config: 'services.mailgun.domain', type: ESettingType::String, description: 'Mailgun 域名')]
#[Setting(config: 'services.mailgun.secret', type: ESettingType::EncryptedString, description: 'Mailgun Secret')]
#[Setting(config: 'services.mailgun.endpoint', type: ESettingType::String, description: 'Mailgun Endpoint')]
class MailSettings extends SettingsDefinition
{
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\SystemTool\Settings;
use Modules\SystemTool\Attributes\Setting;
use Modules\SystemTool\Base\SettingsDefinition;
use Modules\SystemTool\Enum\ESettingType;
/**
* 文件存储配置定义
*
* 对应 config/filesystems.php 中的可配置项。
* 敏感值(密钥、密码)使用 EncryptedString 类型存储。
*/
#[Setting(config: 'filesystems.default', type: ESettingType::String, description: '默认存储驱动(local / s3 / ftp / sftp')]
#[Setting(config: 'filesystems.disks.local.url', type: ESettingType::String, description: '本地存储 URL')]
// S3
#[Setting(config: 'filesystems.disks.s3.key', type: ESettingType::EncryptedString, description: 'S3 Access Key ID')]
#[Setting(config: 'filesystems.disks.s3.secret', type: ESettingType::EncryptedString, description: 'S3 Secret Access Key')]
#[Setting(config: 'filesystems.disks.s3.region', type: ESettingType::String, description: 'S3 区域')]
#[Setting(config: 'filesystems.disks.s3.bucket', type: ESettingType::String, description: 'S3 Bucket')]
#[Setting(config: 'filesystems.disks.s3.url', type: ESettingType::String, description: 'S3 URL')]
#[Setting(config: 'filesystems.disks.s3.endpoint', type: ESettingType::String, description: 'S3 Endpoint')]
#[Setting(config: 'filesystems.disks.s3.use_path_style_endpoint', type: ESettingType::Bool, description: 'S3 使用路径样式端点')]
// FTP
#[Setting(config: 'filesystems.disks.ftp.host', type: ESettingType::String, description: 'FTP 主机')]
#[Setting(config: 'filesystems.disks.ftp.username', type: ESettingType::String, description: 'FTP 用户名')]
#[Setting(config: 'filesystems.disks.ftp.password', type: ESettingType::EncryptedString, description: 'FTP 密码')]
#[Setting(config: 'filesystems.disks.ftp.port', type: ESettingType::Number, description: 'FTP 端口')]
#[Setting(config: 'filesystems.disks.ftp.root', type: ESettingType::String, description: 'FTP 根目录')]
#[Setting(config: 'filesystems.disks.ftp.passive', type: ESettingType::Bool, description: 'FTP 被动模式')]
#[Setting(config: 'filesystems.disks.ftp.ssl', type: ESettingType::Bool, description: 'FTP SSL')]
#[Setting(config: 'filesystems.disks.ftp.timeout', type: ESettingType::Number, description: 'FTP 超时(秒)')]
// SFTP
#[Setting(config: 'filesystems.disks.sftp.host', type: ESettingType::String, description: 'SFTP 主机')]
#[Setting(config: 'filesystems.disks.sftp.username', type: ESettingType::String, description: 'SFTP 用户名')]
#[Setting(config: 'filesystems.disks.sftp.password', type: ESettingType::EncryptedString, description: 'SFTP 密码')]
#[Setting(config: 'filesystems.disks.sftp.port', type: ESettingType::Number, description: 'SFTP 端口')]
#[Setting(config: 'filesystems.disks.sftp.root', type: ESettingType::String, description: 'SFTP 根目录')]
#[Setting(config: 'filesystems.disks.sftp.timeout', type: ESettingType::Number, description: 'SFTP 超时(秒)')]
#[Setting(config: 'filesystems.disks.sftp.privateKey', type: ESettingType::EncryptedString, description: 'SFTP 私钥')]
#[Setting(config: 'filesystems.disks.sftp.passphrase', type: ESettingType::EncryptedString, description: 'SFTP 私钥口令')]
class StorageSettings extends SettingsDefinition
{
}
@@ -0,0 +1,164 @@
<?php
namespace Modules\SystemUser\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemTool\Services\SysFileService;
use Modules\SystemUser\Http\Requests\SysUserUpdateRequest;
use Modules\SystemUser\Models\SysLoginRecordModel;
use Modules\SystemUser\Models\SysRuleModel;
use Modules\SystemUser\Models\SysUserModel;
#[RequestAttribute('/system')]
class IndexController extends BaseController
{
/** 用户登录 */
#[PostRoute('/login', false, 'login_log')]
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'username' => 'required|min:3|alphaDash',
'password' => 'required|min:4|alphaDash',
]);
if (Auth::guard('sys_users')->attempt($credentials)) {
$userID = auth()->id();
$user = SysUserModel::find($userID);
$access = $user->access();
if($request->input('remember', false)) {
$expiration = null;
} else {
$expiration = now()->addDays(3);
}
$data = $request->user()
->createToken($credentials['username'], $access, $expiration)
->toArray();
if(empty($data['plainTextToken'])) {
return $this->error(__('user.login_error'));
}
$response = [
'token' => $data['plainTextToken']
];
SysUserModel::query()->where('id', $userID)->update([
'login_time' => now(),
'login_ip' => $request->ip(),
]);
return $this->success($response, __('user.login_success'));
}
return $this->error(__('user.login_error'));
}
/** 退出登录 */
#[PostRoute('/logout')]
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return $this->success(__('user.logout_success'));
}
/** 获取管理员信息 */
#[GetRoute('/info')]
public function info(): JsonResponse
{
$info = Auth::user();
$access = $info->access();
return $this->success(compact('access','info'));
}
/** 获取菜单信息 */
#[GetRoute('/menu')]
public function menu(): JsonResponse
{
$id = Auth::id();
if($id == 1) {
$menus = SysRuleModel::query()
->where('status', 1)
->whereIn('type', ['menu','route'])
->get()
->toArray();
} else {
$roles = SysUserModel::with(['roles.rules' => function ($query) {
$query->where('status', 1)->whereIn('type', ['menu','route']);
}])->find($id)->roles->toArray();
$menus = collect($roles)
->map(fn ($item) => $item['rules'])
->collapse()
->map(fn ($item) => collect($item)->forget(['pivot', 'updated_at', 'created_at', 'status']) )
->unique('id')
->toArray();
}
$menus = getTreeData($menus);
return $this->success(compact('menus'));
}
/** 更新管理员信息 */
#[PutRoute('/updateInfo')]
public function updateInfo(SysUserUpdateRequest $request): JsonResponse
{
$data = $request->validated();
$id = auth()->id();
$model = SysUserModel::find($id);
if (empty($model)) {
return $this->error(__('user.user_not_exist'));
}
return $this->success($model->update($data));
}
/** 修改密码 */
#[PutRoute('/updatePassword')]
public function updatePassword(Request $request): JsonResponse
{
$validated = $request->validate([
'oldPassword' => 'required|string|min:6|max:20',
'newPassword' => 'required|string|min:6|max:20',
'rePassword' => 'required|same:newPassword',
]);
$user_id = auth()->id();
$user = SysUserModel::find($user_id);
if (! password_verify($validated['oldPassword'], $user->password)) {
return $this->error(__('user.old_password_error'));
}
$user->password = Hash::make($validated['newPassword']);
$user->save();
return $this->success('ok');
}
/** 上传头像 */
#[PostRoute('/uploadAvatar')]
public function uploadAvatar(Request $request): JsonResponse
{
$user_id = Auth::id();
$file = $request->file('file');
$service = new SysFileService();
$data = $service->upload($file, 2, 20, $user_id);
$user = SysUserModel::find($user_id);
$user->avatar_id = $data['id'];
$user->save();
return $this->success($data);
}
/** 获取管理员登录日志 */
#[GetRoute('/loginRecord')]
public function loginRecord(): JsonResponse
{
$id = Auth::id();
$data = SysLoginRecordModel::where('user_id', $id)
->limit(10)
->orderBy('id', 'desc')
->get()
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,99 @@
<?php
namespace Modules\SystemUser\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemUser\Http\Requests\SysDeptFormRequest;
use Modules\SystemUser\Models\SysDeptModel;
/**
* 部门管理控制器
*/
#[RequestAttribute('/system/dept', 'system.dept')]
class SysDeptController extends BaseController
{
/** 部门列表 */
#[GetRoute(authorize: 'query')]
public function query(): JsonResponse
{
$data = SysDeptModel::orderBy('sort', 'desc')->get()->toArray();
$data = getTreeData($data);
return $this->success($data);
}
/** 创建部门 */
#[PostRoute(authorize: 'create')]
public function create(SysDeptFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDeptModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑部门 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysDeptFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysDeptModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除部门 */
#[DeleteRoute(authorize: 'delete')]
public function delete(Request $request): JsonResponse
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'integer|exists:sys_dept,id'
]);
$ids = $request->input('ids');
$departmentsWithChildren = SysDeptModel::whereIn('id', $ids)
->whereHas('children')
->get();
if ($departmentsWithChildren->isNotEmpty()) {
return $this->error('存在下级部门的部门无法删除');
}
SysDeptModel::whereIn('id', $ids)->delete();
return $this->success('部门删除成功');
}
/** 获取部门用户列表 */
#[GetRoute('/users/{id}', 'users')]
public function users(int $id): JsonResponse
{
$model = SysDeptModel::query()->find($id);
if (empty($model)) {
return $this->error('部门不存在');
}
$pageSize = request()->input('pageSize') ?? 10;
$data = $model->users()
->select(['id', 'username', 'nickname', 'email', 'mobile', 'status'])
->paginate($pageSize)
->toArray();
return $this->success($data);
}
}
@@ -0,0 +1,146 @@
<?php
namespace Modules\SystemUser\Http\Controllers;
use App\Exceptions\RepositoryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemUser\Http\Requests\SysRoleFormRequest;
use Modules\SystemUser\Models\SysRoleModel;
use Modules\SystemUser\Models\SysRuleModel;
/**
* 角色管理控制器
*/
#[RequestAttribute('/system/role', 'system.role')]
class SysRoleController extends BaseController
{
protected array $quickSearchField = ['name', 'description'];
protected array $searchField = [
'status' => '=',
'name' => 'like',
];
/** 查询角色列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = SysRoleModel::query();
$data = $this->buildSearch($params, $query)
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建角色 */
#[PostRoute(authorize: 'create')]
public function create(SysRoleFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysRoleModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑角色 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysRoleFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysRoleModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除角色 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysRoleModel::find($id);
if (empty($model)) {
throw new RepositoryException('角色不存在');
}
if ($model->countUser > 0) {
throw new RepositoryException('该角色下存在用户,无法删除');
}
$model->delete();
return $this->success();
}
/** 获取角色用户列表 */
#[GetRoute('/users/{id}', 'users')]
public function users(int $id): JsonResponse
{
$model = SysRoleModel::query()->find($id);
if (empty($model)) {
throw new RepositoryException('角色不存在');
}
$pageSize = request()->input('pageSize', 10);
$data = $model->users()
->paginate($pageSize, ['id', 'username', 'nickname', 'email', 'mobile', 'status'])
->toArray();
return $this->success($data);
}
/** 设置启用状态 */
#[PutRoute('/status/{id}', 'status')]
public function status(int $id): JsonResponse
{
$model = SysRoleModel::find($id);
if (!$model) {
return $this->error(__('system.data_not_exist'));
}
$model->status = $model->status ? 0 : 1;
$model->save();
return $this->success();
}
/** 获取权限选项 */
#[GetRoute('/ruleList', 'ruleList')]
public function ruleList(): JsonResponse
{
$data = SysRuleModel::query()
->where("status", 1)
->get(['name as title', 'parent_id', 'id as key', 'id', 'local'])
->toArray();
$data = getTreeData($data);
return $this->success($data);
}
/** 设置角色权限 */
#[PostRoute('/setRule', 'setRule')]
public function setRule(Request $request): JsonResponse
{
$validated = $request->validate([
'role_id' => 'required|exists:sys_role,id',
'rule_ids' => 'required|array|exists:sys_rule,id',
]);
if ($validated['role_id'] == 1) {
throw new RepositoryException('超级管理员不能修改权限');
}
$model = SysRoleModel::findOrFail($validated['role_id']);
$model->rules()->sync($validated['rule_ids']);
return $this->success();
}
}
@@ -0,0 +1,121 @@
<?php
namespace Modules\SystemUser\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemUser\Http\Requests\SysRuleFormRequest;
use Modules\SystemUser\Models\SysRuleModel;
/**
* 管理员权限控制器
*/
#[RequestAttribute('/system/rule', 'system.rule')]
class SysRuleController extends BaseController
{
protected array $quickSearchField = ['name', 'key', 'path'];
protected array $searchField = [
'type' => '=',
'status' => '=',
'show' => '=',
'parent_id' => '=',
];
/** 获取权限列表(树形) */
#[GetRoute(authorize: 'query')]
public function query(): JsonResponse
{
$rules = SysRuleModel::all();
$data = $rules->toArray();
$data = getTreeData($data);
return $this->success($data);
}
/** 创建权限 */
#[PostRoute(authorize: 'create')]
public function create(SysRuleFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysRuleModel::create($validated);
if (empty($model)) {
return $this->error();
}
return $this->success();
}
/** 编辑权限 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysRuleFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysRuleModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->update($validated);
return $this->success();
}
/** 删除权限 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
$model = SysRuleModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->delete();
return $this->success();
}
/** 获取父级权限 */
#[GetRoute('/parent', authorize: 'parentQuery')]
public function getRulesParent(): JsonResponse
{
$data = SysRuleModel::query()
->whereIn('type', ['menu', 'route'])
->get(['name', 'id', 'parent_id'])
->toArray();
$data = getTreeData($data);
return $this->success($data);
}
/** 设置显示状态 */
#[PutRoute('/show/{id}', authorize: 'show')]
public function show(int $id): JsonResponse
{
$model = SysRuleModel::find($id);
if (!$model) {
return $this->error(__('system.data_not_exist'));
}
$model->hidden = $model->hidden ? 0 : 1;
$model->save();
return $this->success();
}
/** 设置启用状态 */
#[PutRoute('/status/{id}', authorize: 'status')]
public function status(int $id): JsonResponse
{
$model = SysRuleModel::find($id);
if (!$model) {
return $this->error(__('system.data_not_exist'));
}
$model->status = $model->status ? 0 : 1;
$model->save();
return $this->success();
}
}
@@ -0,0 +1,164 @@
<?php
namespace Modules\SystemUser\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Modules\SystemUser\Http\Requests\SysUserFormRequest;
use Modules\SystemUser\Models\SysDeptModel;
use Modules\SystemUser\Models\SysRoleModel;
use Modules\SystemUser\Models\SysUserModel;
/**
* 管理员用户控制器
*/
#[RequestAttribute('/system/user', 'system.user')]
class SysUserController extends BaseController
{
/** 查询管理员用户列表 */
#[GetRoute(authorize: 'query')]
public function query(Request $request): JsonResponse
{
$params = $request->all();
$pageSize = $params['pageSize'] ?? 10;
$query = SysUserModel::query();
$data = $this->buildSearch($params, $query)
->paginate($pageSize)
->toArray();
return $this->success($data);
}
/** 创建管理员用户 */
#[PostRoute(authorize: 'create')]
public function create(SysUserFormRequest $request): JsonResponse
{
$validated = $request->validated();
$user = SysUserModel::create([
'username' => $validated['username'],
'nickname' => $validated['nickname'],
'email' => $validated['email'],
'mobile' => $validated['mobile'],
'password' => Hash::make($validated['password']),
'status' => $validated['status'] ?? 1,
'dept_id' => $validated['dept_id'] ?? null,
'sex' => $validated['sex'] ?? 0
]);
if(empty($user)) {
return $this->error();
}
$user->roles()->sync($validated['role_id'] ?? []);
return $this->success();
}
/** 编辑管理员用户 */
#[PutRoute(
route: '/{id}',
authorize: 'update',
where: ['id' => '[0-9]+']
)]
public function update(int $id, SysUserFormRequest $request): JsonResponse
{
$validated = $request->validated();
$model = SysUserModel::find($id);
if (empty($model)) {
return $this->error();
}
$model->roles()->sync($validated['role_id'] ?? []);
$model->update($validated);
return $this->success();
}
/** 删除管理员用户 */
#[DeleteRoute(
route: '/{id}',
authorize: 'delete',
where: ['id' => '[0-9]+']
)]
public function delete(int $id): JsonResponse
{
if($id == 1) {
$this->error('不能删除系统用户!');
}
$user = SysUserModel::find($id);
if (empty($user)) {
$this->error('Model not found');
}
$user->roles()->detach();
$user->delete();
return $this->success();
}
/** 重置用户密码 */
#[PutRoute('/resetPassword', 'resetPassword')]
public function resetPassword(Request $request): JsonResponse
{
$data = $request->validate([
'id' => 'required|exists:sys_user,id',
'password' => 'required|string|min:6|max:20',
'rePassword' => 'required|same:password',
], [
'id.required' => '请选择管理员用户!',
'id.exists' => '管理员用户不存在!',
'password.required' => '请输入管理员密码!',
'password.min' => '密码最短为6个字符!',
'password.max' => '密码最长伟20个字符!',
'rePassword.required' => '请重复输入密码!',
'rePassword.same' => '两次输入的密码不同!',
]);
$user = SysUserModel::find($data['id']);
if (!$user) {
return $this->error(__('user.user_not_exist'));
}
$user->password = Hash::make($data['password']);
$user->save();
return $this->success('ok');
}
/** 获取用户角色选项栏数据 */
#[GetRoute('/role', 'role')]
public function role(): JsonResponse
{
$data = SysRoleModel::where('status', 1)
->get(['id as role_id', 'name'])
->toArray();
return $this->success($data);
}
/** 获取用户部门选项栏数据 */
#[GetRoute('/dept', 'dept')]
public function dept(): JsonResponse
{
$field = SysDeptModel::where('status', 0)
->select(['id as dept_id', 'name', 'parent_id'])
->get()
->toArray();
$data = $this->buildTree($field);
return $this->success($data);
}
private function buildTree(array $items, $parentId = 0): array
{
$tree = [];
foreach ($items as $item) {
if ($item['parent_id'] == $parentId) {
$children = $this->buildTree($items, $item['dept_id']);
$node = [
'dept_id' => $item['dept_id'],
'name' => $item['name'],
'children' => $children
];
$tree[] = $node;
}
}
return $tree;
}
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\SystemUser\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\SystemUser\Models\SysAccessToken;
use Symfony\Component\HttpFoundation\Response;
class AuthGuardMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, ...$guards): Response
{
$token = $request->bearerToken();
if (!$token) {
return response()->json(['msg' => 'Token not provided', 'success' => false], 401);
}
// 查找 token
$accessToken = SysAccessToken::findToken($token);
if (!$accessToken) {
return response()->json(['msg' => 'Invalid token', 'success' => false], 401);
}
if (empty($guards)) {
$guards = ['sys_users'];
}
Log::info('Guards: ', $guards);
Log::info('Auth Providers: ', config('auth.providers'));
foreach ($guards as $guard) {
Log::info('Auth Providers Model: ' . config('auth.providers.' . $guard . '.model'));
if ($accessToken->tokenable_type == config('auth.providers.' . $guard . '.model')) {
return $next($request);
}
}
return response()->json([
'msg' => __('user.not_login'),
'success' => false
], 401);
}
}
@@ -0,0 +1,111 @@
<?php
namespace Modules\SystemUser\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\SystemUser\Models\SysLoginRecordModel;
use Symfony\Component\HttpFoundation\Response;
class LoginLogMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
try {
// 获取用户名(假设用户名通过请求体传递)
$userAgent = $request->userAgent();
// 继续处理请求
$response = $next($request);
$user_id = auth()->id();
$username = auth()->user()['username'];
// 获取响应状态和消息
$content = json_decode($response->getContent(), true); // 响应内容
$message = $content['msg'] ?? 'No message'; // 从响应中提取消息
SysLoginRecordModel::create([
'ipaddr' => $request->ip(),
'browser' => $this->getBrowser($userAgent),
'os' => $this->getOs($userAgent),
'username' => $username,
'user_id' => $user_id,
'login_location' => $this->getLocation($request->ip()),
'status' => $content['success'] ? '0' : '1',
'msg' => $message,
'login_time' => date('Y-m-d H:i:s'),
]);
}catch (\Exception $e) {
// 记录错误日志
Log::error('Failed to log user login info: ' . $e->getMessage());
}
return $response;
}
/**
* 获取浏览器信息
* @param string $userAgent
* @return string
*/
private function getBrowser(string $userAgent): string
{
$browser = 'XXX';
// 简单的解析逻辑(可以根据需要扩展)
if (str_contains($userAgent, 'Firefox')) {
$browser = 'Firefox';
} elseif (str_contains($userAgent, 'Chrome')) {
$browser = 'Chrome';
} elseif (str_contains($userAgent, 'Safari')) {
$browser = 'Safari';
} elseif (str_contains($userAgent, 'MSIE') || str_contains($userAgent, 'Trident')) {
$browser = 'Internet Explorer';
}
return $browser;
}
/**
* 获取操作系统信息
* @param string $userAgent
* @return string
*/
private function getOs(string $userAgent): string
{
$os = 'Unknown';
if (str_contains($userAgent, 'Windows')) {
$os = 'Windows';
} elseif (str_contains($userAgent, 'Macintosh')) {
$os = 'Mac OS';
} elseif (str_contains($userAgent, 'Linux')) {
$os = 'Linux';
} elseif (str_contains($userAgent, 'Android')) {
$os = 'Android';
} elseif (str_contains($userAgent, 'iOS')) {
$os = 'iOS';
}
return $os;
}
/**
* 获取 IP 地址对应的地理位置
*
* @param string $ip
* @return string
*/
private function getLocation(string $ip): string
{
if($ip == '127.0.0.1') {
return '本地';
}
// 这里可以使用第三方 API(如 IPStack 或 IPInfo)来获取地理位置
try {
$response = file_get_contents("https://ipinfo.io/{$ip}/json");
$data = json_decode($response, true);
return $data['city'] . ', ' . $data['country'];
}catch (\Exception $e) {
return 'XXX';
}
}
}
@@ -0,0 +1,78 @@
<?php
namespace Modules\SystemUser\Http\Requests;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysDeptFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
return [
'name' => 'required|unique:sys_dept,name',
'code' => 'required|unique:sys_dept,code',
'type' => 'required|integer|in:0,1,2',
'parent_id' => [
'required',
'integer',
function ($attribute, $value, $fail) {
if ($value != 0 && !DB::table('sys_dept')->where('id', $value)->exists()) {
$fail('选择的上级部门不存在。');
}
},
],
'sort' => 'required|integer',
'phone' => 'nullable',
'address' => 'nullable',
'email' => 'nullable|email',
'status' => 'required|in:0,1',
'remark' => 'nullable',
];
} else {
$id = $this->route('id');
return [
'name' => [
'required',
Rule::unique('sys_dept', 'name')->ignore($id)
],
'code' => [
'required',
Rule::unique('sys_dept', 'code')->ignore($id)
],
'type' => 'required|integer|in:0,1,2',
'sort' => 'required|integer',
'phone' => 'nullable',
'address' => 'nullable',
'email' => 'nullable|email',
'status' => 'required|in:0,1',
'remark' => 'nullable',
];
}
}
public function messages(): array
{
return [
'name.required' => '部门名称不能为空',
'name.unique' => '部门名称已存在',
'code.required' => '部门编码不能为空',
'code.unique' => '部门编码已存在',
'type.required' => '部门类型不能为空',
'type.integer' => '部门类型必须是整数',
'type.in' => '部门类型错误',
'parent_id.required' => '上级部门不能为空',
'parent_id.integer' => '上级部门ID必须是整数',
'parent_id.exists' => '选择的上级部门不存在',
'sort.required' => '排序字段不能为空',
'sort.integer' => '排序字段必须是整数',
'email.email' => '请输入有效的邮箱地址',
'status.required' => '状态不能为空',
'status.in' => '状态类型错误',
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\SystemUser\Http\Requests;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysRoleFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if (!$this->isUpdate()) {
return [
'name' => 'required|unique:sys_role,name',
'sort' => 'required|integer|min:0',
'description' => 'nullable|string',
'status' => 'required|integer|in:0,1',
];
} else {
$id = $this->route('id');
return [
'name' => [
'required',
'string',
Rule::unique('sys_role', 'name')->ignore($id),
],
'sort' => 'required|integer|min:0',
'description' => 'nullable|string',
'status' => 'required|integer|in:0,1',
];
}
}
public function messages(): array
{
return [
'name.required' => '角色名称不能为空',
'name.unique' => '角色名称已存在',
'sort.required' => '排序不能为空',
'sort.integer' => '排序必须为整数',
'status.required' => '状态不能为空',
'status.in' => '状态格式错误',
];
}
}
@@ -0,0 +1,86 @@
<?php
namespace Modules\SystemUser\Http\Requests;
use App\Exceptions\RepositoryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysRuleFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
$type = $this->input('type');
if (empty($type)) {
throw new RepositoryException('权限类型为必填项!');
}
if (!in_array($type, ['menu', 'route', 'rule'])) {
throw new RepositoryException('权限类型错误!');
}
$rules = [
'parent_id' => [
'required',
'integer',
'numeric',
function ($attribute, $value, $fail) {
if ($value != 0 && !DB::table('sys_rule')->where('id', $value)->exists()) {
$fail('选择的上级权限不存在。');
}
},
],
'order' => 'required|integer',
'name' => 'required',
];
if (!$this->isUpdate()) {
$rules['key'] = 'required|unique:sys_rule,key';
} else {
$rules['key'] = [
'required',
Rule::unique('sys_rule', 'key')->ignore($this->route('id')),
];
}
if ($type == 'menu') {
$rules += [
'type' => 'required|string|in:menu',
'local' => 'nullable|string',
'icon' => 'nullable|string',
];
} elseif ($type == 'route') {
$rules += [
'type' => 'required|string|in:route',
'path' => 'required|string',
'local' => 'nullable|string',
'icon' => 'nullable|string',
'link' => 'required|integer|numeric|in:0,1',
];
} else {
$rules += [
'type' => 'required|string|in:rule',
];
}
return $rules;
}
public function messages(): array
{
return [
'name.required' => '权限名称不能为空',
'type.required' => '类型不能为空',
'type.in' => '类型格式错误',
'order.required' => '排序不能为空',
'order.integer' => '排序必须为整数',
'key.required' => '唯一标识不能为空',
'key.unique' => '唯一标识已存在',
'path.required' => '路径不能为空',
'parent_id.required' => '父级权限不能为空',
'parent_id.integer' => '父级权限格式错误',
];
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\SystemUser\Http\Requests;
use Illuminate\Validation\Rule;
use Modules\Common\Http\Requests\BaseFormRequest;
class SysUserFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if(!$this->isUpdate()) {
return [
'username' => 'required|unique:sys_user,username',
'nickname' => 'required',
'sex' => 'in:0,1',
'mobile' => 'required',
'email' => 'required|email|unique:sys_user,email',
'dept_id' => 'required|exists:sys_dept,id',
'role_id' => 'required|array|exists:sys_role,id',
'status' => 'required|int|in:1,0',
'password' => 'required|min:6',
'rePassword' => 'required|same:password',
];
} else {
$id = $this->route('id');
return [
'username' => [
'required',
Rule::unique('sys_user', 'username')->ignore($id)
],
'nickname' => 'required',
'sex' => 'in:0,1',
'mobile' => 'required',
'email' => [
'required',
Rule::unique('sys_user', 'email')->ignore($id)
],
'role_id' => 'required|array|exists:sys_role,id',
'dept_id' => 'required|exists:sys_dept,id',
'status' => 'required|int|in:1,0',
];
}
}
public function messages(): array
{
return [
'username.required' => '用户名不能为空',
'username.unique' => '用户名已存在',
'nickname.required' => '昵称不能为空',
'sex.in' => '性别格式错误',
'mobile.required' => '手机号不能为空',
'email.required' => '邮箱不能为空',
'email.email' => '邮箱格式错误',
'email.unique' => '邮箱已存在',
'dept_id.exists' => '部门不存在',
'role_id.exists' => '角色不存在',
'status.required' => '状态不能为空',
'status.int' => '状态值错误',
'status.in' => '状态格式错误',
'password.required' => '密码不能为空',
'password.min' => '密码至少6位',
'rePassword.required' => '确认密码不能为空',
'rePassword.same' => '两次密码不一致',
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace Modules\SystemUser\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SysUserUpdateRequest extends FormRequest
{
public function rules(): array
{
return [
'nickname' => [
'required',
Rule::unique('sys_user', 'username')->ignore($id)
],
'sex' => 'required|in:0,1',
'bio' => 'sometimes|max:255',
'mobile' => [
'required',
Rule::unique('sys_user', 'mobile')->ignore($id)
],
'email' => [
'required',
Rule::unique('sys_user', 'email')->ignore($id)
],
];
}
public function messages(): array
{
return [
'nickname.required' => '昵称不能为空',
'sex.required' => '性别不能为空',
'sex.in' => '性别格式错误',
'bio.max' => '个人简介最大不能超过255个字符',
'mobile.required' => '手机号不能为空',
'email.required' => '邮箱不能为空',
'email.email' => '邮箱格式错误'
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\SystemUser\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
class SysAccessToken extends SanctumPersonalAccessToken
{
protected $table = 'sys_access_token';
public function can($ability): bool
{
if (
$this->tokenable_type == SysUserModel::class
&& $this->tokenable_id == 1
) {
return true;
}
return in_array('*', $this->abilities) ||
array_key_exists($ability, array_flip($this->abilities));
}
}
@@ -0,0 +1,63 @@
<?php
namespace Modules\SystemUser\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 系统用户部门模型
*/
class SysDeptModel extends Model
{
use SoftDeletes;
protected $table = 'sys_dept';
protected $primaryKey = 'id';
protected $fillable = [
'parent_id',
'name',
'code',
'type',
'sort',
'phone',
'email',
'status',
'address',
'remark'
];
protected $casts = [
'parent_id' => 'integer',
'sort' => 'integer',
'status' => 'integer',
];
protected $hidden = [ 'deleted_at' ];
/**
* 定义与用户的关联关系(一个部门有多个用户)
*/
public function users(): HasMany
{
return $this->hasMany(SysUserModel::class, 'dept_id', 'id');
}
/**
* 定义父级部门关联
*/
public function parent(): BelongsTo
{
return $this->belongsTo(SysDeptModel::class, 'parent_id', 'id');
}
/**
* 定义子部门关联
*/
public function children(): HasMany
{
return $this->hasMany(SysDeptModel::class, 'parent_id', 'id');
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\SystemUser\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 系统用户登录日志模型
*/
class SysLoginRecordModel extends Model
{
protected $table = 'sys_login_record';
protected $primaryKey = 'id';
public $timestamps = false;
protected $fillable = [
'user_id',
'username',
'ipaddr',
'login_location',
'browser',
'os',
'status',
'msg',
'login_time'
];
protected $casts = [
'user_id' => 'integer',
'login_time' => 'datetime'
];
/**
* 定义与用户的关联关系
*/
public function user(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'user_id', 'id');
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\SystemUser\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* 系统角色模型
*/
class SysRoleModel extends Model
{
protected $table = 'sys_role';
protected $primaryKey = 'id';
protected $fillable = [
'name',
'sort',
'rules',
'description',
'status'
];
protected $casts = [
'sort' => 'integer',
'status' => 'integer'
];
protected $appends = ['countUser', 'ruleIds'];
/**
* 角色用户关联
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(SysUserModel::class, 'sys_user_role', 'role_id', 'user_id');
}
/**
* 角色权限关联中间表
*/
public function rules(): BelongsToMany
{
return $this->belongsToMany(SysRuleModel::class, 'sys_role_rule', 'role_id', 'rule_id');
}
/** 用户总数 */
public function getCountUserAttribute(): int
{
return $this->users()->count();
}
/** 拥有的权限ID */
public function getRuleIdsAttribute(): array
{
if(!empty($this->id) && $this->id == 1) {
return SysRuleModel::query()->pluck('id')->toArray();
}
return $this->rules()->pluck('id')->toArray();
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\SystemUser\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 系统规则模型
*/
class SysRuleModel extends Model
{
protected $table = 'sys_rule';
protected $primaryKey = 'id';
protected $fillable = [
'parent_id',
'type',
'order',
'name',
'key',
'path',
'icon',
'local',
'link',
];
protected $casts = [
'parent_id' => 'integer',
'sort' => 'integer',
'status' => 'integer',
'show' => 'integer'
];
/**
* 定义子权限关联
*/
public function children(): HasMany
{
return $this->hasMany(SysRuleModel::class, 'parent_id', 'id')
->orderBy('sort');
}
/**
* 定义父权限关联
*/
public function parent(): BelongsTo
{
return $this->belongsTo(SysRuleModel::class, 'parent_id', 'id');
}
/**
* 角色权限关联中间表
*/
public function roles(): BelongsToMany
{
return $this->belongsToMany(SysRoleModel::class, 'sys_role_rule', 'rule_id', 'role_id');
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace Modules\SystemUser\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User;
use Illuminate\Notifications\Notifiable;
use Laravel\Ai\Concerns\HasConversations;
use Laravel\Sanctum\HasApiTokens;
use Modules\SystemTool\Models\SysFileModel;
/**
* 系统用户模型
*/
class SysUserModel extends User
{
use SoftDeletes, HasConversations, HasApiTokens, HasFactory, Notifiable;
protected $table = 'sys_user';
protected $primaryKey = 'id';
protected $fillable = [
'username',
'password',
'nickname',
'avatar_id',
'bio',
'sex',
'mobile',
'email',
'email_verified_at',
'dept_id',
'login_ip',
'login_time',
'status'
];
protected $casts = [
'email_verified_at' => 'datetime',
'login_time' => 'datetime',
'status' => 'integer',
'sex' => 'integer',
'avatar_id' => 'integer',
'dept_id' => 'integer'
];
protected $appends = ['role_id', 'dept_name', 'avatar_url'];
protected $with = ['dept', 'avatar'];
protected $hidden = [
'dept',
'avatar',
'password',
'remember_token',
'deleted_at',
];
/**
* 定义与部门的归属关系
*/
public function dept(): BelongsTo
{
return $this->belongsTo(SysDeptModel::class, 'dept_id', 'id');
}
/** 部门名称 */
public function getDeptNameAttribute(): string
{
return $this->dept->name ?? '';
}
/**
* 定义与角色的关联
*/
public function roles(): BelongsToMany
{
return $this->belongsToMany(SysRoleModel::class, 'sys_user_role', 'user_id', 'role_id');
}
/**
* 获取用户角色列表
*/
public function getRoleIdAttribute()
{
return $this->roles()
->pluck('id')->toArray();
}
/**
* 定义与登录日志的关联
*/
public function loginRecords(): HasMany
{
return $this->hasMany(SysLoginRecordModel::class, 'user_id', 'id');
}
/**
* 关联用户头像
* @return HasOne
*/
public function avatar(): HasOne
{
return $this->hasOne(SysFileModel::class, 'id', 'avatar_id');
}
/**
* 获取用户角色列表
*/
public function getAvatarUrlAttribute()
{
if($this->avatar) {
return $this->avatar->preview_url;
}
return null;
}
/**
* 获取用户所有权限
* @return array
*/
public function access(): array
{
if($this->id == 1) {
return SysRuleModel::query()
->where('status', 1)
->pluck('key')
->toArray();
}
$roles = SysUserModel::with(['roles.rules' => function ($query) {
$query->where('status', 1);
}])->find($this->id)->roles->toArray();
return collect($roles)
->map(fn ($item) => $item['rules'] )
->collapse()
->map(fn ($item) => $item['key'] )
->unique()
->toArray();
}
}
@@ -0,0 +1,36 @@
<?php
namespace Modules\SystemUser\Providers;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\Sanctum;
use Modules\AnnoRoute\AnnoRoute;
use Modules\SystemUser\Models\SysAccessToken;
class SystemUserServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//...
}
/**
* Bootstrap any application services.
*/
public function boot(AnnoRoute $annoRoute): void
{
// 注册路由
$annoRoute->register(base_path('modules/SystemUser/Http/Controllers'));
//
Sanctum::usePersonalAccessTokenModel(SysAccessToken::class);
}
}