您好!
54 | 55 |您正在尝试进行身份验证,请使用以下验证码完成操作:
56 | 57 |此验证码将在 {{ $expireMinutes }} 分钟后失效,请尽快使用。
62 | 63 |如果您没有请求此验证码,请忽略此邮件。
64 | 65 | 68 | 69 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysUserListController.php: -------------------------------------------------------------------------------- 1 | resetPassword($request); 40 | } 41 | 42 | /** 获取用户角色选项栏数据 */ 43 | #[GetMapping('/role', 'getRole')] 44 | public function role(SysUserRoleService $service): JsonResponse 45 | { 46 | return $this->success($service->getRoleFields()); 47 | } 48 | 49 | /** 获取用户部门选项栏数据 */ 50 | #[GetMapping('/dept', 'getDept')] 51 | public function dept(SysUserDeptService $service): JsonResponse 52 | { 53 | return $this->success($service->getDeptField()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysUserRuleController.php: -------------------------------------------------------------------------------- 1 | getList(); 37 | } 38 | 39 | /** 获取父级权限 */ 40 | #[GetMapping('/parent', authorize: 'parentQuery')] 41 | public function getRulesParent(SysUserRuleService $service): JsonResponse 42 | { 43 | return $service->getRuleParent(); 44 | } 45 | 46 | /** 设置显示状态 */ 47 | #[PutMapping('/show/{id}', authorize: 'show')] 48 | public function show($id, SysUserRuleService $service): JsonResponse 49 | { 50 | return $service->setShow($id); 51 | } 52 | 53 | /** 设置启用状态 */ 54 | #[PutMapping('/status/{id}', authorize: 'status')] 55 | public function status($id, SysUserRuleService $service): JsonResponse 56 | { 57 | return $service->setStatus($id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Http/Controllers/Sys/SysWatcherController.php: -------------------------------------------------------------------------------- 1 | type('request'); 25 | return $this->success($storage->get($options)); 26 | } 27 | 28 | #[GetMapping('/query', 'query')] 29 | public function queryLog(Request $request, EntriesRepository $storage): JsonResponse 30 | { 31 | $options = EntryQueryOptions::fromRequest($request); 32 | $options->type('query'); 33 | return $this->success($storage->get($options)); 34 | } 35 | 36 | #[GetMapping('/cache', 'cache')] 37 | public function cache(Request $request, EntriesRepository $storage): JsonResponse 38 | { 39 | $options = EntryQueryOptions::fromRequest($request); 40 | $options->type('cache'); 41 | return $this->success($storage->get($options)); 42 | } 43 | 44 | #[GetMapping('/redis', 'redis')] 45 | public function redis(Request $request, EntriesRepository $storage): JsonResponse 46 | { 47 | $options = EntryQueryOptions::fromRequest($request); 48 | $options->type('redis'); 49 | return $this->success($storage->get($options)); 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000003_create_dict_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('name', 50)->comment('字典名称'); 19 | $table->string('code', 50)->comment('字典编码'); 20 | $table->string('describe', 255)->nullable()->comment('字典描述'); 21 | $table->string('type', 10)->default('default')->comment('字典类型'); 22 | $table->timestamps(); 23 | $table->comment('字典表'); 24 | }); 25 | } 26 | if (! Schema::hasTable('sys_dict_item')) { 27 | Schema::create('sys_dict_item', function (Blueprint $table) { 28 | $table->increments('id'); 29 | $table->unsignedBigInteger('dict_id')->comment('字典ID'); 30 | $table->string('label', 50)->comment('字典项名称'); 31 | $table->string('value', 50)->comment('字典项值'); 32 | $table->unsignedInteger('switch')->default(1)->comment('是否启用:0:禁用,1:启用'); 33 | $table->string('status', 10)->default('default')->comment('状态:(default,success,error,processing,warning)'); 34 | $table->timestamps(); 35 | $table->comment('字典项表'); 36 | }); 37 | } 38 | } 39 | 40 | /** 41 | * Reverse the migrations. 42 | */ 43 | public function down(): void 44 | { 45 | Schema::dropIfExists('sys_dict'); 46 | Schema::dropIfExists('sys_dict_item'); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /app/Providers/Telescope/TelescopeServiceProvider.php: -------------------------------------------------------------------------------- 1 | app); 21 | Telescope::listenForStorageOpportunities($this->app); 22 | } 23 | 24 | /** 25 | * Register any package services. 26 | */ 27 | public function register(): void 28 | { 29 | $this->registerDatabaseDriver(); 30 | 31 | $this->hideSensitiveRequestDetails(); 32 | 33 | Telescope::filter(function (IncomingEntry $entry) { 34 | return $entry->isFailedRequest() || 35 | $entry->isQuery() || 36 | $entry->isSlowQuery() || 37 | $entry->isRequest() || 38 | $entry->isCache() || 39 | $entry->isRedis() || 40 | $entry->isAuth(); 41 | }); 42 | } 43 | 44 | /** 45 | * Register the package database storage driver. 46 | */ 47 | protected function registerDatabaseDriver(): void 48 | { 49 | $this->app->singleton( 50 | EntriesRepository::class, StorageRepository::class 51 | ); 52 | } 53 | 54 | /** 55 | * Prevent sensitive request details from being logged by Telescope. 56 | */ 57 | protected function hideSensitiveRequestDetails(): void 58 | { 59 | Telescope::hideRequestParameters(['_token']); 60 | 61 | Telescope::hideRequestHeaders([ 62 | 'cookie', 63 | 'x-csrf-token', 64 | 'x-xsrf-token', 65 | ]); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysLoginRecordRepository.php: -------------------------------------------------------------------------------- 1 | '=', 15 | 'user_id' => '=' 16 | ]; 17 | 18 | /** @var array|string[] 快速搜索字段 */ 19 | protected array $quickSearchField = ['username', 'ipaddr', 'browser', 'os']; 20 | 21 | /** 22 | * @inheritDoc 23 | */ 24 | protected function model(): Builder 25 | { 26 | return SysLoginRecordModel::query(); 27 | } 28 | 29 | protected function rules(): array 30 | { 31 | return [ 32 | 'username' => 'required', 33 | 'user_id' => 'required|exists:sys_user,id', 34 | 'ipaddr' => 'required|ip', 35 | 'login_location' => 'required', 36 | 'browser' => 'required', 37 | 'os' => 'required', 38 | 'status' => 'required|in:0,1', 39 | 'msg' => 'required', 40 | 'login_time' => 'required|date' 41 | ]; 42 | } 43 | 44 | protected function messages(): array 45 | { 46 | return [ 47 | 'username.required' => '用户名不能为空', 48 | 'user_id.required' => '用户ID不能为空', 49 | 'user_id.exists' => '用户不存在', 50 | 'ipaddr.required' => 'IP地址不能为空', 51 | 'ipaddr.ip' => 'IP地址格式错误', 52 | 'login_location.required' => '登录地点不能为空', 53 | 'browser.required' => '浏览器不能为空', 54 | 'os.required' => '操作系统不能为空', 55 | 'status.required' => '登录状态不能为空', 56 | 'status.in' => '登录状态格式错误', 57 | 'msg.required' => '提示消息不能为空', 58 | 'login_time.required' => '登录时间不能为空', 59 | 'login_time.date' => '登录时间格式错误' 60 | ]; 61 | } 62 | } -------------------------------------------------------------------------------- /app/Repositories/Sys/SysSettingGroupRepository.php: -------------------------------------------------------------------------------- 1 | 'required', 25 | 'title' => 'required', 26 | 'remark' => 'sometimes|required', 27 | ]; 28 | } 29 | 30 | protected function messages(): array 31 | { 32 | return [ 33 | 'key.required' => '键名字段是必填的', 34 | 'title.required' => '标题字段是必填的', 35 | 'remark.required' => '备注字段是必填的', 36 | ]; 37 | } 38 | 39 | public function list(array $params): array 40 | { 41 | if(empty($params['keywordSearch'])){ 42 | return $this->model() 43 | ->get() 44 | ->toArray(); 45 | }else { 46 | return $this->model() 47 | ->whereAny( 48 | ['title', 'remark', 'key'], 49 | 'like', 50 | '%'.str_replace('%', '\%', $params['keywordSearch']).'%' 51 | ) 52 | ->get() 53 | ->toArray(); 54 | } 55 | } 56 | 57 | public function delete(int $id): bool 58 | { 59 | $model = $this->model()->find($id); 60 | if (empty($model)) { 61 | throw new RepositoryException('Model not found'); 62 | } 63 | $count = $model->settings()->count(); 64 | if ($count > 0) { 65 | throw new RepositoryException('当前分组有未删除的设置项!'); 66 | } 67 | return $model->delete(); 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /app/Services/Sys/SysUserRoleService.php: -------------------------------------------------------------------------------- 1 | error(__('system.data_not_exist')); 22 | } 23 | $model->status = $model->status ? 0 : 1; 24 | $model->save(); 25 | return $this->success(); 26 | } 27 | 28 | public function setRule(Request $request): void 29 | { 30 | $validated = $request->validate([ 31 | 'role_id' => 'required|exists:sys_role,id', 32 | 'rule_ids' => 'required|array|exists:sys_rule,id', 33 | ]); 34 | if($validated['role_id'] == 1) { 35 | throw new RepositoryException('超级管理员不嫩修改权限'); 36 | } 37 | $model = SysRoleModel::findOrFail($validated['role_id']); 38 | $model->rules()->sync($validated['rule_ids']); 39 | } 40 | 41 | /** 获取角色选择项 */ 42 | public function getRoleFields(): array 43 | { 44 | return SysRoleModel::where('status', 1) 45 | ->get(['id as role_id', 'name']) 46 | ->toArray(); 47 | } 48 | 49 | /** 获取用户列表 */ 50 | public function users($id): array 51 | { 52 | $model = SysRoleModel::query()->find($id); 53 | if (empty($model)) { 54 | throw new RepositoryException('Model not found'); 55 | } 56 | $pageSize = request()->input('pageSize') ?? 10; 57 | return $model->users() 58 | ->paginate( 59 | $pageSize, 60 | ['id', 'username', 'nickname', 'email', 'mobile', 'status'] 61 | ) 62 | ->toArray(); 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /app/Support/Enum/FileType.php: -------------------------------------------------------------------------------- 1 | __('system.file.image'), 33 | self::AUDIO => __('system.file.audio'), 34 | self::VIDEO => __('system.file.video'), 35 | self::ZIP => __('system.file.zip'), 36 | self::ANNEX => __('system.file.annex'), 37 | }; 38 | } 39 | 40 | /** 41 | * 获取预览地址 42 | */ 43 | public function previewPath(): string 44 | { 45 | return match ($this) { 46 | self::IMAGE => 'static/image.svg', 47 | self::AUDIO => 'static/audio.svg', 48 | self::VIDEO => 'static/video.svg', 49 | self::ZIP => 'static/zip.svg', 50 | self::ANNEX => 'static/annex.svg', 51 | }; 52 | } 53 | 54 | /** 55 | * 获取最大大小 56 | */ 57 | public function maxSize(): int 58 | { 59 | return match ($this) { 60 | self::IMAGE => 2097152, 61 | self::AUDIO, self::VIDEO, self::ZIP, self::ANNEX => 10485760, 62 | }; 63 | } 64 | 65 | /** 66 | * 文件扩展名 67 | */ 68 | public function fileExt(): array|string 69 | { 70 | return match ($this) { 71 | self::IMAGE => ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'avif', 'webp'], 72 | self::AUDIO => ['mp3', 'wma', 'wav', 'ape', 'flac', 'ogg', 'aac'], 73 | self::VIDEO => ['mp4', 'mov', 'wmv', 'flv', 'avl', 'webm', 'mkv'], 74 | self::ZIP => ['zip', 'rar'], 75 | self::ANNEX => '*', 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(SysSettingService::class, SysSettingService::class); 26 | 27 | $this->app->bind('Illuminate\Pagination\LengthAwarePaginator', function ($app, $options) { 28 | return new LengthAwarePaginatorService( 29 | $options['items'], 30 | $options['total'], 31 | $options['perPage'], 32 | $options['currentPage'], 33 | $options['options'] 34 | ); 35 | }); 36 | 37 | $this->app->bind(ExceptionsHandler::class, \App\Exceptions\ExceptionsHandler::class); 38 | } 39 | 40 | /** 41 | * Bootstrap any application services. 42 | */ 43 | public function boot(): void 44 | { 45 | Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); 46 | 47 | // 注册系统设置观察者,自动刷新缓存 48 | SysSettingItemsModel::observe(SysSettingObserver::class); 49 | 50 | if (Schema::hasTable('sys_setting_items')) { 51 | // 刷新系统设置缓存 52 | SysSettingService::refreshSettings(); 53 | // 从系统设置初始化邮件配置 54 | MailConfigService::initFromSettings(); 55 | // 从系统设置初始化存储配置 56 | StorageConfigService::initFromSettings(); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysDictRepository.php: -------------------------------------------------------------------------------- 1 | isUpdate()) { 28 | $id = request()->route('id'); 29 | return [ 30 | 'name' => 'required', 31 | 'code' => [ 32 | 'required', 33 | Rule::unique('sys_dict', 'code')->ignore($id) 34 | ], 35 | 'describe' => 'sometimes', 36 | 'type' => 'required|in:default,badge,tag' 37 | ]; 38 | } else { 39 | return [ 40 | 'name' => 'required', 41 | 'code' => 'required|unique:sys_dict,code', 42 | 'describe' => 'sometimes', 43 | 'type' => 'required|in:default,badge,tag' 44 | ]; 45 | } 46 | } 47 | 48 | protected function messages(): array 49 | { 50 | return [ 51 | 'name.required' => '字典名称不能为空', 52 | 'code.required' => '字典编码不能为空', 53 | 'code.unique' => '字典编码已存在', 54 | 'type.required' => '字典类型不能为空', 55 | 'type.in' => '字典类型格式错误' 56 | ]; 57 | } 58 | 59 | public function delete(int $id): bool 60 | { 61 | $model = $this->model()->find($id); 62 | $count = $model->dictItems()->count(); 63 | if ($count > 0) { 64 | throw new RepositoryException("字典包含子项!"); 65 | } 66 | return $model->delete(); 67 | } 68 | } -------------------------------------------------------------------------------- /app/Providers/AnnoRoute/Attribute/Mapping.php: -------------------------------------------------------------------------------- 1 | success($service->users($id)); 40 | } 41 | 42 | /** 设置启用状态 */ 43 | #[PutMapping('/status/{id}', authorize: 'status')] 44 | public function status(int $id, SysUserRoleService $service): JsonResponse 45 | { 46 | return $service->setStatus($id); 47 | } 48 | 49 | /** 设置角色权限 */ 50 | #[PostMapping('/rule', 'rule')] 51 | public function setRoleRule(Request $request, SysUserRoleService $service): JsonResponse 52 | { 53 | $service->setRule($request); 54 | return $this->success(); 55 | } 56 | 57 | /** 获取权限选项 */ 58 | #[GetMapping('/rule/list', 'rule.list')] 59 | public function ruleList(SysUserRuleService $service): JsonResponse 60 | { 61 | return $service->getRuleFields(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000009_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('queue')->index(); 17 | $table->longText('payload'); 18 | $table->unsignedTinyInteger('attempts'); 19 | $table->unsignedInteger('reserved_at')->nullable(); 20 | $table->unsignedInteger('available_at'); 21 | $table->unsignedInteger('created_at'); 22 | }); 23 | 24 | Schema::create('sys_job_batches', function (Blueprint $table) { 25 | $table->string('id')->primary(); 26 | $table->string('name'); 27 | $table->integer('total_jobs'); 28 | $table->integer('pending_jobs'); 29 | $table->integer('failed_jobs'); 30 | $table->longText('failed_job_ids'); 31 | $table->mediumText('options')->nullable(); 32 | $table->integer('cancelled_at')->nullable(); 33 | $table->integer('created_at'); 34 | $table->integer('finished_at')->nullable(); 35 | }); 36 | 37 | Schema::create('sys_failed_jobs', function (Blueprint $table) { 38 | $table->id(); 39 | $table->string('uuid')->unique(); 40 | $table->text('connection'); 41 | $table->text('queue'); 42 | $table->longText('payload'); 43 | $table->longText('exception'); 44 | $table->timestamp('failed_at')->useCurrent(); 45 | }); 46 | } 47 | 48 | /** 49 | * Reverse the migrations. 50 | */ 51 | public function down(): void 52 | { 53 | Schema::dropIfExists('sys_jobs'); 54 | Schema::dropIfExists('sys_job_batches'); 55 | Schema::dropIfExists('sys_failed_jobs'); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /app/Services/Sys/SysUserRuleService.php: -------------------------------------------------------------------------------- 1 | toArray(); 18 | $data = $this->getTreeData($data); 19 | return $this->success($data); 20 | } 21 | 22 | /** 23 | * 设置显示状态 24 | */ 25 | public function setShow($ruleID): JsonResponse 26 | { 27 | $model = SysRuleModel::find($ruleID); 28 | if (! $model) { 29 | return $this->error(__('system.data_not_exist')); 30 | } 31 | $model->hidden = $model->hidden ? 0 : 1; 32 | $model->save(); 33 | return $this->success(); 34 | } 35 | 36 | /** 37 | * 设置状态 38 | */ 39 | public function setStatus($ruleID): JsonResponse 40 | { 41 | $model = SysRuleModel::find($ruleID); 42 | if (! $model) { 43 | return $this->error(__('system.data_not_exist')); 44 | } 45 | $model->status = $model->status ? 0 : 1; 46 | $model->save(); 47 | return $this->success(); 48 | } 49 | 50 | /** 51 | * 获取父节点 52 | */ 53 | public function getRuleParent(): JsonResponse 54 | { 55 | $data = SysRuleModel::query() 56 | ->whereIn('type', ['menu', 'route', 'nested-route']) 57 | ->get(['name', 'id', 'parent_id']) 58 | ->toArray(); 59 | $data = $this->getTreeData($data); 60 | return $this->success($data); 61 | } 62 | 63 | /** 获取权限选择项 */ 64 | public function getRuleFields(): JsonResponse 65 | { 66 | $data = SysRuleModel::query() 67 | ->where("status", 1) 68 | ->get(['name as title', 'parent_id', 'id as key', 'id', 'local']) 69 | ->toArray(); 70 | $data = $this->getTreeData($data); 71 | return $this->success($data); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000005_create_setting_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 17 | $table->string('key', 50)->comment('设置项标示'); 18 | $table->string('title', 50)->comment('设置标题'); 19 | $table->string('describe', 500)->default('')->comment('设置项描述'); 20 | $table->string('values', 255)->default('')->comment('设置值'); 21 | $table->string('type', 50)->comment('设置类型'); 22 | $table->string('options', 500)->nullable()->comment('options配置'); 23 | $table->string('props', 500)->nullable()->comment('props配置'); 24 | $table->integer('group_id')->comment('分组ID'); 25 | $table->integer('sort')->comment('排序'); 26 | $table->timestamps(); 27 | $table->comment('系统设置表'); 28 | $table->unique(['key', 'group_id']); 29 | }); 30 | } 31 | if (! Schema::hasTable('sys_setting_group')) { 32 | Schema::create('sys_setting_group', function (Blueprint $table) { 33 | $table->increments('id'); 34 | $table->string('title', 50)->comment('分组标题'); 35 | $table->string('key', 50)->comment('分组KEY'); 36 | $table->string('remark', 255)->nullable()->comment('备注描述'); 37 | $table->timestamps(); 38 | $table->comment('设置分组表'); 39 | }); 40 | } 41 | } 42 | 43 | /** 44 | * Reverse the migrations. 45 | */ 46 | public function down(): void 47 | { 48 | Schema::dropIfExists('sys_setting_items'); 49 | Schema::dropIfExists('sys_setting_group'); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000004_create_file_table.php: -------------------------------------------------------------------------------- 1 | increments('id')->comment('文件ID'); 18 | $table->integer('group_id')->default(0)->comment('文件分组ID'); 19 | $table->integer('channel')->comment('上传来源(10:系统用户 20:App用户端)'); 20 | $table->string('disk', 10)->comment('存储方式'); 21 | $table->integer('file_type')->comment('文件类型'); 22 | $table->string('file_name', 255)->comment('文件名称'); 23 | $table->string('file_path', 255)->comment('文件路径'); 24 | $table->integer('file_size')->comment('文件大小(字节)'); 25 | $table->string('file_ext', 20)->comment('文件扩展名'); 26 | $table->integer('uploader_id')->comment('上传者用户ID'); 27 | $table->softDeletes(); 28 | $table->timestamps(); 29 | $table->comment('文件表'); 30 | }); 31 | } 32 | if (! Schema::hasTable('sys_file_group')) { 33 | Schema::create('sys_file_group', function (Blueprint $table) { 34 | $table->increments('id')->comment('文件分组ID'); 35 | $table->integer('parent_id')->default(0)->comment('上级ID'); 36 | $table->string('name', 50)->comment('文件名称'); 37 | $table->integer('sort')->comment('分组排序'); 38 | $table->string('describe', 500)->nullable()->comment('分组描述'); 39 | $table->timestamps(); 40 | $table->comment('文件分组表'); 41 | }); 42 | } 43 | } 44 | 45 | /** 46 | * Reverse the migrations. 47 | */ 48 | public function down(): void 49 | { 50 | Schema::dropIfExists('sys_file'); 51 | Schema::dropIfExists('sys_file_group'); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /app/Http/Controllers/App/IndexController.php: -------------------------------------------------------------------------------- 1 | success(compact('web_setting')); 30 | } 31 | 32 | /** 用户登录 */ 33 | #[PostMapping('/login')] 34 | public function login(Request $request): JsonResponse 35 | { 36 | $credentials = $request->validate([ 37 | 'username' => 'required|min:4|alphaDash', 38 | 'password' => 'required|min:4|alphaDash', 39 | ]); 40 | if (Auth::guard('users')->attempt($credentials, true)) { 41 | $data = $request->user('users') 42 | ->createToken($credentials['username']) 43 | ->toArray(); 44 | return $this->success($data, __('user.login_success')); 45 | } 46 | return $this->error(__('user.login_error')); 47 | } 48 | 49 | /** 用户注册 */ 50 | #[PostMapping('/register')] 51 | public function register(UserRegisterRequest $request): JsonResponse 52 | { 53 | $data = $request->validated(); 54 | $model = new UserModel; 55 | $model->username = $data['username']; 56 | $model->password = password_hash($data['password'], PASSWORD_DEFAULT); 57 | $model->email = $data['email']; 58 | if ($model->save()) { 59 | return $this->success(); 60 | } 61 | 62 | return $this->error('创建用户失败'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysDictItemRepository.php: -------------------------------------------------------------------------------- 1 | '=' 14 | ]; 15 | 16 | /** @var array|string[] 快速搜索字段 */ 17 | protected array $quickSearchField = ['label', 'value']; 18 | 19 | /** 20 | * @inheritDoc 21 | */ 22 | protected function model(): Builder 23 | { 24 | return SysDictItemModel::query(); 25 | } 26 | 27 | protected function rules(): array 28 | { 29 | if($this->isUpdate()) { 30 | $request = request()->all(); 31 | return [ 32 | 'dict_id' => 'required|exists:sys_dict,id', 33 | 'label' => 'required', 34 | 'value' => [ 35 | Rule::unique('sys_dict_item')->where(function ($query) use ($request) { 36 | return $query->where('dict_id', $request['dict_id'] ?? null); 37 | }) 38 | ], 39 | 'switch' => 'required|int|in:0,1', 40 | 'status' => 'required|in:default,success,error,processing,warning' 41 | ]; 42 | } else { 43 | return [ 44 | 'dict_id' => 'required|exists:sys_dict,id', 45 | 'label' => 'required', 46 | 'value' => 'required', 47 | 'switch' => 'required|in:0,1', 48 | 'status' => 'required|in:default,success,error,processing,warning' 49 | ]; 50 | } 51 | 52 | } 53 | 54 | protected function messages(): array 55 | { 56 | return [ 57 | 'dict_id.required' => '字典ID不能为空', 58 | 'dict_id.exists' => '字典不存在', 59 | 'label.required' => '字典项名称不能为空', 60 | 'value.required' => '字典项值不能为空', 61 | 'switch.required' => '启用状态不能为空', 62 | 'switch.in' => '启用状态格式错误', 63 | 'status.required' => '状态不能为空', 64 | 'status.in' => '状态格式错误' 65 | ]; 66 | } 67 | } -------------------------------------------------------------------------------- /app/Models/Sys/SysSettingItemsModel.php: -------------------------------------------------------------------------------- 1 | 'int', 17 | 'sort' => 'int', 18 | ]; 19 | 20 | protected $fillable = [ 21 | 'key', 22 | 'title', 23 | 'describe', 24 | 'values', 25 | 'type', 26 | 'options', 27 | 'props', 28 | 'group_id', 29 | 'sort', 30 | ]; 31 | 32 | protected $appends = ['options_json', 'props_json']; 33 | 34 | /** 35 | * 关联设置 36 | * @return BelongsTo 37 | */ 38 | public function group(): BelongsTo 39 | { 40 | return $this->belongsTo(SysSettingGroupModel::class, 'id', 'group_id'); 41 | } 42 | 43 | public function getOptionsJsonAttribute(): string 44 | { 45 | if(empty($this->options)) { 46 | return "{}"; 47 | } 48 | $data = []; 49 | $value = explode("\n", $this->options); 50 | foreach ($value as $item) { 51 | $item = explode('=',$item); 52 | if(count($item) < 2) { 53 | continue; 54 | } 55 | $data[] = [ 56 | 'label' => $item[1], 57 | 'value' => $item[0] 58 | ]; 59 | } 60 | return json_encode($data); 61 | } 62 | 63 | public function getPropsJsonAttribute(): string 64 | { 65 | if(empty($this->props)) { 66 | return "{}"; 67 | } 68 | $data = []; 69 | $value = explode("\n",$this->props); 70 | foreach ($value as $item) { 71 | $item = explode('=',$item); 72 | if(count($item) < 2) { 73 | continue; 74 | } 75 | if($item[1] === 'false') { 76 | $data[$item[0]] = false; 77 | }elseif ($item[1] === 'true') { 78 | $data[$item[0]] = true; 79 | }else { 80 | $data[$item[0]] = $item[1]; 81 | } 82 | } 83 | return json_encode($data); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/Providers/Telescope/Storage/EntryQueryOptions.php: -------------------------------------------------------------------------------- 1 | validate([ 43 | 'type' => ['nullable', 'string'], 44 | 'date' => ['nullable', 'date'], 45 | 'pageSize' => ['nullable', 'integer'], 46 | 'current' => ['nullable', 'integer'], 47 | ]); 48 | 49 | return (new static) 50 | ->type($params['type'] ?? 'request') 51 | ->date($params['date'] ?? date('Y-m-d')) 52 | ->page($params['current'] ?? 1) 53 | ->limit($params['pageSize'] ?? 10); 54 | } 55 | 56 | /** 57 | * Set the date of entries to retrieve. 58 | */ 59 | public function date(string $date): static 60 | { 61 | $this->date = $date; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Set the type of entries to retrieve. 68 | */ 69 | public function page(int $page): static 70 | { 71 | $this->page = $page; 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * Set the type of entries to retrieve. 78 | */ 79 | public function type(string $type): static 80 | { 81 | $this->type = $type; 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * Set the number of entries that should be retrieved. 88 | */ 89 | public function limit(int $limit): static 90 | { 91 | $this->limit = $limit; 92 | 93 | return $this; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysFileGroupRepository.php: -------------------------------------------------------------------------------- 1 | 'like']; 15 | 16 | protected function model(): Builder 17 | { 18 | return SysFileGroupModel::query(); 19 | } 20 | 21 | protected function rules(): array 22 | { 23 | if(! $this->isUpdate()) { 24 | return [ 25 | 'parent_id' => [ 26 | 'required', 27 | 'integer', 28 | function ($attribute, $value, $fail) { 29 | if ($value != 0 && !DB::table('sys_dept')->where('id', $value)->exists()) { 30 | $fail('选择的上级部门不存在。'); 31 | } 32 | }, 33 | ], 34 | 'name' => 'required|string|max:255', 35 | 'describe' => 'sometimes|string|max:500', 36 | 'sort' => 'sometimes|integer|min:0', 37 | ]; 38 | } else { 39 | return [ 40 | 'name' => 'required|string|max:255', 41 | 'describe' => 'sometimes|string|max:500', 42 | 'sort' => 'sometimes|integer|min:0', 43 | ]; 44 | } 45 | } 46 | 47 | protected function messages(): array 48 | { 49 | return [ 50 | 'name.required' => '分组名称不能为空', 51 | 'name.string' => '分组名称必须是字符串', 52 | 'name.max' => '分组名称不能超过50个字符', 53 | 54 | 'sort.integer' => '分组排序必须是整数', 55 | 'sort.min' => '分组排序不能为负数', 56 | 57 | 'describe.string' => '分组描述必须是字符串', 58 | 'describe.max' => '分组描述不能超过500个字符', 59 | ]; 60 | } 61 | 62 | public function delete(int $id): bool 63 | { 64 | $model = SysFileGroupModel::find($id); 65 | if (empty($model)) { 66 | throw new RepositoryException('Model not found'); 67 | } 68 | if ($model->countFiles > 0) { 69 | throw new RepositoryException('该文件夹下存在文件,无法删除'); 70 | } 71 | return $model->delete(); 72 | } 73 | } -------------------------------------------------------------------------------- /app/Http/Controllers/App/UserController.php: -------------------------------------------------------------------------------- 1 | user(); 26 | return $this->success(compact('info')); 27 | } 28 | 29 | #[PostMapping('/logout')] 30 | public function logout(): JsonResponse 31 | { 32 | $user_id = auth('users')->id(); 33 | $model = new UserModel; 34 | if ($model->logout($user_id)) { 35 | return $this->success('退出登录成功'); 36 | } else { 37 | return $this->error($model->getErrorMsg()); 38 | } 39 | } 40 | 41 | #[PutMapping] 42 | public function setUserInfo(UserUpdateInfoRequest $request): JsonResponse 43 | { 44 | UserModel::where('user_id', auth('user')->id())->update($request->validated()); 45 | 46 | return $this->error('更新成功'); 47 | } 48 | 49 | #[PostMapping('/setPwd')] 50 | public function setPassword(Request $request): JsonResponse 51 | { 52 | $data = $request->validate([ 53 | 'oldPassword' => 'required|string|max:20', 54 | 'newPassword' => 'required|string|min:6|max:20', 55 | 'rePassword' => 'required|same:newPassword', 56 | ]); 57 | $user_id = auth('user')->id(); 58 | $user = UserModel::query()->find($user_id); 59 | if (! password_verify($data['oldPassword'], $user['password'])) { 60 | return $this->error('旧密码不正确!'); 61 | } 62 | $user->password = password_hash($data['newPassword'], PASSWORD_DEFAULT); 63 | if ($user->save()) { 64 | return $this->success('更新成功'); 65 | } 66 | 67 | return $this->error('更新失败'); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/Repositories/Sys/SysRoleRepository.php: -------------------------------------------------------------------------------- 1 | '=', 17 | 'name' => 'like', 18 | ]; 19 | 20 | /** @var array|string[] 快速搜索字段 */ 21 | protected array $quickSearchField = ['name', 'description']; 22 | 23 | /** 24 | * @inheritDoc 25 | */ 26 | protected function model(): Builder 27 | { 28 | return SysRoleModel::query(); 29 | } 30 | 31 | protected function rules(): array 32 | { 33 | if(! $this->isUpdate()) { 34 | return [ 35 | 'name' => 'required|unique:sys_role,name', 36 | 'sort' => 'required|integer|min:0', 37 | 'description' => 'nullable|string', 38 | 'status' => 'required|integer|in:0,1' 39 | ]; 40 | } else { 41 | $id = request()->route('id'); 42 | return [ 43 | 'name' => [ 44 | 'required', 45 | 'string', 46 | Rule::unique('sys_role', 'name')->ignore($id) 47 | ], 48 | 'sort' => 'required|integer|min:0', 49 | 'description' => 'nullable|string', 50 | 'status' => 'required|integer|in:0,1' 51 | ]; 52 | } 53 | } 54 | 55 | public function messages(): array 56 | { 57 | return [ 58 | 'name.required' => '角色名称不能为空', 59 | 'name.unique' => '角色名称已存在', 60 | 'sort.required' => '排序不能为空', 61 | 'sort.integer' => '排序必须为整数', 62 | 'status.required' => '状态不能为空', 63 | 'status.in' => '状态格式错误' 64 | ]; 65 | } 66 | 67 | public function delete(int $id): bool 68 | { 69 | $model = SysRoleModel::find($id); 70 | if (empty($model)) { 71 | throw new RepositoryException('Model not found'); 72 | } 73 | if ($model->countUser > 0) { 74 | throw new RepositoryException('该角色下存在用户,无法删除'); 75 | } 76 | return $model->delete(); 77 | } 78 | } -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DISK', 'local'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Filesystem Disks 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Below you may configure as many filesystem disks as necessary, and you 24 | | may even configure multiple disks for the same driver. Examples for 25 | | most supported storage drivers are configured here for reference. 26 | | 27 | | Supported drivers: "local", "ftp", "sftp", "s3" 28 | | 29 | */ 30 | 31 | 'disks' => [ 32 | 'local' => [ 33 | 'driver' => 'local', 34 | 'root' => public_path('storage'), 35 | 'url' => env('APP_URL').'/storage', 36 | 'visibility' => 'public', 37 | 'throw' => false, 38 | ], 39 | 's3' => [ 40 | 'driver' => 's3', 41 | 'key' => env('AWS_ACCESS_KEY_ID'), 42 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 43 | 'region' => env('AWS_DEFAULT_REGION'), 44 | 'bucket' => env('AWS_BUCKET'), 45 | 'url' => env('AWS_URL'), 46 | 'endpoint' => env('AWS_ENDPOINT'), 47 | 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 48 | 'throw' => false, 49 | ], 50 | ], 51 | 52 | /* 53 | |-------------------------------------------------------------------------- 54 | | Symbolic Links 55 | |-------------------------------------------------------------------------- 56 | | 57 | | Here you may configure the symbolic links that will be created when the 58 | | `storage:link` Artisan command is executed. The array keys should be 59 | | the locations of the links and the values should be their targets. 60 | | 61 | */ 62 | 63 | 'links' => [], 64 | 65 | ]; 66 | -------------------------------------------------------------------------------- /app/Http/Middleware/LanguageMiddleware.php: -------------------------------------------------------------------------------- 1 | 'en', // 英语 17 | 'zh' => 'zh', // 简体中文 18 | 'jp' => 'ja', // 日语 19 | ]; 20 | 21 | /** 22 | * 默认语言 23 | */ 24 | protected string $defaultLanguage = 'zh'; 25 | 26 | /** 27 | * Handle an incoming request. 28 | */ 29 | public function handle(Request $request, Closure $next) 30 | { 31 | // 获取当前语言 32 | $locale = $this->getLocale($request); 33 | // 设置应用语言 34 | App::setLocale($locale); 35 | // 让请求继续处理 36 | return $next($request); 37 | } 38 | 39 | /** 40 | * 获取当前语言设置 41 | */ 42 | protected function getLocale(Request $request): string 43 | { 44 | // 优先级 1: URL 参数 (例如 ?lang=en) 45 | if ($request->has('lang')) { 46 | $lang = $request->get('lang'); 47 | if ($this->isSupported($lang)) { 48 | return $lang; 49 | } 50 | } 51 | 52 | // 优先级 2: User-Language 头 53 | $browserLocale = $this->getBrowserLocale($request); 54 | if ($browserLocale && $this->isSupported($browserLocale)) { 55 | return $browserLocale; 56 | } 57 | 58 | // 优先级 3: Session 中存储的语言 59 | if (Session::has('locale')) { 60 | $lang = Session::get('locale'); 61 | if ($this->isSupported($lang)) { 62 | return $lang; 63 | } 64 | } 65 | 66 | // 优先级 4: 配置文件中的默认语言 67 | return config('app.locale', $this->defaultLanguage); 68 | } 69 | 70 | /** 71 | * 从 User-Language 头中获取浏览器偏好语言 72 | */ 73 | protected function getBrowserLocale(Request $request): ?string 74 | { 75 | $acceptLanguage = $request->header('User-Language'); 76 | 77 | if (!$acceptLanguage) { 78 | return null; 79 | } 80 | 81 | return $acceptLanguage; 82 | } 83 | 84 | /** 85 | * 检查语言是否被支持 86 | */ 87 | protected function isSupported(string $locale): bool 88 | { 89 | return array_key_exists($locale, $this->supportedLanguages); 90 | } 91 | } -------------------------------------------------------------------------------- /app/Support/Enum/SettingType.php: -------------------------------------------------------------------------------- 1 | '输入框', 27 | self::TEXTAREA => '文本域', 28 | self::INPUT_NUMBER => '数字输入框', 29 | self::SWITCH => '开关', 30 | self::RADIO => '单选框', 31 | self::CHECKBOX => '复选框', 32 | self::SELECT => '下拉选择', 33 | self::DATE_PICKER => '日期选择器', 34 | }; 35 | } 36 | 37 | /** 38 | * 判断是否为数字类型 39 | */ 40 | public function isNumeric(): bool 41 | { 42 | return match($this) { 43 | self::INPUT_NUMBER => true, 44 | default => false, 45 | }; 46 | } 47 | 48 | /** 49 | * 判断是否为布尔类型 50 | */ 51 | public function isBoolean(): bool 52 | { 53 | return match($this) { 54 | self::SWITCH => true, 55 | default => false, 56 | }; 57 | } 58 | 59 | /** 60 | * 判断是否为数组类型 61 | */ 62 | public function isArray(): bool 63 | { 64 | return match($this) { 65 | self::CHECKBOX => true, 66 | default => false, 67 | }; 68 | } 69 | 70 | /** 71 | * 获取所有前端组件类型 72 | */ 73 | public static function getFrontendTypes(): array 74 | { 75 | return [ 76 | self::INPUT->value, 77 | self::TEXTAREA->value, 78 | self::INPUT_NUMBER->value, 79 | self::SWITCH->value, 80 | self::RADIO->value, 81 | self::CHECKBOX->value, 82 | self::SELECT->value, 83 | self::DATE_PICKER->value, 84 | ]; 85 | } 86 | 87 | /** 88 | * 从字符串创建枚举实例(宽松匹配) 89 | */ 90 | public static function fromString(string $type): ?self 91 | { 92 | return self::tryFrom($type); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/Providers/Telescope/Watchers/RedisWatcher.php: -------------------------------------------------------------------------------- 1 | bound('redis')) { 18 | return; 19 | } 20 | 21 | $app['events']->listen(CommandExecuted::class, [$this, 'recordCommand']); 22 | 23 | foreach ((array) $app['redis']->connections() as $connection) { 24 | $connection->setEventDispatcher($app['events']); 25 | } 26 | 27 | $app['redis']->enableEvents(); 28 | } 29 | 30 | /** 31 | * Record a Redis command was executed. 32 | * 33 | * @param CommandExecuted $event 34 | * @return void 35 | */ 36 | public function recordCommand(CommandExecuted $event): void 37 | { 38 | if (! Telescope::isRecording() || $this->shouldIgnore($event)) { 39 | return; 40 | } 41 | 42 | Telescope::record(IncomingEntry::make([ 43 | 'connection' => $event->connectionName, 44 | 'command' => $this->formatCommand($event->command, $event->parameters), 45 | 'time' => number_format($event->time, 2, '.', ''), 46 | ], EntryType::REDIS)); 47 | } 48 | 49 | /** 50 | * Format the given Redis command. 51 | */ 52 | private function formatCommand(string $command, array $parameters): string 53 | { 54 | $parameters = collect($parameters)->map(function ($parameter) { 55 | if (is_array($parameter)) { 56 | return collect($parameter)->map(function ($value, $key) { 57 | if (is_array($value)) { 58 | return json_encode($value); 59 | } 60 | 61 | return is_int($key) ? $value : "{$key} {$value}"; 62 | })->implode(' '); 63 | } 64 | 65 | return $parameter; 66 | })->implode(' '); 67 | 68 | return "{$command} {$parameters}"; 69 | } 70 | 71 | /** 72 | * Determine if the event should be ignored. 73 | */ 74 | private function shouldIgnore(mixed $event): bool 75 | { 76 | return in_array($event->command, [ 77 | 'pipeline', 'transaction', 78 | ]); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
35 |
36 | ## 主要特征
37 |
38 | ### ✨ 前后端分离
39 | 采用Api接口规范,前后端分离开发模式,后端不必考虑视图UI功能,前端web目录不包含任何后端代码。
40 |
41 | ### 🎨 React 技术栈
42 | 阿里 Umi Js 以及 AntdPro 组件库,不仅简单易用,并且可以是你的技术更上一层楼,带你体验技术的革新,站在巨人肩膀上享受开发的便捷和乐趣。
43 |
44 | ### 📟 权限控制系统
45 | 我们提供了完善的权限验证系统,支持客户端、管理端,双动态菜单,页面按钮级权限控制,使用PHP8注解验证精确控制接口请求,支持分组权限禁用继承。
46 |
47 | ### ♻️ 数据字典和全局设置
48 | 强大的数据字典,支持CRUD生成,value、label 映射,支持标签、文字、徽标三种表格展示类型,多种显示状态,还有方便的系统配置。
49 |
50 | ### 🎁 文件管理系统
51 | XinAdmin 拥有强大的文件系统,可拓展 AliOss 存储 支持多选、文件分组等,支持图片、视频、音频、压缩文件和其它文件上传
52 |
53 | ## 内置功能
54 |
55 | - 仪表盘:提供基于 antv 开箱即用的仪表盘方案,以及演示页面
56 | - 示例组件:包含图标、表格、列表、表单等组件的示例
57 | - 前台会员:前台会员的权限管理、分组和列表以及余额记录等
58 | - 管理员:管理员是后台系统的访问者,提供管理员分组、权限、列表以及管理员信息设置
59 | - 系统设置:系统设置是对服务器可变参数快速设置的表单,可以自定义分组以及表单类型
60 | - 文件管理:文件上传解决方案,可拓展 AliOss 存储,后台文件管理文件夹,支持多选、文件分组等,支持图片、视频、音频、压缩文件和其它文件上传
61 | - 字典管理:对系统中经常使用的一些较为固定的数据进行维护
62 |
63 | ## 其他
64 | - 接口文档:[https://api.xinadmin.cn/](https://api.xinadmin.cn/)
65 | - 演示站:[https://laravel.xinadmin.cn/](https://laravel.xinadmin.cn/)
--------------------------------------------------------------------------------
/app/Http/Controllers/Sys/SysUserController.php:
--------------------------------------------------------------------------------
1 | login($request);
31 | }
32 |
33 | /** 退出登录 */
34 | #[PostMapping('/logout')]
35 | public function logout(Request $request): JsonResponse
36 | {
37 | $request->user()->currentAccessToken()->delete();
38 | return $this->success(__('user.logout_success'));
39 | }
40 |
41 | /** 获取管理员信息 */
42 | #[GetMapping('/info')]
43 | public function info(SysUserService $service): JsonResponse
44 | {
45 | $info = Auth::user();
46 | $id = Auth::id();
47 | $access = $service->ruleKeys($id);
48 | $menus = $service->getAdminMenus($id);
49 | return $this->success(compact('access', 'menus', 'info'));
50 | }
51 |
52 | /** 更新管理员信息 */
53 | #[PutMapping]
54 | public function updateInfo(Request $request, SysUserService $service): JsonResponse
55 | {
56 | return $service->updateInfo(Auth::id(), $request);
57 | }
58 |
59 | /** 修改密码 */
60 | #[PutMapping('/updatePassword')]
61 | public function updatePassword(Request $request, SysUserService $service): JsonResponse
62 | {
63 | return $service->updatePassword($request);
64 | }
65 |
66 | /** 上传头像 */
67 | #[PostMapping('/avatar')]
68 | public function uploadAvatar(SysUserService $service): JsonResponse
69 | {
70 | return $service->uploadAvatar();
71 | }
72 |
73 | /** 获取管理员登录日志 */
74 | #[GetMapping('/login/record')]
75 | public function get(SysLoginRecordService $service): JsonResponse
76 | {
77 | $id = Auth::id();
78 | $data = $service->getRecordByID($id);
79 | return $this->success($data);
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "laravel/laravel",
3 | "type": "project",
4 | "description": "The skeleton application for the Laravel framework.",
5 | "keywords": ["laravel", "framework"],
6 | "license": "MIT",
7 | "require": {
8 | "php": "^8.2",
9 | "ext-bcmath": "*",
10 | "ext-curl": "*",
11 | "ext-pdo": "*",
12 | "ext-redis": "*",
13 | "laravel/framework": "^v12.0",
14 | "laravel/sanctum": "^4.0",
15 | "laravel/tinker": "^2.10.1",
16 | "league/flysystem-aws-s3-v3": "3.0",
17 | "predis/predis": "2.0"
18 | },
19 | "require-dev": {
20 | "fakerphp/faker": "^1.23",
21 | "laravel/pail": "^1.2.2",
22 | "laravel/sail": "^1.41",
23 | "mockery/mockery": "^1.6",
24 | "nunomaduro/collision": "^8.6",
25 | "phpunit/phpunit": "^11.5.3"
26 | },
27 | "autoload": {
28 | "psr-4": {
29 | "App\\": "app/",
30 | "Database\\Seeders\\": "database/seeders/"
31 | },
32 | "files": [
33 | "app/Support/helpers.php"
34 | ]
35 | },
36 | "autoload-dev": {
37 | "psr-4": {
38 | "Tests\\": "tests/"
39 | }
40 | },
41 | "scripts": {
42 | "dev": [
43 | "Composer\\Config::disableProcessTimeout",
44 | "npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" --names='server,queue'"
45 | ],
46 | "post-create-project-cmd": [
47 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
48 | "@php artisan key:generate --ansi",
49 | "@php artisan migrate --graceful --ansi",
50 | "@php artisan db:seed --force"
51 | ],
52 | "post-autoload-dump": [
53 | "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
54 | "@php artisan package:discover --ansi"
55 | ],
56 | "post-update-cmd": [
57 | "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
58 | ]
59 | },
60 | "extra": {
61 | "laravel": {
62 | "dont-discover": []
63 | }
64 | },
65 | "replace": {
66 | "symfony/polyfill-php80": "*"
67 | },
68 | "config": {
69 | "optimize-autoloader": true,
70 | "preferred-install": "dist",
71 | "sort-packages": true,
72 | "allow-plugins": {
73 | "pestphp/pest-plugin": true,
74 | "php-http/discovery": true
75 | }
76 | },
77 | "minimum-stability": "stable",
78 | "prefer-stable": true
79 | }
80 |
--------------------------------------------------------------------------------
/app/Providers/Telescope/Storage/StorageRepository.php:
--------------------------------------------------------------------------------
1 | type. '-' . $options->date . '.jsonl';
16 | $storage = Storage::disk('telescope');
17 | if (!$storage->exists($fileName)) {
18 | return [];
19 | }
20 | $filePath = storage_path('telescope/' . $fileName);
21 |
22 | $collection = LazyCollection::make(function () use ($filePath) {
23 | $file = fopen($filePath, 'r');
24 |
25 | while (($line = fgets($file)) !== false) {
26 | yield json_decode($line, true);
27 | }
28 | fclose($file);
29 | });
30 |
31 | return [
32 | 'data' => $collection->forPage($options->page, $options->limit)->values()->all(),
33 | 'total' => $collection->count(),
34 | 'pageSize' => $options->limit,
35 | 'current' => $options->page,
36 | ];
37 | }
38 |
39 | public function store(array|Collection $entries): void
40 | {
41 | if ($entries->isEmpty()) {
42 | return;
43 | }
44 | $date = date('Y-m-d');
45 | $contents = [];
46 | foreach (config('telescope.watchers') as $key => $watcher) {
47 | if (! is_string($key)) continue;
48 | if( $watcher === false ) continue;
49 | $contents[$key] = [];
50 | }
51 | $entries->each(function (IncomingEntry $chunked) use (&$contents) {
52 | if(empty($chunked->content)) {
53 | return;
54 | }
55 | $contents[$chunked->type][] = $chunked->getContentAsString();
56 | });
57 | $storage = Storage::disk('telescope');
58 | foreach ($contents as $type => $entries) {
59 | if (empty($entries)) {
60 | continue;
61 | }
62 | $fileName = $type. '-' . $date . '.jsonl';
63 | // 文件不存在创建文件
64 | if (!$storage->exists($fileName)) {
65 | $storage->put($fileName, implode("\n", $entries));
66 | continue;
67 | }
68 | $storage->append($fileName, implode("\n", $entries));
69 | }
70 | }
71 |
72 | public function clear(): void
73 | {
74 | // TODO: Implement clear() method.
75 | }
76 | }
--------------------------------------------------------------------------------
/app/Repositories/Sys/SysFileRepository.php:
--------------------------------------------------------------------------------
1 | '=',
13 | 'name' => 'like',
14 | 'file_type' => '=',
15 | ];
16 |
17 | /**
18 | * @inheritDoc
19 | */
20 | protected function model(): Builder
21 | {
22 | return SysFileModel::query();
23 | }
24 |
25 | protected function rules(): array
26 | {
27 | return [
28 | 'group_id' => 'required|integer|exists:sys_file_group,id',
29 | 'channel' => 'required|integer|in:10,20',
30 | 'disk' => 'required|string|max:10',
31 | 'file_type' => 'required|integer',
32 | 'file_name' => 'required|string|max:255',
33 | 'file_path' => 'required|string|max:255',
34 | 'file_size' => 'required|integer|min:0',
35 | 'file_ext' => 'required|string|max:20',
36 | 'uploader_id' => 'required|integer',
37 | ];
38 | }
39 |
40 | protected function messages(): array
41 | {
42 | return [
43 | // 文件表验证消息
44 | 'group_id.required' => '文件分组ID不能为空',
45 | 'group_id.integer' => '文件分组ID必须是整数',
46 | 'group_id.exists' => '文件分组不存在',
47 |
48 | 'channel.required' => '上传来源不能为空',
49 | 'channel.integer' => '上传来源必须是整数',
50 | 'channel.in' => '上传来源必须是10(系统用户)或20(App用户端)',
51 |
52 | 'disk.required' => '存储方式不能为空',
53 | 'disk.string' => '存储方式必须是字符串',
54 | 'disk.max' => '存储方式不能超过10个字符',
55 |
56 | 'file_type.required' => '文件类型不能为空',
57 | 'file_type.integer' => '文件类型必须是整数',
58 |
59 | 'file_name.required' => '文件名称不能为空',
60 | 'file_name.string' => '文件名称必须是字符串',
61 | 'file_name.max' => '文件名称不能超过255个字符',
62 |
63 | 'file_path.required' => '文件路径不能为空',
64 | 'file_path.string' => '文件路径必须是字符串',
65 | 'file_path.max' => '文件路径不能超过255个字符',
66 |
67 | 'file_size.required' => '文件大小不能为空',
68 | 'file_size.integer' => '文件大小必须是整数',
69 | 'file_size.min' => '文件大小不能为负数',
70 |
71 | 'file_ext.required' => '文件扩展名不能为空',
72 | 'file_ext.string' => '文件扩展名必须是字符串',
73 | 'file_ext.max' => '文件扩展名不能超过20个字符',
74 |
75 | 'uploader_id.required' => '上传者用户ID不能为空',
76 | 'uploader_id.integer' => '上传者用户ID必须是整数',
77 | ];
78 | }
79 |
80 | public function getTrashedList(array $params = []): array
81 | {
82 | $pageSize = $params['pageSize'] ?? 10;
83 | return $this->model()
84 | ->onlyTrashed()
85 | ->paginate($pageSize)
86 | ->toArray();
87 | }
88 | }
--------------------------------------------------------------------------------
/app/Repositories/Sys/SysSettingItemsRepository.php:
--------------------------------------------------------------------------------
1 | '=' ];
14 |
15 | protected function rules(): array
16 | {
17 | return [
18 | 'title' => 'required',
19 | 'key' => 'required|min:2|max:255',
20 | 'group_id' => 'required|exists:sys_setting_group,id',
21 | 'type' => 'required',
22 | 'describe' => 'sometimes|string',
23 | 'options' => [
24 | 'sometimes',
25 | 'nullable',
26 | 'string',
27 | 'regex:/^(?:[^=\n]+=[^=\n]+)(?:\n[^=\n]+=[^=\n]+)*$/',
28 | ],
29 | 'props' => [
30 | 'sometimes',
31 | 'nullable',
32 | 'string',
33 | 'regex:/^(?:[^=\n]+=[^=\n]+)(?:\n[^=\n]+=[^=\n]+)*$/',
34 | ],
35 | 'sort' => 'sometimes|integer',
36 | 'values' => 'sometimes|string',
37 | ];
38 | }
39 |
40 | protected function messages(): array
41 | {
42 | return [
43 | 'title.required' => '标题字段是必填的',
44 | 'key.required' => '键名字段是必填的',
45 | 'key.min' => '键名至少需要 :min 个字符',
46 | 'key.max' => '键名不能超过 :max 个字符',
47 | 'group_id.required' => '分组ID是必填的',
48 | 'group_id.exists' => '选择的分组不存在',
49 | 'type.required' => '类型字段是必填的',
50 | 'describe.string' => '描述必须是字符串',
51 | 'options.regex' => '选项格式不正确,应为 key=value 格式,多个用换行分隔',
52 | 'props.regex' => '属性格式不正确,应为 key=value 格式,多个用换行分隔',
53 | 'sort.integer' => '排序必须是整数',
54 | 'values.string' => '值必须是字符串',
55 | ];
56 | }
57 |
58 | /** 验证数据 */
59 | protected function validation(array $data): array
60 | {
61 | $data = parent::validation($data);
62 | $num = $this->model()->where('group_id', $data['group_id'])->where('key', $data['key'])->count();
63 | if($num > 0 && request()->method() != 'PUT') {
64 | throw new RepositoryException(
65 | 'Validation failed: ' . '该键名在此分组中已存在',
66 | );
67 | }
68 | return $data;
69 | }
70 |
71 | /**
72 | * @inheritDoc
73 | */
74 | protected function model(): Builder
75 | {
76 | return SysSettingItemsModel::query();
77 | }
78 |
79 | public function list(array $params): array
80 | {
81 | if(empty($params['group_id'])) {
82 | throw new RepositoryException('请选择设置分组');
83 | }
84 | return $this->model()
85 | ->where('group_id', $params['group_id'])
86 | ->get()
87 | ->toArray();
88 | }
89 | }
--------------------------------------------------------------------------------
/app/Services/Sys/SysUserDeptService.php:
--------------------------------------------------------------------------------
1 | get()->toArray();
22 | $data = $this->getTreeData($data);
23 |
24 | return $this->success($data);
25 | }
26 |
27 | /**
28 | * 批量删除部门
29 | * @param Request $request
30 | * @return JsonResponse
31 | */
32 | public function delete(Request $request): JsonResponse
33 | {
34 | $request->validate([
35 | 'ids' => 'required|array',
36 | 'ids.*' => 'integer|exists:sys_dept,id'
37 | ]);
38 |
39 | $ids = $request->input('ids');
40 |
41 | // 检查是否有下级部门
42 | $departmentsWithChildren = SysDeptModel::whereIn('id', $ids)
43 | ->whereHas('children')
44 | ->get();
45 |
46 | if ($departmentsWithChildren->isNotEmpty()) {
47 | return $this->error('存在下级部门的部门无法删除');
48 | }
49 |
50 | // 执行删除
51 | SysDeptModel::whereIn('id', $ids)->delete();
52 |
53 | return $this->success('部门删除成功');
54 | }
55 |
56 | /**
57 | * 获取部门用户列表
58 | * @param int $id
59 | * @return JsonResponse
60 | */
61 | public function users(int $id): JsonResponse
62 | {
63 | $model = SysDeptModel::query()->find($id);
64 | if (empty($model)) {
65 | return $this->error('部门不存在');
66 | }
67 | $pageSize = request()->input('pageSize') ?? 10;
68 | $data = $model->users()
69 | ->select(['id', 'username', 'nickname', 'email', 'mobile', 'status'])
70 | ->paginate($pageSize)
71 | ->toArray();
72 | return $this->success($data);
73 | }
74 |
75 |
76 | /** 获取部门选择项 */
77 | public function getDeptField(): array
78 | {
79 | $field = SysDeptModel::where('status', 0)
80 | ->select(['id as dept_id', 'name', 'parent_id'])
81 | ->get()
82 | ->toArray();
83 | return $this->buildTree($field);
84 | }
85 |
86 | /** 构建树形结构 */
87 | private function buildTree(array $items, $parentId = 0): array
88 | {
89 | $tree = [];
90 | foreach ($items as $item) {
91 | if ($item['parent_id'] == $parentId) {
92 | $children = $this->buildTree($items, $item['dept_id']);
93 | $node = [
94 | 'dept_id' => $item['dept_id'],
95 | 'name' => $item['name'],
96 | 'children' => $children
97 | ];
98 | $tree[] = $node;
99 | }
100 | }
101 | return $tree;
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/app/Models/Sys/SysUserModel.php:
--------------------------------------------------------------------------------
1 | 'datetime',
42 | 'login_time' => 'datetime',
43 | 'status' => 'integer',
44 | 'sex' => 'integer',
45 | 'avatar_id' => 'integer',
46 | 'dept_id' => 'integer'
47 | ];
48 |
49 | protected $appends = ['role_id', 'dept_name', 'avatar_url'];
50 |
51 | protected $with = ['dept', 'avatar'];
52 |
53 | protected $hidden = [
54 | 'dept',
55 | 'avatar',
56 | 'password',
57 | 'remember_token',
58 | 'deleted_at',
59 | ];
60 |
61 | /**
62 | * 定义与部门的归属关系
63 | */
64 | public function dept(): BelongsTo
65 | {
66 | return $this->belongsTo(SysDeptModel::class, 'dept_id', 'id');
67 | }
68 |
69 | /** 部门名称 */
70 | public function getDeptNameAttribute(): string
71 | {
72 | return $this->dept->name ?? '';
73 | }
74 |
75 | /**
76 | * 定义与角色的关联
77 | */
78 | public function roles(): BelongsToMany
79 | {
80 | return $this->belongsToMany(SysRoleModel::class, 'sys_user_role', 'user_id', 'role_id');
81 | }
82 |
83 | /**
84 | * 获取用户角色列表
85 | */
86 | public function getRoleIdAttribute()
87 | {
88 | return $this->roles()
89 | ->pluck('id')->toArray();
90 | }
91 |
92 | /**
93 | * 定义与登录日志的关联
94 | */
95 | public function loginRecords(): HasMany
96 | {
97 | return $this->hasMany(SysLoginRecordModel::class, 'user_id', 'id');
98 | }
99 |
100 | /**
101 | * 关联用户头像
102 | * @return HasOne
103 | */
104 | public function avatar(): HasOne
105 | {
106 | return $this->hasOne(SysFileModel::class, 'id', 'avatar_id');
107 | }
108 |
109 | /**
110 | * 获取用户角色列表
111 | */
112 | public function getAvatarUrlAttribute()
113 | {
114 | if($this->avatar) {
115 | return $this->avatar->preview_url;
116 | }
117 | return null;
118 | }
119 |
120 |
121 |
122 | }
123 |
--------------------------------------------------------------------------------
/app/Models/Sys/SysFileModel.php:
--------------------------------------------------------------------------------
1 | 'int',
24 | 'channel' => 'int',
25 | 'file_type' => 'int',
26 | 'file_size' => 'int',
27 | 'uploader_id' => 'int',
28 | ];
29 |
30 | protected $fillable = [
31 | 'group_id',
32 | 'disk',
33 | 'channel',
34 | 'file_type',
35 | 'file_name',
36 | 'file_path',
37 | 'file_size',
38 | 'file_ext',
39 | 'uploader_id',
40 | ];
41 |
42 | protected $appends = ['preview_url', 'file_url'];
43 |
44 | /**
45 | * 获取文件所属分组
46 | */
47 | public function group(): BelongsTo
48 | {
49 | return $this->belongsTo(SysFileGroupModel::class, 'group_id', 'id');
50 | }
51 |
52 | /**
53 | * 获取上传者
54 | * 根据channel字段判断是系统用户还是App用户
55 | */
56 | public function uploader(): BelongsTo
57 | {
58 | // channel 10:系统用户 20:App用户端
59 | if ($this->channel == 10) {
60 | return $this->belongsTo(SysUserModel::class, 'uploader_id', 'id');
61 | } else {
62 | return $this->belongsTo(UserModel::class, 'uploader_id', 'id');
63 | }
64 | }
65 |
66 | protected function previewUrl(): Attribute
67 | {
68 | return new Attribute(
69 | get: function ($value, array $data) {
70 | try {
71 | $fileType = FileType::tryFrom($data['file_type']);
72 |
73 | // 图片类型:直接返回图片URL作为预览
74 | if ($fileType === FileType::IMAGE) {
75 | return Storage::disk($data['disk'])->url($data['file_path']);
76 | }
77 |
78 | // 其他类型:返回默认类型图标
79 | $previewPath = $fileType?->previewPath() ?? FileType::ANNEX->previewPath();
80 | return config('app.url') . '/' . $previewPath;
81 |
82 | } catch (\Throwable $e) {
83 | // 发生异常时返回默认图标
84 | return config('app.url') . '/' . FileType::ANNEX->previewPath();
85 | }
86 | }
87 | );
88 | }
89 |
90 | /**
91 | * 获取文件访问URL
92 | */
93 | protected function fileUrl(): Attribute
94 | {
95 | return new Attribute(
96 | get: function ($value, array $data) {
97 | try {
98 | return Storage::disk($data['disk'])->url($data['file_path']);
99 | } catch (\Throwable $e) {
100 | return null;
101 | }
102 | }
103 | );
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/config/sanctum.php:
--------------------------------------------------------------------------------
1 | explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
19 | '%s%s',
20 | 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
21 | Sanctum::currentApplicationUrlWithPort()
22 | ))),
23 |
24 | /*
25 | |--------------------------------------------------------------------------
26 | | Sanctum Guards
27 | |--------------------------------------------------------------------------
28 | |
29 | | This array contains the authentication guards that will be checked when
30 | | Sanctum is trying to authenticate a request. If none of these guards
31 | | are able to authenticate the request, Sanctum will use the bearer
32 | | token that's present on an incoming request for authentication.
33 | |
34 | */
35 |
36 | 'guard' => ['web'],
37 |
38 | /*
39 | |--------------------------------------------------------------------------
40 | | Expiration Minutes
41 | |--------------------------------------------------------------------------
42 | |
43 | | This value controls the number of minutes until an issued token will be
44 | | considered expired. This will override any values set in the token's
45 | | "expires_at" attribute, but first-party sessions are not affected.
46 | |
47 | */
48 |
49 | 'expiration' => null,
50 |
51 | /*
52 | |--------------------------------------------------------------------------
53 | | Token Prefix
54 | |--------------------------------------------------------------------------
55 | |
56 | | Sanctum can prefix new tokens in order to take advantage of numerous
57 | | security scanning initiatives maintained by open source platforms
58 | | that notify developers if they commit tokens into repositories.
59 | |
60 | | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
61 | |
62 | */
63 |
64 | 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
65 |
66 | /*
67 | |--------------------------------------------------------------------------
68 | | Sanctum Middleware
69 | |--------------------------------------------------------------------------
70 | |
71 | | When authenticating your first-party SPA with Sanctum you may need to
72 | | customize some of the middleware Sanctum uses while processing the
73 | | request. You may change the middleware listed below as required.
74 | |
75 | */
76 |
77 | 'middleware' => [
78 | 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
79 | 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
80 | 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
81 | ],
82 |
83 | ];
84 |
--------------------------------------------------------------------------------
/app/Repositories/Sys/SysDeptRepository.php:
--------------------------------------------------------------------------------
1 | '=',
17 | 'status' => '='
18 | ];
19 |
20 | /** @var array|string[] 快速搜索字段 */
21 | protected array $quickSearchField = ['name', 'leader', 'phone'];
22 |
23 | /**
24 | * @inheritDoc
25 | */
26 | protected function model(): Builder
27 | {
28 | return SysDeptModel::query();
29 | }
30 |
31 | protected function rules(): array
32 | {
33 | if (! $this->isUpdate()) {
34 | return [
35 | 'name' => 'required|unique:sys_dept,name',
36 | 'code' => 'required|unique:sys_dept,code',
37 | 'type' => 'required|integer|in:0,1,2',
38 | 'parent_id' => [
39 | 'required',
40 | 'integer',
41 | function ($attribute, $value, $fail) {
42 | if ($value != 0 && !DB::table('sys_dept')->where('id', $value)->exists()) {
43 | $fail('选择的上级部门不存在。');
44 | }
45 | },
46 | ],
47 | 'sort' => 'required|integer',
48 | 'phone' => 'nullable',
49 | 'address' => 'nullable',
50 | 'email' => 'nullable|email',
51 | 'status' => 'required|in:0,1',
52 | 'remark' => 'nullable',
53 | ];
54 | } else {
55 | $id = request()->route('id');
56 | return [
57 | 'name' => [
58 | 'required',
59 | Rule::unique('sys_dept', 'name')->ignore($id)
60 | ],
61 | 'code' => [
62 | 'required',
63 | Rule::unique('sys_dept', 'code')->ignore($id)
64 | ],
65 | 'type' => 'required|integer|in:0,1,2',
66 | 'sort' => 'required|integer',
67 | 'phone' => 'nullable',
68 | 'address' => 'nullable',
69 | 'email' => 'nullable|email',
70 | 'status' => 'required|in:0,1',
71 | 'remark' => 'nullable',
72 | ];
73 | }
74 | }
75 |
76 | protected function messages(): array
77 | {
78 | return [
79 | 'name.required' => '部门名称不能为空',
80 | 'name.unique' => '部门名称已存在',
81 | 'code.required' => '部门编码不能为空',
82 | 'code.unique' => '部门编码已存在',
83 | 'type.required' => '部门类型不能为空',
84 | 'type.integer' => '部门类型必须是整数',
85 | 'type.in' => '部门类型错误',
86 | 'parent_id.required' => '上级部门不能为空',
87 | 'parent_id.integer' => '上级部门ID必须是整数',
88 | 'parent_id.exists' => '选择的上级部门不存在',
89 | 'sort.required' => '排序字段不能为空',
90 | 'sort.integer' => '排序字段必须是整数',
91 | 'email.email' => '请输入有效的邮箱地址',
92 | 'status.required' => '状态不能为空',
93 | 'status.in' => '状态类型错误',
94 | ];
95 | }
96 | }
--------------------------------------------------------------------------------
/config/telescope.php:
--------------------------------------------------------------------------------
1 | env('TELESCOPE_ENABLED', true),
19 |
20 | /*
21 | |--------------------------------------------------------------------------
22 | | Telescope Domain. 监控可访问的域名,为空时监控所有
23 | |--------------------------------------------------------------------------
24 | |
25 | | This is the subdomain where Telescope will be accessible from. If the
26 | | setting is null, Telescope will reside under the same domain as the
27 | | application. Otherwise, this value will be used as the subdomain.
28 | |
29 | */
30 |
31 | 'domain' => env('TELESCOPE_DOMAIN'),
32 |
33 | /*
34 | |--------------------------------------------------------------------------
35 | | Allowed / Ignored Paths & Commands . 监控地址白名单
36 | |--------------------------------------------------------------------------
37 | |
38 | | The following array lists the URI paths and Artisan commands that will
39 | | not be watched by Telescope. In addition to this list, some Laravel
40 | | commands.
41 | |
42 | */
43 |
44 | 'only_paths' => [
45 | // 'api/*'
46 | ],
47 |
48 | 'ignore_paths' => [
49 | 'livewire*',
50 | 'nova-api*',
51 | 'pulse*',
52 | ],
53 |
54 | /*
55 | |--------------------------------------------------------------------------
56 | | Telescope Watchers 监控器
57 | |--------------------------------------------------------------------------
58 | |
59 | | The following array lists the "watchers" that will be registered with
60 | | Telescope. The watchers gather the application's profile data when
61 | | a request or task is executed. Feel free to customize this list.
62 | |
63 | */
64 |
65 | 'watchers' => [
66 |
67 | \App\Providers\Telescope\Watchers\CacheWatcher::class => [
68 | 'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
69 | 'hidden' => [],
70 | ],
71 |
72 | \App\Providers\Telescope\Watchers\QueryWatcher::class => [
73 | 'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
74 | 'ignore_packages' => true,
75 | 'ignore_paths' => [],
76 | 'slow' => 100,
77 | ],
78 |
79 | \App\Providers\Telescope\Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
80 |
81 | \App\Providers\Telescope\Watchers\RequestWatcher::class => [
82 | 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
83 | 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 10),
84 | 'ignore_http_methods' => [
85 | 'OPTIONS'
86 | ],
87 | 'ignore_status_codes' => [],
88 | 'ignore_http_path' => [
89 | 'system/watcher*',
90 | '*login',
91 | '*logout',
92 | ],
93 | ],
94 | ],
95 | ];
96 |
--------------------------------------------------------------------------------
/app/Providers/AutoBindServiceProvider.php:
--------------------------------------------------------------------------------
1 | 'App\\Repositories',
17 | 'Services' => 'App\\Services',
18 | ];
19 |
20 | /**
21 | * 要排除的基类
22 | */
23 | protected array $excludeClasses = [
24 | 'App\\Repositories\\BaseRepository', // 仓储基类
25 | 'App\\Services\\BaseService', // 服务基类
26 | ];
27 |
28 | public function register(): void
29 | {
30 | foreach ($this->scanConfig as $directory => $namespace) {
31 | $classes = $this->scanClasses($directory, $namespace);
32 |
33 | foreach ($classes as $class) {
34 | if ($this->shouldBind($class)) {
35 | $this->app->bind($class, $class);
36 | // 如果需要单例模式,使用:$this->app->singleton($class, $class);
37 | }
38 | }
39 | }
40 | }
41 |
42 | /**
43 | * 扫描目录获取所有类
44 | */
45 | protected function scanClasses(string $directory, string $namespace): array
46 | {
47 | $absolutePath = app_path($directory);
48 |
49 | if (!is_dir($absolutePath)) {
50 | return [];
51 | }
52 |
53 | $classes = [];
54 | $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absolutePath));
55 |
56 | $regex = new RegexIterator($iterator, '/^.+\.php$/i', RegexIterator::GET_MATCH);
57 |
58 | foreach ($regex as $file) {
59 | $filePath = $file[0];
60 | $relativePath = str_replace([$absolutePath, '.php'], '', $filePath);
61 | $relativePath = trim($relativePath, DIRECTORY_SEPARATOR);
62 |
63 | $className = str_replace(
64 | DIRECTORY_SEPARATOR,
65 | '\\',
66 | $namespace . '\\' . $relativePath
67 | );
68 |
69 | if (class_exists($className)) {
70 | $classes[] = $className;
71 | }
72 | }
73 |
74 | return $classes;
75 | }
76 |
77 | /**
78 | * 判断是否应该绑定该类
79 | */
80 | protected function shouldBind(string $className): bool
81 | {
82 | // 排除抽象类
83 | if ((new \ReflectionClass($className))->isAbstract()) {
84 | return false;
85 | }
86 |
87 | // 排除基类
88 | foreach ($this->excludeClasses as $exclude) {
89 | if (str_contains($className, $exclude) || class_basename($className) === $exclude) {
90 | return false;
91 | }
92 | }
93 |
94 | return true;
95 | }
96 |
97 | /**
98 | * 获取已绑定的类列表(用于调试)
99 | */
100 | public function getBoundClasses(): array
101 | {
102 | $boundClasses = [];
103 |
104 | foreach ($this->scanConfig as $directory => $namespace) {
105 | $classes = $this->scanClasses($directory, $namespace);
106 | foreach ($classes as $class) {
107 | if ($this->shouldBind($class)) {
108 | $boundClasses[] = $class;
109 | }
110 | }
111 | }
112 |
113 | return $boundClasses;
114 | }
115 | }
--------------------------------------------------------------------------------
/app/Providers/Telescope/IncomingEntry.php:
--------------------------------------------------------------------------------
1 | type = $type;
30 |
31 | $this->content = array_merge($content, [
32 | 'host_name' => gethostname(),
33 | 'recorded_at' => now()->toDateTimeString()
34 | ]);
35 | }
36 |
37 | /**
38 | * Create a new entry instance.
39 | */
40 | public static function make(array $content, string $type): static
41 | {
42 | return new static($content, $type);
43 | }
44 |
45 | /**
46 | * Set the currently authenticated user.
47 | */
48 | public function user(Authenticatable $user): static
49 | {
50 | $this->user = $user;
51 |
52 | $this->content = array_merge($this->content, [
53 | 'user' => [
54 | 'id' => $user->getAuthIdentifier(),
55 | 'name' => $user->username ?? null,
56 | 'email' => $user->email ?? null,
57 | 'avatar' => $user->avatarUrl ?? null,
58 | ],
59 | ]);
60 |
61 | return $this;
62 | }
63 |
64 | /**
65 | * Set the entry's type.
66 | */
67 | public function type(string $type): static
68 | {
69 | $this->type = $type;
70 |
71 | return $this;
72 | }
73 |
74 | /**
75 | * Determine if the incoming entry is a cache entry.
76 | */
77 | public function isCache(): bool
78 | {
79 | return $this->type === EntryType::CACHE;
80 | }
81 |
82 | /**
83 | * Determine if the incoming entry is a auth.
84 | */
85 | public function isAuth(): bool
86 | {
87 | return $this->type === EntryType::AUTH;
88 | }
89 |
90 | /**
91 | * Determine if the incoming entry is a query.
92 | */
93 | public function isQuery(): bool
94 | {
95 | return $this->type === EntryType::QUERY;
96 | }
97 |
98 | /**
99 | * Determine if the incoming entry is a slow query.
100 | */
101 | public function isSlowQuery(): bool
102 | {
103 | return $this->type === EntryType::QUERY && ($this->content['slow'] ?? false);
104 | }
105 |
106 | /**
107 | * Determine if the incoming entry is a redis.
108 | */
109 | public function isRedis(): bool
110 | {
111 | return $this->type === EntryType::REDIS;
112 | }
113 |
114 | /**
115 | * Determine if the incoming entry is a request.
116 | */
117 | public function isRequest(): bool
118 | {
119 | return $this->type === EntryType::REQUEST;
120 | }
121 |
122 | /**
123 | * Determine if the incoming entry is a failed request.
124 | */
125 | public function isFailedRequest(): bool
126 | {
127 | return $this->type === EntryType::REQUEST &&
128 | ($this->content['response_status'] ?? 200) >= 500;
129 | }
130 |
131 | /**
132 | * Get the entry's content as a string.
133 | */
134 | public function getContentAsString(): string
135 | {
136 | return json_encode($this->content, JSON_INVALID_UTF8_SUBSTITUTE);
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/app/Providers/Telescope/Watchers/QueryWatcher.php:
--------------------------------------------------------------------------------
1 | listen(QueryExecuted::class, [$this, 'recordQuery']);
20 | }
21 |
22 | /**
23 | * Record a query was executed.
24 | *
25 | * @param QueryExecuted $event
26 | * @return void
27 | */
28 | public function recordQuery(QueryExecuted $event): void
29 | {
30 | if (! Telescope::isRecording()) {
31 | return;
32 | }
33 |
34 | $time = $event->time;
35 |
36 | if ($caller = $this->getCallerFromStackTrace()) {
37 | Telescope::record(IncomingEntry::make([
38 | 'connection' => $event->connectionName,
39 | 'sql' => $this->replaceBindings($event),
40 | 'time' => number_format($time, 2, '.', ''),
41 | 'slow' => isset($this->options['slow']) && $time >= $this->options['slow'],
42 | 'file' => $caller['file'],
43 | 'line' => $caller['line'],
44 | ], EntryType::QUERY));
45 | }
46 | }
47 |
48 | /**
49 | * Calculate the family look-up hash for the query event.
50 | */
51 | public function familyHash(QueryExecuted $event): string
52 | {
53 | return md5($event->sql);
54 | }
55 |
56 | /**
57 | * Format the given bindings to strings.
58 | */
59 | protected function formatBindings(QueryExecuted $event): array
60 | {
61 | return $event->connection->prepareBindings($event->bindings);
62 | }
63 |
64 | /**
65 | * Replace the placeholders with the actual bindings.
66 | */
67 | public function replaceBindings(QueryExecuted $event): string
68 | {
69 | $sql = $event->sql;
70 |
71 | foreach ($this->formatBindings($event) as $key => $binding) {
72 | $regex = is_numeric($key)
73 | ? "/\?(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/"
74 | : "/:{$key}(?=(?:[^'\\\']*'[^'\\\']*')*[^'\\\']*$)/";
75 |
76 | if ($binding === null) {
77 | $binding = 'null';
78 | } elseif (! is_int($binding) && ! is_float($binding)) {
79 | $binding = $this->quoteStringBinding($event, $binding);
80 | }
81 |
82 | $sql = preg_replace(
83 | $regex,
84 | $binding,
85 | $sql,
86 | is_numeric($key) ? 1 : -1
87 | );
88 | }
89 |
90 | return $sql;
91 | }
92 |
93 | /**
94 | * Add quotes to string bindings.
95 | */
96 | protected function quoteStringBinding(QueryExecuted $event, string $binding): string
97 | {
98 | try {
99 | $pdo = $event->connection->getPdo();
100 |
101 | if ($pdo instanceof \PDO) {
102 | return $pdo->quote($binding);
103 | }
104 | } catch (\PDOException $e) {
105 | throw_if('IM001' !== $e->getCode(), $e);
106 | }
107 |
108 | // Fallback when PDO::quote function is missing...
109 | $binding = \strtr($binding, [
110 | chr(26) => '\\Z',
111 | chr(8) => '\\b',
112 | '"' => '\"',
113 | "'" => "\'",
114 | '\\' => '\\\\',
115 | ]);
116 |
117 | return "'".$binding."'";
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/app/Repositories/Sys/SysRuleRepository.php:
--------------------------------------------------------------------------------
1 | '=',
17 | 'status' => '=',
18 | 'show' => '=',
19 | 'parent_id' => '='
20 | ];
21 |
22 | protected array $quickSearchField = ['name', 'key', 'path'];
23 |
24 | protected function model(): Builder
25 | {
26 | return SysRuleModel::query();
27 | }
28 |
29 | protected function rules(): array
30 | {
31 | $type = request()->input('type');
32 | if (empty($type)) {
33 | throw new RepositoryException('权限类型为必填项!');
34 | }
35 | if (!in_array($type, ['menu', 'route', 'nested-route', 'rule'])) {
36 | throw new RepositoryException('权限类型错误!');
37 | }
38 | $rules = [
39 | 'parent_id' => [
40 | 'required',
41 | 'integer',
42 | 'numeric',
43 | function ($attribute, $value, $fail) {
44 | if ($value != 0 && !DB::table('sys_rule')->where('id', $value)->exists()) {
45 | $fail('选择的上级部门不存在。');
46 | }
47 | },
48 | ],
49 | 'order' => 'required|integer',
50 | 'name' => 'required',
51 | 'key' => 'required|unique:sys_rule,key'
52 | ];
53 | if ($this->isUpdate()) {
54 | $rules['key'] = [
55 | 'required',
56 | Rule::unique('sys_rule', 'key')->ignore(request()->route('id'))
57 | ];
58 | }
59 | if ($type == 'menu') {
60 | $rules += [
61 | 'type' => 'required|string|in:menu',
62 | 'local' => 'nullable|string',
63 | 'icon' => 'nullable|string',
64 | ];
65 | } else if ($type == 'route') {
66 | $rules += [
67 | 'type' => 'required|string|in:route',
68 | 'path' => 'required|string',
69 | 'local' => 'nullable|string',
70 | 'icon' => 'nullable|string',
71 | 'link' => 'required|integer|numeric|in:0,1',
72 | 'elementPath' => 'sometimes|required|string',
73 | ];
74 | } else if ($type == 'nested-route') {
75 | $rules += [
76 | 'type' => 'required|string|in:nested-route',
77 | 'path' => 'required|string',
78 | 'elementPath' => 'required|string',
79 | ];
80 | } else {
81 | $rules += [
82 | 'type' => 'required|string|in:rule',
83 | ];
84 | }
85 | return $rules;
86 | }
87 |
88 | protected function messages(): array
89 | {
90 | return [
91 | 'name.required' => '权限名称不能为空',
92 | 'type.required' => '类型不能为空',
93 | 'type.in' => '类型格式错误',
94 | 'order.required' => '排序不能为空',
95 | 'order.integer' => '排序必须为整数',
96 | 'key.required' => '唯一标识不能为空',
97 | 'key.unique' => '唯一标识已存在',
98 | 'path.required' => '路径不能为空',
99 | 'status.required' => '状态不能为空',
100 | 'status.in' => '状态格式错误',
101 | 'show.required' => '显示状态不能为空',
102 | 'show.in' => '显示状态格式错误',
103 | 'parent_id.required' => '父级权限不能为空',
104 | 'parent_id.integer' => '父级权限格式错误',
105 | 'parent_id.exists' => '父级权限不存在'
106 | ];
107 | }
108 | }
--------------------------------------------------------------------------------
/app/Http/Middleware/LoginLogMiddleware.php:
--------------------------------------------------------------------------------
1 | userAgent();
23 | // 继续处理请求
24 | $response = $next($request);
25 | $user_id = auth()->id();
26 | $username = auth()->user()['username'];
27 | // 获取响应状态和消息
28 | $content = json_decode($response->getContent(), true); // 响应内容
29 | $message = $content['msg'] ?? 'No message'; // 从响应中提取消息
30 | SysLoginRecordModel::create([
31 | 'ipaddr' => $request->ip(),
32 | 'browser' => $this->getBrowser($userAgent),
33 | 'os' => $this->getOs($userAgent),
34 | 'username' => $username,
35 | 'user_id' => $user_id,
36 | 'login_location' => $this->getLocation($request->ip()),
37 | 'status' => $content['success'] ? '0' : '1',
38 | 'msg' => $message,
39 | 'login_time' => date('Y-m-d H:i:s'),
40 | ]);
41 | }catch (\Exception $e) {
42 | // 记录错误日志
43 | Log::error('Failed to log user login info: ' . $e->getMessage());
44 | }
45 | return $response;
46 | }
47 |
48 | /**
49 | * 获取浏览器信息
50 | * @param string $userAgent
51 | * @return string
52 | */
53 | private function getBrowser(string $userAgent): string
54 | {
55 | $browser = 'XXX';
56 | // 简单的解析逻辑(可以根据需要扩展)
57 | if (str_contains($userAgent, 'Firefox')) {
58 | $browser = 'Firefox';
59 | } elseif (str_contains($userAgent, 'Chrome')) {
60 | $browser = 'Chrome';
61 | } elseif (str_contains($userAgent, 'Safari')) {
62 | $browser = 'Safari';
63 | } elseif (str_contains($userAgent, 'MSIE') || str_contains($userAgent, 'Trident')) {
64 | $browser = 'Internet Explorer';
65 | }
66 | return $browser;
67 | }
68 |
69 | /**
70 | * 获取操作系统信息
71 | * @param string $userAgent
72 | * @return string
73 | */
74 | private function getOs(string $userAgent): string
75 | {
76 | $os = 'Unknown';
77 | if (str_contains($userAgent, 'Windows')) {
78 | $os = 'Windows';
79 | } elseif (str_contains($userAgent, 'Macintosh')) {
80 | $os = 'Mac OS';
81 | } elseif (str_contains($userAgent, 'Linux')) {
82 | $os = 'Linux';
83 | } elseif (str_contains($userAgent, 'Android')) {
84 | $os = 'Android';
85 | } elseif (str_contains($userAgent, 'iOS')) {
86 | $os = 'iOS';
87 | }
88 | return $os;
89 | }
90 |
91 | /**
92 | * 获取 IP 地址对应的地理位置
93 | *
94 | * @param string $ip
95 | * @return string
96 | */
97 | private function getLocation(string $ip): string
98 | {
99 | if($ip == '127.0.0.1') {
100 | return '本地';
101 | }
102 | // 这里可以使用第三方 API(如 IPStack 或 IPInfo)来获取地理位置
103 | try {
104 | $response = file_get_contents("https://ipinfo.io/{$ip}/json");
105 | $data = json_decode($response, true);
106 | return $data['city'] . ', ' . $data['country'];
107 | }catch (\Exception $e) {
108 | return 'XXX';
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/app/Exceptions/ExceptionsHandler.php:
--------------------------------------------------------------------------------
1 | function (HttpResponseException $e) {
35 | return response()->json($e->toArray(), $e->getCode());
36 | },
37 | MissingAbilityException::class => function ($e) {
38 | return $this->notification(
39 | 'No Permission',
40 | __('system.error.no_permission'),
41 | ShowType::WARN_NOTIFICATION
42 | );
43 | },
44 | AuthenticationException::class => function ($e) {
45 | return response()->json([
46 | 'msg' => __('user.not_login'),
47 | 'success' => false
48 | ], 401);
49 | },
50 | NotFoundHttpException::class => function ($e) {
51 | return $this->notification(
52 | 'Route Not Exist',
53 | __('system.error.route_not_exist'),
54 | ShowType::WARN_NOTIFICATION
55 | );
56 | },
57 | ValidationException::class => function (ValidationException $e) {
58 | return response()->json([
59 | 'msg' => $e->validator->errors()->first(),
60 | 'showType' => ShowType::WARN_MESSAGE->value,
61 | 'success' => false,
62 | ]);
63 | },
64 | ];
65 |
66 | foreach ($exceptionHandlers as $exceptionType => $handler) {
67 | if ($e instanceof $exceptionType) {
68 | $response = $handler($e);
69 | break;
70 | }
71 | }
72 |
73 | if (!isset($response)) {
74 | $debug = config('app.debug');
75 | $data = [
76 | 'msg' => $e->getMessage(),
77 | 'showType' => ShowType::ERROR_MESSAGE->value,
78 | 'success' => false,
79 | ];
80 |
81 | if ($debug) {
82 | $data += [
83 | 'file' => $e->getFile(),
84 | 'line' => $e->getLine(),
85 | 'trace' => $e->getTrace(),
86 | 'code' => $e->getCode(),
87 | ];
88 | }
89 |
90 | $response = response()->json($data);
91 | }
92 |
93 | $response->headers->set('Access-Control-Allow-Origin', '*');
94 | $response->headers->set('Access-Control-Allow-Credentials', 'true');
95 | $response->headers->set('Access-Control-Max-Age', 1800);
96 | $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
97 | $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, User-Language');
98 |
99 | return $response;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/config/cache.php:
--------------------------------------------------------------------------------
1 | env('CACHE_STORE', 'redis'),
19 |
20 | /*
21 | |--------------------------------------------------------------------------
22 | | Cache Stores
23 | |--------------------------------------------------------------------------
24 | |
25 | | Here you may define all of the cache "stores" for your application as
26 | | well as their drivers. You may even define multiple stores for the
27 | | same cache driver to group types of items stored in your caches.
28 | |
29 | | Supported drivers: "array", "database", "file", "memcached",
30 | | "redis", "dynamodb", "octane", "null"
31 | |
32 | */
33 |
34 | 'stores' => [
35 |
36 | 'array' => [
37 | 'driver' => 'array',
38 | 'serialize' => false,
39 | ],
40 |
41 | 'database' => [
42 | 'driver' => 'database',
43 | 'connection' => env('DB_CACHE_CONNECTION'),
44 | 'table' => env('DB_CACHE_TABLE', 'sys_cache'),
45 | 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
46 | 'lock_table' => env('DB_CACHE_LOCK_TABLE', 'sys_cache_locks'),
47 | ],
48 |
49 | 'file' => [
50 | 'driver' => 'file',
51 | 'path' => storage_path('framework/cache/data'),
52 | 'lock_path' => storage_path('framework/cache/data'),
53 | ],
54 |
55 | 'memcached' => [
56 | 'driver' => 'memcached',
57 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
58 | 'sasl' => [
59 | env('MEMCACHED_USERNAME'),
60 | env('MEMCACHED_PASSWORD'),
61 | ],
62 | 'options' => [
63 | // Memcached::OPT_CONNECT_TIMEOUT => 2000,
64 | ],
65 | 'servers' => [
66 | [
67 | 'host' => env('MEMCACHED_HOST', '127.0.0.1'),
68 | 'port' => env('MEMCACHED_PORT', 11211),
69 | 'weight' => 100,
70 | ],
71 | ],
72 | ],
73 |
74 | 'redis' => [
75 | 'driver' => 'redis',
76 | 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
77 | 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
78 | ],
79 |
80 | 'dynamodb' => [
81 | 'driver' => 'dynamodb',
82 | 'key' => env('AWS_ACCESS_KEY_ID'),
83 | 'secret' => env('AWS_SECRET_ACCESS_KEY'),
84 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
85 | 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
86 | 'endpoint' => env('DYNAMODB_ENDPOINT'),
87 | ],
88 |
89 | 'octane' => [
90 | 'driver' => 'octane',
91 | ],
92 |
93 | ],
94 |
95 | /*
96 | |--------------------------------------------------------------------------
97 | | Cache Key Prefix
98 | |--------------------------------------------------------------------------
99 | |
100 | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache
101 | | stores, there might be other applications using the same cache. For
102 | | that reason, you may prefix every cache key to avoid collisions.
103 | |
104 | */
105 |
106 | 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
107 |
108 | ];
109 |
--------------------------------------------------------------------------------
/tests/Feature/MailServiceTest.php:
--------------------------------------------------------------------------------
1 | assertArrayHasKey('default', $config);
27 | $this->assertArrayHasKey('smtp', $config);
28 | $this->assertArrayHasKey('from', $config);
29 | }
30 |
31 | /**
32 | * 测试检查邮件配置是否有效
33 | */
34 | public function test_mail_config_is_configured(): void
35 | {
36 | $isConfigured = MailConfigService::isConfigured();
37 |
38 | // 根据系统设置中是否配置了邮件返回结果
39 | $this->assertIsBool($isConfigured);
40 | }
41 |
42 | /**
43 | * 测试验证码邮件类创建
44 | */
45 | public function test_verification_code_mail_can_be_created(): void
46 | {
47 | $code = '123456';
48 | $expireMinutes = 30;
49 |
50 | $mail = new VerificationCodeMail($code, $expireMinutes);
51 |
52 | $this->assertEquals($code, $mail->code);
53 | $this->assertEquals($expireMinutes, $mail->expireMinutes);
54 | }
55 |
56 | /**
57 | * 测试邮件发送(使用 Fake)
58 | */
59 | public function test_verification_code_mail_can_be_sent(): void
60 | {
61 | Mail::fake();
62 |
63 | $testEmail = 'test@example.com';
64 | $code = '654321';
65 |
66 | // 发送邮件
67 | Mail::to($testEmail)->send(new VerificationCodeMail($code));
68 |
69 | // 验证邮件已发送
70 | Mail::assertSent(VerificationCodeMail::class, function ($mail) use ($code, $testEmail) {
71 | return $mail->code === $code
72 | && $mail->hasTo($testEmail);
73 | });
74 | }
75 |
76 | /**
77 | * 测试邮件发送队列(使用 Fake)
78 | */
79 | public function test_verification_code_mail_can_be_queued(): void
80 | {
81 | Mail::fake();
82 |
83 | $testEmail = 'queue@example.com';
84 | $code = '789012';
85 |
86 | // 队列发送邮件
87 | Mail::to($testEmail)->queue(new VerificationCodeMail($code));
88 |
89 | // 验证邮件已加入队列
90 | Mail::assertQueued(VerificationCodeMail::class, function ($mail) use ($code) {
91 | return $mail->code === $code;
92 | });
93 | }
94 |
95 | /**
96 | * 真实发送测试邮件(需要配置正确的邮件服务)
97 | * 运行命令: php artisan test --filter=test_send_real_email
98 | *
99 | * 注意:运行此测试前请确保:
100 | * 1. 已在系统设置中配置正确的邮件参数 (host, port, username, password, encryption, from_address)
101 | * 2. 修改下方的 TEST_MAIL_TO 或在 .env 中设置为真实邮箱地址
102 | * 3. 创议使用 SSL 加密 (port: 465, encryption: ssl)
103 | */
104 | public function test_send_real_email(): void
105 | {
106 | // 如果邮件未配置,跳过此测试
107 | if (!MailConfigService::isConfigured()) {
108 | $this->markTestSkipped('邮件服务未配置,跳过真实发送测试');
109 | }
110 |
111 | // 初始化邮件配置
112 | MailConfigService::initFromSettings();
113 |
114 | // 修改为真实的测试邮箱
115 | $testEmail = env('TEST_MAIL_TO', 'test@example.com');
116 |
117 | // 如果是默认邮箱,跳过测试
118 | if ($testEmail === 'test@example.com') {
119 | $this->markTestSkipped('请设置 TEST_MAIL_TO 环境变量为真实邮箱地址');
120 | }
121 |
122 | $code = sprintf('%06d', random_int(0, 999999));
123 |
124 | try {
125 | Mail::to($testEmail)->send(new VerificationCodeMail($code, 5));
126 | $this->assertTrue(true, '邮件发送成功');
127 | } catch (\Exception $e) {
128 | $this->fail('邮件发送失败: ' . $e->getMessage());
129 | }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/config/mail.php:
--------------------------------------------------------------------------------
1 | env('MAIL_MAILER', 'log'),
18 |
19 | /*
20 | |--------------------------------------------------------------------------
21 | | Mailer Configurations
22 | |--------------------------------------------------------------------------
23 | |
24 | | Here you may configure all of the mailers used by your application plus
25 | | their respective settings. Several examples have been configured for
26 | | you and you are free to add your own as your application requires.
27 | |
28 | | Laravel supports a variety of mail "transport" drivers that can be used
29 | | when delivering an email. You may specify which one you're using for
30 | | your mailers below. You may also add additional mailers if needed.
31 | |
32 | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
33 | | "postmark", "resend", "log", "array",
34 | | "failover", "roundrobin"
35 | |
36 | */
37 |
38 | 'mailers' => [
39 | 'mailgun' => [
40 | 'transport' => 'mailgun',
41 | // 'client' => [
42 | // 'timeout' => 5,
43 | // ],
44 | ],
45 |
46 | 'smtp' => [
47 | 'transport' => 'smtp',
48 | 'scheme' => env('MAIL_SCHEME'),
49 | 'url' => env('MAIL_URL'),
50 | 'host' => env('MAIL_HOST', '127.0.0.1'),
51 | 'port' => env('MAIL_PORT', 2525),
52 | 'username' => env('MAIL_USERNAME'),
53 | 'password' => env('MAIL_PASSWORD'),
54 | 'timeout' => null,
55 | 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
56 | ],
57 |
58 | 'ses' => [
59 | 'transport' => 'ses',
60 | ],
61 |
62 | 'postmark' => [
63 | 'transport' => 'postmark',
64 | // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
65 | // 'client' => [
66 | // 'timeout' => 5,
67 | // ],
68 | ],
69 |
70 | 'resend' => [
71 | 'transport' => 'resend',
72 | ],
73 |
74 | 'sendmail' => [
75 | 'transport' => 'sendmail',
76 | 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
77 | ],
78 |
79 | 'log' => [
80 | 'transport' => 'log',
81 | 'channel' => env('MAIL_LOG_CHANNEL'),
82 | ],
83 |
84 | 'array' => [
85 | 'transport' => 'array',
86 | ],
87 |
88 | 'failover' => [
89 | 'transport' => 'failover',
90 | 'mailers' => [
91 | 'smtp',
92 | 'log',
93 | ],
94 | ],
95 |
96 | 'roundrobin' => [
97 | 'transport' => 'roundrobin',
98 | 'mailers' => [
99 | 'ses',
100 | 'postmark',
101 | ],
102 | ],
103 |
104 | ],
105 |
106 | /*
107 | |--------------------------------------------------------------------------
108 | | Global "From" Address
109 | |--------------------------------------------------------------------------
110 | |
111 | | You may wish for all emails sent by your application to be sent from
112 | | the same address. Here you may specify a name and address that is
113 | | used globally for all emails that are sent by your application.
114 | |
115 | */
116 |
117 | 'from' => [
118 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
119 | 'name' => env('MAIL_FROM_NAME', 'Example'),
120 | ],
121 |
122 | ];
123 |
--------------------------------------------------------------------------------