From 8d89932c98d3f5de76fcd87dfeaba0644731ae50 Mon Sep 17 00:00:00 2001 From: daodao97 Date: Tue, 16 Jun 2020 22:33:55 +0800 Subject: [PATCH] feat: hyperf-admin init --- .gitignore | 3 + README.md | 22 + composer.json | 102 + docs/.nojekyll | 0 docs/README.md | 34 + docs/_coverpage.md | 12 + docs/_navbar.md | 0 docs/_sidebar.md | 21 + docs/backend/common-config.md | 38 + docs/backend/components/cron-center.md | 7 + docs/backend/components/data-focus.md | 97 + docs/backend/components/dev-tools.md | 11 + docs/backend/form.md | 430 ++++ docs/backend/functions.md | 78 + docs/backend/list.md | 571 +++++ docs/backend/scaffold.md | 408 ++++ docs/backend/super-button.md | 132 ++ docs/favicon.ico | Bin 0 -> 4286 bytes docs/frontend/chart.md | 1 + docs/frontend/form.md | 1 + docs/frontend/list.md | 1 + docs/guide/desc.md | 37 + docs/guide/dev_example.md | 119 + docs/guide/install.md | 39 + docs/index.html | 63 + docs/logo.png | Bin 0 -> 21908 bytes docs/ws.js | 83 + src/admin/.gitignore | 3 + src/admin/composer.json | 31 + src/admin/src/ConfigProvider.php | 36 + .../Controller/AdminAbstractController.php | 83 + .../src/Controller/CommonConfigController.php | 126 ++ src/admin/src/Controller/LogController.php | 147 ++ src/admin/src/Controller/MenuController.php | 572 +++++ src/admin/src/Controller/RoleController.php | 198 ++ src/admin/src/Controller/SystemController.php | 29 + src/admin/src/Controller/UploadController.php | 58 + src/admin/src/Controller/UserController.php | 325 +++ src/admin/src/Crontab/ExportTask.php | 33 + src/admin/src/Install/InstallCommand.php | 29 + src/admin/src/Install/UpdateCommand.php | 44 + src/admin/src/Install/install.sql | 141 ++ src/admin/src/Middleware/AuthMiddleware.php | 54 + .../src/Middleware/PermissionMiddleware.php | 147 ++ src/admin/src/Model/CommonConfig.php | 48 + src/admin/src/Model/ExportTasks.php | 54 + src/admin/src/Model/FrontRoutes.php | 102 + src/admin/src/Model/GlobalConfig.php | 96 + src/admin/src/Model/OperatorLog.php | 23 + src/admin/src/Model/RequestLog.php | 43 + src/admin/src/Model/Role.php | 49 + src/admin/src/Model/RoleMenu.php | 28 + src/admin/src/Model/User.php | 85 + src/admin/src/Model/UserRole.php | 29 + src/admin/src/Model/Version.php | 76 + src/admin/src/Model/Versionable.php | 195 ++ src/admin/src/Service/AuthService.php | 70 + src/admin/src/Service/CommonConfig.php | 49 + src/admin/src/Service/ExportService.php | 197 ++ src/admin/src/Service/GlobalConfig.php | 67 + src/admin/src/Service/Menu.php | 95 + src/admin/src/Service/OperatorLogService.php | 92 + src/admin/src/Service/PermissionService.php | 388 ++++ src/admin/src/Service/UserService.php | 79 + src/admin/src/config/config.php | 57 + src/admin/src/config/routes.php | 40 + src/admin/src/funcs/common.php | 34 + src/alert-manager/composer.json | 30 + src/alert-manager/src/AlertJob.php | 77 + src/alert-manager/src/AlertMessage.php | 23 + src/alert-manager/src/AlertQueueConsumer.php | 15 + src/alert-manager/src/AlertRobots.php | 24 + src/alert-manager/src/AlertRules.php | 24 + src/alert-manager/src/AlertService.php | 35 + src/alert-manager/src/ConfigProvider.php | 51 + src/alert-manager/src/DingTalkRobot.php | 63 + src/alert-manager/src/SenderInterface.php | 9 + src/alert-manager/src/func.php | 10 + src/base-utils/composer.json | 78 + src/base-utils/src/AKSK.php | 50 + src/base-utils/src/AliyunOSS.php | 150 ++ src/base-utils/src/ColorLineFormatter.php | 27 + src/base-utils/src/ConfigProvider.php | 117 + src/base-utils/src/Constants/Consts.php | 42 + src/base-utils/src/Constants/ErrorCode.php | 128 ++ src/base-utils/src/Excel/ExcelReader.php | 49 + src/base-utils/src/Excel/ExcelWriter.php | 28 + .../src/Exception/HttpExceptionHandler.php | 54 + src/base-utils/src/Guzzle.php | 79 + src/base-utils/src/HAStreamHandler.php | 18 + src/base-utils/src/Helper/array.php | 326 +++ src/base-utils/src/Helper/common.php | 810 +++++++ src/base-utils/src/Helper/constants.php | 6 + src/base-utils/src/Helper/system.php | 211 ++ src/base-utils/src/JWT.php | 132 ++ .../src/Listener/BootAppConfListener.php | 39 + .../src/Listener/DbQueryExecutedListener.php | 103 + .../src/Listener/FetchModeListener.php | 24 + src/base-utils/src/Log.php | 12 + .../src/Middleware/CorsMiddleware.php | 25 + .../src/Middleware/HttpLogMiddleware.php | 69 + src/base-utils/src/Model/BaseModel.php | 151 ++ src/base-utils/src/Model/EsBaseModel.php | 609 ++++++ src/base-utils/src/Redis/Redis.php | 215 ++ src/base-utils/src/Redis/RedisArray.php | 53 + src/base-utils/src/Redis/RedisQueue.php | 146 ++ src/base-utils/src/RotatingFileHandler.php | 74 + src/base-utils/src/RoutesDispatcher.php | 25 + .../Controller/AbstractController.php | 1304 +++++++++++ .../src/Scaffold/Controller/Controller.php | 133 ++ .../src/Scaffold/Entity/ApiEntityAbstract.php | 92 + .../src/Scaffold/Entity/EntityInterface.php | 21 + .../src/Scaffold/Entity/EsEntityAbstract.php | 77 + .../Scaffold/Entity/MysqlEntityAbstract.php | 109 + src/base-utils/src/StdoutLoggerFactory.php | 12 + .../src/classmap/ProviderConfig.php | 82 + src/base-utils/src/config/routes.php | 11 + src/cron-center/.gitignore | 3 + src/cron-center/composer.json | 31 + src/cron-center/src/AlertManagerProcess.php | 25 + src/cron-center/src/ClassJobAbstract.php | 89 + src/cron-center/src/CommandJobAbstract.php | 111 + src/cron-center/src/ConfigProvider.php | 40 + .../src/Controller/CronNodeController.php | 90 + .../src/Controller/CrontabController.php | 219 ++ src/cron-center/src/CronManager.php | 238 ++ src/cron-center/src/Crontab.php | 29 + .../src/CrontabDispatcherProcess.php | 85 + .../src/CrontabRegisterListener.php | 20 + src/cron-center/src/Executor.php | 135 ++ .../src/Install/InstallCommand.php | 29 + src/cron-center/src/Install/install.sql | 67 + src/cron-center/src/Model/CronJobs.php | 61 + src/cron-center/src/Model/CronNodes.php | 32 + src/cron-center/src/Model/JobRunLog.php | 22 + src/cron-center/src/config/routes.php | 13 + src/data-focus/composer.json | 29 + src/data-focus/src/ConfigProvider.php | 33 + .../src/Controller/DsnController.php | 86 + .../Controller/PluginFunctionController.php | 85 + .../Controller/ReportChangeLogController.php | 48 + .../src/Controller/ReportsController.php | 149 ++ src/data-focus/src/Install/InstallCommand.php | 29 + src/data-focus/src/Install/install.sql | 59 + src/data-focus/src/Model/Dsn.php | 52 + src/data-focus/src/Model/PluginFunction.php | 52 + src/data-focus/src/Model/ReportChangeLog.php | 37 + src/data-focus/src/Model/Reports.php | 59 + src/data-focus/src/Service/Dsn.php | 102 + .../src/Util/BootAppConfListener.php | 21 + src/data-focus/src/Util/CodeRunner.php | 345 +++ src/data-focus/src/Util/PHPSandbox.php | 173 ++ src/data-focus/src/Util/SandboxException.php | 12 + src/data-focus/src/Util/SimpleHtmlDom.php | 1917 +++++++++++++++++ src/data-focus/src/Util/ValidatorVisitor.php | 31 + src/data-focus/src/Util/func.php | 148 ++ src/data-focus/src/config/routes.php | 16 + src/data-focus/src/readme.md | 3 + src/dev-tools/composer.json | 28 + src/dev-tools/src/AbstractMaker.php | 133 ++ src/dev-tools/src/ConfigProvider.php | 15 + .../src/Controller/DevController.php | 316 +++ src/dev-tools/src/ControllerMaker.php | 163 ++ src/dev-tools/src/ModelMaker.php | 130 ++ src/dev-tools/src/TableSchema.php | 68 + src/dev-tools/src/config/routes.php | 14 + src/event-bus/composer.json | 29 + src/event-bus/publish/event-bus.php | 36 + src/event-bus/src/BootProcessListener.php | 62 + src/event-bus/src/ConfigProvider.php | 27 + .../src/Controller/PushController.php | 40 + src/event-bus/src/Kafka.php | 117 + src/event-bus/src/OnPipeMessageListener.php | 50 + src/event-bus/src/PipeMessage.php | 15 + src/event-bus/src/ProcessFactory.php | 154 ++ src/event-bus/src/config/routes.php | 6 + src/event-bus/src/funcs.php | 36 + src/process-manager/composer.json | 29 + .../publish/process_manager.php | 28 + .../src/AmqpConsumerManager.php | 90 + .../src/BootProcessListener.php | 82 + src/process-manager/src/ConfigProvider.php | 28 + .../src/NsqConsumerManager.php | 128 ++ src/rule-engine/composer.json | 20 + src/rule-engine/src/ArrayHelper.php | 73 + src/rule-engine/src/BooleanOperation.php | 210 ++ src/rule-engine/src/Context/Context.php | 111 + .../src/Context/ContextPluginAbstract.php | 94 + .../src/Context/ContextPluginInterface.php | 9 + src/rule-engine/src/Context/IpContext.php | 65 + .../src/Context/RequestContext.php | 10 + src/rule-engine/src/Context/TimeContext.php | 62 + src/rule-engine/src/FilterException.php | 6 + src/validation/.gitignore | 3 + src/validation/composer.json | 30 + src/validation/src/Validation.php | 270 +++ src/validation/src/ValidationCustomRule.php | 45 + src/workflow/composer.json | 33 + 198 files changed, 20104 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 docs/.nojekyll create mode 100644 docs/README.md create mode 100644 docs/_coverpage.md create mode 100644 docs/_navbar.md create mode 100644 docs/_sidebar.md create mode 100644 docs/backend/common-config.md create mode 100644 docs/backend/components/cron-center.md create mode 100644 docs/backend/components/data-focus.md create mode 100644 docs/backend/components/dev-tools.md create mode 100644 docs/backend/form.md create mode 100644 docs/backend/functions.md create mode 100644 docs/backend/list.md create mode 100644 docs/backend/scaffold.md create mode 100644 docs/backend/super-button.md create mode 100644 docs/favicon.ico create mode 100644 docs/frontend/chart.md create mode 100644 docs/frontend/form.md create mode 100644 docs/frontend/list.md create mode 100644 docs/guide/desc.md create mode 100644 docs/guide/dev_example.md create mode 100644 docs/guide/install.md create mode 100644 docs/index.html create mode 100644 docs/logo.png create mode 100644 docs/ws.js create mode 100644 src/admin/.gitignore create mode 100644 src/admin/composer.json create mode 100644 src/admin/src/ConfigProvider.php create mode 100644 src/admin/src/Controller/AdminAbstractController.php create mode 100644 src/admin/src/Controller/CommonConfigController.php create mode 100644 src/admin/src/Controller/LogController.php create mode 100644 src/admin/src/Controller/MenuController.php create mode 100644 src/admin/src/Controller/RoleController.php create mode 100644 src/admin/src/Controller/SystemController.php create mode 100644 src/admin/src/Controller/UploadController.php create mode 100644 src/admin/src/Controller/UserController.php create mode 100644 src/admin/src/Crontab/ExportTask.php create mode 100644 src/admin/src/Install/InstallCommand.php create mode 100644 src/admin/src/Install/UpdateCommand.php create mode 100644 src/admin/src/Install/install.sql create mode 100644 src/admin/src/Middleware/AuthMiddleware.php create mode 100644 src/admin/src/Middleware/PermissionMiddleware.php create mode 100644 src/admin/src/Model/CommonConfig.php create mode 100644 src/admin/src/Model/ExportTasks.php create mode 100644 src/admin/src/Model/FrontRoutes.php create mode 100644 src/admin/src/Model/GlobalConfig.php create mode 100644 src/admin/src/Model/OperatorLog.php create mode 100644 src/admin/src/Model/RequestLog.php create mode 100644 src/admin/src/Model/Role.php create mode 100644 src/admin/src/Model/RoleMenu.php create mode 100644 src/admin/src/Model/User.php create mode 100644 src/admin/src/Model/UserRole.php create mode 100644 src/admin/src/Model/Version.php create mode 100644 src/admin/src/Model/Versionable.php create mode 100644 src/admin/src/Service/AuthService.php create mode 100644 src/admin/src/Service/CommonConfig.php create mode 100644 src/admin/src/Service/ExportService.php create mode 100644 src/admin/src/Service/GlobalConfig.php create mode 100644 src/admin/src/Service/Menu.php create mode 100644 src/admin/src/Service/OperatorLogService.php create mode 100644 src/admin/src/Service/PermissionService.php create mode 100644 src/admin/src/Service/UserService.php create mode 100644 src/admin/src/config/config.php create mode 100644 src/admin/src/config/routes.php create mode 100644 src/admin/src/funcs/common.php create mode 100644 src/alert-manager/composer.json create mode 100644 src/alert-manager/src/AlertJob.php create mode 100644 src/alert-manager/src/AlertMessage.php create mode 100644 src/alert-manager/src/AlertQueueConsumer.php create mode 100644 src/alert-manager/src/AlertRobots.php create mode 100644 src/alert-manager/src/AlertRules.php create mode 100644 src/alert-manager/src/AlertService.php create mode 100644 src/alert-manager/src/ConfigProvider.php create mode 100644 src/alert-manager/src/DingTalkRobot.php create mode 100644 src/alert-manager/src/SenderInterface.php create mode 100644 src/alert-manager/src/func.php create mode 100644 src/base-utils/composer.json create mode 100644 src/base-utils/src/AKSK.php create mode 100644 src/base-utils/src/AliyunOSS.php create mode 100644 src/base-utils/src/ColorLineFormatter.php create mode 100644 src/base-utils/src/ConfigProvider.php create mode 100644 src/base-utils/src/Constants/Consts.php create mode 100644 src/base-utils/src/Constants/ErrorCode.php create mode 100644 src/base-utils/src/Excel/ExcelReader.php create mode 100644 src/base-utils/src/Excel/ExcelWriter.php create mode 100644 src/base-utils/src/Exception/HttpExceptionHandler.php create mode 100644 src/base-utils/src/Guzzle.php create mode 100644 src/base-utils/src/HAStreamHandler.php create mode 100644 src/base-utils/src/Helper/array.php create mode 100644 src/base-utils/src/Helper/common.php create mode 100644 src/base-utils/src/Helper/constants.php create mode 100644 src/base-utils/src/Helper/system.php create mode 100644 src/base-utils/src/JWT.php create mode 100644 src/base-utils/src/Listener/BootAppConfListener.php create mode 100644 src/base-utils/src/Listener/DbQueryExecutedListener.php create mode 100644 src/base-utils/src/Listener/FetchModeListener.php create mode 100644 src/base-utils/src/Log.php create mode 100644 src/base-utils/src/Middleware/CorsMiddleware.php create mode 100644 src/base-utils/src/Middleware/HttpLogMiddleware.php create mode 100644 src/base-utils/src/Model/BaseModel.php create mode 100644 src/base-utils/src/Model/EsBaseModel.php create mode 100644 src/base-utils/src/Redis/Redis.php create mode 100644 src/base-utils/src/Redis/RedisArray.php create mode 100644 src/base-utils/src/Redis/RedisQueue.php create mode 100644 src/base-utils/src/RotatingFileHandler.php create mode 100644 src/base-utils/src/RoutesDispatcher.php create mode 100644 src/base-utils/src/Scaffold/Controller/AbstractController.php create mode 100644 src/base-utils/src/Scaffold/Controller/Controller.php create mode 100644 src/base-utils/src/Scaffold/Entity/ApiEntityAbstract.php create mode 100644 src/base-utils/src/Scaffold/Entity/EntityInterface.php create mode 100644 src/base-utils/src/Scaffold/Entity/EsEntityAbstract.php create mode 100644 src/base-utils/src/Scaffold/Entity/MysqlEntityAbstract.php create mode 100644 src/base-utils/src/StdoutLoggerFactory.php create mode 100644 src/base-utils/src/classmap/ProviderConfig.php create mode 100644 src/base-utils/src/config/routes.php create mode 100644 src/cron-center/.gitignore create mode 100644 src/cron-center/composer.json create mode 100644 src/cron-center/src/AlertManagerProcess.php create mode 100644 src/cron-center/src/ClassJobAbstract.php create mode 100644 src/cron-center/src/CommandJobAbstract.php create mode 100644 src/cron-center/src/ConfigProvider.php create mode 100644 src/cron-center/src/Controller/CronNodeController.php create mode 100644 src/cron-center/src/Controller/CrontabController.php create mode 100644 src/cron-center/src/CronManager.php create mode 100644 src/cron-center/src/Crontab.php create mode 100644 src/cron-center/src/CrontabDispatcherProcess.php create mode 100644 src/cron-center/src/CrontabRegisterListener.php create mode 100644 src/cron-center/src/Executor.php create mode 100644 src/cron-center/src/Install/InstallCommand.php create mode 100644 src/cron-center/src/Install/install.sql create mode 100644 src/cron-center/src/Model/CronJobs.php create mode 100644 src/cron-center/src/Model/CronNodes.php create mode 100644 src/cron-center/src/Model/JobRunLog.php create mode 100644 src/cron-center/src/config/routes.php create mode 100644 src/data-focus/composer.json create mode 100644 src/data-focus/src/ConfigProvider.php create mode 100644 src/data-focus/src/Controller/DsnController.php create mode 100644 src/data-focus/src/Controller/PluginFunctionController.php create mode 100644 src/data-focus/src/Controller/ReportChangeLogController.php create mode 100644 src/data-focus/src/Controller/ReportsController.php create mode 100644 src/data-focus/src/Install/InstallCommand.php create mode 100644 src/data-focus/src/Install/install.sql create mode 100644 src/data-focus/src/Model/Dsn.php create mode 100644 src/data-focus/src/Model/PluginFunction.php create mode 100644 src/data-focus/src/Model/ReportChangeLog.php create mode 100644 src/data-focus/src/Model/Reports.php create mode 100644 src/data-focus/src/Service/Dsn.php create mode 100644 src/data-focus/src/Util/BootAppConfListener.php create mode 100644 src/data-focus/src/Util/CodeRunner.php create mode 100644 src/data-focus/src/Util/PHPSandbox.php create mode 100644 src/data-focus/src/Util/SandboxException.php create mode 100644 src/data-focus/src/Util/SimpleHtmlDom.php create mode 100644 src/data-focus/src/Util/ValidatorVisitor.php create mode 100644 src/data-focus/src/Util/func.php create mode 100644 src/data-focus/src/config/routes.php create mode 100644 src/data-focus/src/readme.md create mode 100644 src/dev-tools/composer.json create mode 100644 src/dev-tools/src/AbstractMaker.php create mode 100644 src/dev-tools/src/ConfigProvider.php create mode 100644 src/dev-tools/src/Controller/DevController.php create mode 100644 src/dev-tools/src/ControllerMaker.php create mode 100644 src/dev-tools/src/ModelMaker.php create mode 100644 src/dev-tools/src/TableSchema.php create mode 100644 src/dev-tools/src/config/routes.php create mode 100644 src/event-bus/composer.json create mode 100644 src/event-bus/publish/event-bus.php create mode 100644 src/event-bus/src/BootProcessListener.php create mode 100644 src/event-bus/src/ConfigProvider.php create mode 100644 src/event-bus/src/Controller/PushController.php create mode 100644 src/event-bus/src/Kafka.php create mode 100644 src/event-bus/src/OnPipeMessageListener.php create mode 100644 src/event-bus/src/PipeMessage.php create mode 100644 src/event-bus/src/ProcessFactory.php create mode 100644 src/event-bus/src/config/routes.php create mode 100644 src/event-bus/src/funcs.php create mode 100644 src/process-manager/composer.json create mode 100644 src/process-manager/publish/process_manager.php create mode 100644 src/process-manager/src/AmqpConsumerManager.php create mode 100644 src/process-manager/src/BootProcessListener.php create mode 100644 src/process-manager/src/ConfigProvider.php create mode 100644 src/process-manager/src/NsqConsumerManager.php create mode 100644 src/rule-engine/composer.json create mode 100644 src/rule-engine/src/ArrayHelper.php create mode 100644 src/rule-engine/src/BooleanOperation.php create mode 100644 src/rule-engine/src/Context/Context.php create mode 100644 src/rule-engine/src/Context/ContextPluginAbstract.php create mode 100644 src/rule-engine/src/Context/ContextPluginInterface.php create mode 100644 src/rule-engine/src/Context/IpContext.php create mode 100644 src/rule-engine/src/Context/RequestContext.php create mode 100644 src/rule-engine/src/Context/TimeContext.php create mode 100644 src/rule-engine/src/FilterException.php create mode 100644 src/validation/.gitignore create mode 100644 src/validation/composer.json create mode 100644 src/validation/src/Validation.php create mode 100644 src/validation/src/ValidationCustomRule.php create mode 100644 src/workflow/composer.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a485728 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +composer.lock +/vendor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..00dcc4a --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +`HyperfAdmin`是前后端分离的后台管理系统, 前端基于`vue`的 `vue-admin-template`, 针对后台业务`列表`, `表单`等场景封装了大量业务组件, 后端基于`hyperf`实现, 整体思路是后端定义页面渲染规则, 前端页面渲染时首先拉取配置, 然后组件根据具体配置完成页面渲染, 方便开发者仅做少量的配置工作就能完成常见的`CRUD`工作, 同时支持自定义组件和自定义页面, 以开发更为复杂的页面. + +[详细文档](https://hyperf-admin.github.io/) + +![HyperfAdmin架构](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/sJaJti.png) + +前端为`vue multiple page`多页模式, 可以按模块打包, 默认包含两个模块`default` 默认模块, `system`系统管理模块, 绝大部分业务组件在`src/components`目录 + +后端为`composer包`模式, 目前包含组件 + +- 基础组件 + - `composer require hyperf-admin/base-utils` hyperf-admin的基础组件包, 脚手架主要功能封装 + - `composer require hyperf-admin/validation` 参数验证包, 对规则和参数提示做了较多优化 + - `composer require hyperf-admin/alert-manager` 企微/钉钉机器人报警包 + - `composer require hyperf-admin/rule-engine` 规则引擎 + - `composer require hyperf-admin/event-bus` mq/nsq/kafka消息派发器 + - `composer require hyperf-admin/process-manager` 进程管理组件 +- 业务组件 (业务组件为包含特定业务功能的包) + - `composer require hyperf-admin/admin` 系统管理业务包 + - `composer require hyperf-admin/dev-tools` 开发者工具包, 主要是代码生成, 辅助开发 + - `composer require hyperf-admin/cron-center` 定时任务管理, 后台化管理任务 + - `composer require hyperf-admin/data-focus` 数据面板模块, 帮你快速制作数据大盘 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..865828b --- /dev/null +++ b/composer.json @@ -0,0 +1,102 @@ +{ + "name": "hyperf-admin/hyperf-admin", + "description": "hyperf-admin", + "authors": [ + { + "name": "daodao97", + "email": "daodao97@foxmail.com" + } + ], + "require": { + "php": ">=7.2", + "ext-json": "*", + "ext-swoole": ">=4.4", + "ext-pdo": "*" + }, + "require-dev": { + "aliyuncs/oss-sdk-php": "^2.3", + "box/spout": "^3.1", + "hyperf/amqp": "^1.1", + "hyperf/cache": "~1.1.0", + "hyperf/command": "~1.1.0", + "hyperf/config": "~1.1.0", + "hyperf/constants": "^1.1", + "hyperf/database": "^1.1", + "hyperf/db-connection": "~1.1.0", + "hyperf/filesystem": "^1.1", + "hyperf/framework": "~1.1.0", + "hyperf/guzzle": "~1.1.0", + "hyperf/http-server": "~1.1.0", + "hyperf/logger": "~1.1.0", + "hyperf/memory": "~1.1.0", + "hyperf/metric": "^1.1", + "hyperf/nsq": "^1.1", + "hyperf/process": "~1.1.0", + "hyperf/redis": "~1.1.0", + "hyperf/snowflake": "^1.1", + "yadakhov/insert-on-duplicate-key": "^1.2", + "hyperf/async-queue": "^1.1", + "hyperf/crontab": "^1.1", + "zoujingli/ip2region": "^1.0", + "hyperf/validation": "^1.1" + }, + "replace": { + "hyperf-admin/base-utils": "self.version", + "hyperf-admin/admin": "self.version", + "hyperf-admin/alert-manager": "self.version", + "hyperf-admin/cron-center": "self.version", + "hyperf-admin/event-bus": "self.version", + "hyperf-admin/process-manager": "self.version", + "hyperf-admin/rule-engine": "self.version", + "hyperf-admin/validation": "self.version", + "hyperf-admin/workflow": "self.version", + "hyperf-admin/data-focus": "self.version", + "hyperf-admin/dev-tools": "self.version" + }, + "autoload": { + "psr-4": { + "HyperfAdmin\\BaseUtils\\": "./src/base-utils/src", + "HyperfAdmin\\Admin\\": "./src/admin/src", + "HyperfAdmin\\AlertManager\\": "./src/alert-manager/src", + "HyperfAdmin\\CronCenter\\": "./src/cron-center/src", + "HyperfAdmin\\DataFocus\\": "./src/data-focus/src", + "HyperfAdmin\\DevTools\\": "./src/dev-tools/src", + "HyperfAdmin\\EventBus\\": "./src/event-bus/src", + "HyperfAdmin\\ProcessManager\\": "./src/process-manager/src", + "HyperfAdmin\\RuleEngine\\": "./src/rule-engine/src", + "HyperfAdmin\\Validation\\": "./src/validation/src" + }, + "files": [ + "./src/base-utils/src/Helper/array.php", + "./src/base-utils/src/Helper/common.php", + "./src/base-utils/src/Helper/constants.php", + "./src/base-utils/src/Helper/system.php", + "./src/data-focus/src/Util/func.php", + "./src/event-bus/src/funcs.php", + "./src/admin/src/funcs/common.php", + "./src/data-focus/src/Util/SimpleHtmlDom.php" + ], + "classmap": [ + "./src/base-utils/src/classmap" + ] + }, + "extra": { + "hyperf": { + "config": [ + "HyperfAdmin\\Admin\\ConfigProvider", + "HyperfAdmin\\BaseUtils\\ConfigProvider@99", + "HyperfAdmin\\AlertManager\\ConfigProvider", + "HyperfAdmin\\CronCenter\\ConfigProvider", + "HyperfAdmin\\DataFocus\\ConfigProvider", + "HyperfAdmin\\DevTools\\ConfigProvider", + "HyperfAdmin\\EventBus\\ConfigProvider", + "HyperfAdmin\\ProcessManager\\ConfigProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true + } +} diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0431252 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +`hyperf-admin`是前后端分离的后台管理系统, 前端基于`vue`的 `vue-admin-template`, 针对后台业务`列表`, `表单`等场景封装了大量业务组件, 后端基于`hyperf`实现, 整体思路是后端定义页面渲染规则, 前端页面渲染时首先拉取配置, 然后组件根据具体配置完成页面渲染, 方便开发者仅做少量的配置工作就能完成常见的`CRUD`工作, 同时支持自定义组件和自定义页面, 以开发更为复杂的页面. + +![hyperf-admin架构](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/sJaJti.png) + +前端为`vue multiple page`多页模式, 可以按模块打包, 默认包含两个模块`default` 默认模块, `system`系统管理模块, 绝大部分业务组件在`src/components`目录, 前端文档详见 [这里](/frontend/) + +后端为`composer包`模式, 目前包含组件 + +- 基础组件 + - `composer require hyperf-admin/base-utils` hyperf-admin的基础组件包, 脚手架主要功能封装 + - `composer require hyperf-admin/validation` 参数验证包, 对规则和参数提示做了较多优化 + - `composer require hyperf-admin/alert-manager` 企微/钉钉机器人报警包 + - `composer require hyperf-admin/rule-engine` 规则引擎 + - `composer require hyperf-admin/event-bus` mq/nsq/kafka消息派发器 + - `composer require hyperf-admin/process-manager` 进程管理组件 +- 业务组件 (业务组件为包含特定业务功能的包) + - `composer require hyperf-admin/admin` 系统管理业务包 + - `composer require hyperf-admin/dev-tools` 开发者工具包, 主要是代码生成, 辅助开发 + - `composer require hyperf-admin/cron-center` 定时任务管理, 后台化管理任务 + - `composer require hyperf-admin/data-focus` 数据面板模块, 帮你快速制作数据大盘 + +后端的详细文档见[这里](/backend/) + +## 依赖 & 参考 + +- 前端 + - [Vue](https://github.com/vuejs/vue) + - [ElementUI](https://github.com/ElemeFE/element) + - [FormCreate](http://www.form-create.com/v2/guide) + - [vue-admin-template](https://github.com/PanJiaChen/vue-admin-template) + - [Vue 渲染函数 & JSX](https://cn.vuejs.org/v2/guide/render-function.html) +- 后端 + - [Hyperf](http://hyperf.wiki/) + - [Swoole](http://wiki.swoole.com) diff --git a/docs/_coverpage.md b/docs/_coverpage.md new file mode 100644 index 0000000..a94a886 --- /dev/null +++ b/docs/_coverpage.md @@ -0,0 +1,12 @@ +![logo](_media/icon.svg) + +# docsify + +> A magical documentation site generator. + +* Simple and lightweight (~12kb gzipped) +* Multiple themes +* Not build static html files + +[GitHub](https://github.com/docsifyjs/docsify/) +[Get Started](#quick-start) diff --git a/docs/_navbar.md b/docs/_navbar.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/_sidebar.md b/docs/_sidebar.md new file mode 100644 index 0000000..0edaf31 --- /dev/null +++ b/docs/_sidebar.md @@ -0,0 +1,21 @@ +* 入门 + * [介绍](guide/desc.md) + * [安装](guide/install.md) + * [开发样例](guide/dev_example.md) +* 后端 + * [脚手架](backend/scaffold.md) + * [表单详解](backend/form.md) + * [列表详解](backend/list.md) + * [按钮详解](backend/super-button.md) + * [通用配置](backend/common-config.md) + * [辅助函数](backend/functions.md) +* 业务组件 + * [DevTools-开发者工具](backend/components/dev-tools.md) + * [DataFocus-数据面板](backend/components/data-focus.md) + * [CronCenter-任务中心](backend/components/cron-center.md) + * [开发一个业务组件](backend/make_component.md) +* 前端 + * [表单](frontend/form.md) + * [列表](frontend/list.md) + * [图表](frontend/chart.md) + diff --git a/docs/backend/common-config.md b/docs/backend/common-config.md new file mode 100644 index 0000000..49b75e1 --- /dev/null +++ b/docs/backend/common-config.md @@ -0,0 +1,38 @@ +通用配置在只需要使用表单搜集信息, 没有过多业务逻辑和校验规则时使用, 可以无需开发, 完成表单的定义和使用 + +1. 定义表单 http://localhost:9528/system/#/cconf/list, 此处表单规则同控制器中的form定义 + + ![](http://qupinapptest.oss-cn-beijing.aliyuncs.com/img/DGD103.png) + +2. 投放表单, 创建 `/***/cconf_{1中的表单名称}` 即可通过该路由访问 + + ![](http://qupinapptest.oss-cn-beijing.aliyuncs.com/img/b9Pw1z.png) + +3. 访问 http://localhost:9528/hyperf/#/lucky/cconf_lucky_round + + ![](http://qupinapptest.oss-cn-beijing.aliyuncs.com/img/I3pgnz.png) + +数据存储位置`hyperf_admin.common_config` + +```mysql +CREATE TABLE `common_config` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `namespace` varchar(50) NOT NULL DEFAULT '' COMMENT '命名空间, 字母', + `name` varchar(100) NOT NULL COMMENT '配置名, 字母', + `title` varchar(100) NOT NULL DEFAULT '' COMMENT '可读配置名', + `remark` varchar(100) NOT NULL DEFAULT '' COMMENT '备注', + `rules` text COMMENT '配置规则描述', + `value` text COMMENT '具体配置值 key:value', + `permissions` text COMMENT '权限', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `unique` (`name`,`namespace`), + KEY `namespace` (`namespace`), + KEY `update_at` (`update_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通用配置'; +``` + +可以通过 http://localhost:9528/system/#/cconf/list 管理命名空间 + +系统中可以使用`Rcok\BaseUtils\Service\CommonConfig`获取相应的提交数据 diff --git a/docs/backend/components/cron-center.md b/docs/backend/components/cron-center.md new file mode 100644 index 0000000..7f41689 --- /dev/null +++ b/docs/backend/components/cron-center.md @@ -0,0 +1,7 @@ +脚本作业的管理中心, 可以在代码中实现`class`类型, `command`类型的脚本作业, 在后台添加相关任务, 即可在相应脚本机上执行 + +可以对任务的入口, 执行规划, 执行节点, 执行参数 等进行配置, 也可在列表主动触发任务 + +作业必须基于`App\Util\CronCenter\ClassJobAbstract`, 或`App/Util/CronCenter/CommandJobAbstract.php`抽象类进行实现, 才可进行执行状态的跟踪 + +`CronCenter`的实现基于`hyperf-crontab`进行实现, 具体代码在`app/Util/CronCenter`, 更多细节可查看[文档](https://hyperf.wiki/#/zh-cn/crontab) diff --git a/docs/backend/components/data-focus.md b/docs/backend/components/data-focus.md new file mode 100644 index 0000000..c4c297f --- /dev/null +++ b/docs/backend/components/data-focus.md @@ -0,0 +1,97 @@ +数据大盘 + +DataFocus (焦点数据) 用途是帮助大家快速构建一个如下的数据面板, 对数据可视化做了大量封装, 让开发者只用关心数据来源和数据处理, 无需处理复杂的图标构建, 即可轻松制作出漂亮的看板, 为业务决策这提供更直观的参考. + +数据面板的定义 + +数据面板中可以定义, `sql`, `json`, `php代码`, `markdown` , `html` 等级数据格式, 通过统一转换由相应前端组件渲染成`图标` 或`列表` + +目前支持的图标样式`LineChart`, `ColumnChart`, `PieChart`, `NumberPanel` 分别会渲染为`曲线图`, `柱状图`, `饼图`, `数字面板`, `列表` + +### sql节点 + +1. 定义改节点的属性 + 1. id 节点名称, 必须, 不可重复 + 2. dsn sql 查询说使用的dsn, 可用dsn的范围是 hyperf/config/databaes 中 DataFocus Dsn 中定义的数据源 + 3. chart 图标类型, 格式为`图表名|X轴,Y轴1,Y轴2,…`, 图标名为必须 + 4. show_table 默认 `false`, 为`true`时除了渲染图表, 还会渲染数据列表 + 5. table_plugin 表级的插件, 可以对结果数据做二次干预, 可以调用 DataFocus/plugin_fucntion 中定义的全局插件, 也可在调用当前面板中自定义插件 + 1. 自定义插件为 当前页面的一段`php function`代码 + 2. 面板中的所有自定义`php`方法, 必须以`df_`开头 + 6. span 布局 总24的栅格布局, 具体参见 +2. 节点内容, 填写构造数据的查询`sql`即可 + 1. 一个`` 节点 只能定义一个`sql`语句 + 2. 只能使用`select`语句 + +下面的样例中定义的一个以日期`date`为`X轴`, 其他数据指标为`Y轴`的曲线图 + +```php + + select + visitor_uv as "人数", + visitor_pv as "次数" + data_date as date + from + visitor_log + where + data_date >= {{ date('Y-m-d', strtotime('-30 day')) }} + group by date + +``` + +其他类型的图标也基本类似, 只用调整相应的`chart`数据即可, 比如下面的饼图 + +```php + + select + area as "地区", + count(1) as "数量" + from + visitor_log + where + data_date >= {{ date('Y-m-d', strtotime('-1 day')) }} + group by area + +``` + +3. 模板变量 + +细心的同学可能已经发现, 上面的`sql`内容中使用了`{{ date('Y-m-d', strtotime('-1 day')) }}`这样的变量定义,模板变量的定义类似 `twig` 语法, 本质上是把花括号的内容转换为`php`代码进行运算, 然后替换模板变量, 执行`sql`. + +格式: `{{ func|pip1|pip2 }}` + +`func` 为数据的产生源头, 是必须的. 后面`|` 竖线分隔的管道, 让会源头输出的结构做二次处理, 最终替换到模板中 + +次数模板替换时, 会默认给变量加引号, 比如上方的`sql` 最终会替换为 `data >= '2020-06-11'`, 若需要原样输出, 可只用 `raw` 管道 + +管道也可以是一个自定义`php` 方法 + +更多样例见`系统管理/DataFocus/数据面板` 菜单下 + +### json + +节点属性同 sql + +节点内容, 可以填入`json`格式数据 + +```php + +{ + "lable":"value" +} + +``` + +### php + +php并非一个单独节点, 而是可以作为一个片段, 迁移任意节点内 + +```php + + + +``` + +### md + +### html \ No newline at end of file diff --git a/docs/backend/components/dev-tools.md b/docs/backend/components/dev-tools.md new file mode 100644 index 0000000..d628d8f --- /dev/null +++ b/docs/backend/components/dev-tools.md @@ -0,0 +1,11 @@ +## 代码自动生成工具 + +针对 数据库, `Model`, `Controller` 通用模型, 可以使用后台提供的`代码生成工具`来初始化大部分代码. + +![9fnAUL(1)](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/9fnAUL%20%281%29.png) + +连接池对应`config.autoload.databases` 中配置的可用链接, 选择好`连接池`, `数据库`, `表` 后下方表单会根据表结构字段渲染, 完成具体字段的配置, 点击提交. + +![b4lL49](http://qupinapptest.oss-cn-beijing.aliyuncs.com/img/b4lL49.png) + +相应的`Modle`, `Controller` 便已创建成功. diff --git a/docs/backend/form.md b/docs/backend/form.md new file mode 100644 index 0000000..b717967 --- /dev/null +++ b/docs/backend/form.md @@ -0,0 +1,430 @@ +## 字段规则 + +```php +'field|字段名称' => [ + // 字段验证规则, + 'rule' => 'required|max:', + // 请参考http://www.form-create.com/v2/element-ui/components/input.html + 'type' => 'input', + // 表单默认值 + 'default' => '', + 'info' => '字段备注', + // 只读属性,当编辑时有效 + 'readonly' => true, + // 表单选项,只有支持options选项的组件设置才有效,可以定义一个callback方法,可以参考formOptionsConvert方法 + 'options' => [], + // 其他组件属性,请参考具体组件的props的定义 + 'props' => [], + // 定义依赖项 + 'depend' => [ + 'field' => 'target_type', + 'value' => [], + ], + // col 布局规则 http://www.form-create.com/v2/element-ui/col.html + 'col' => [ + // 表单长度 + 'span' => 12, + // 标签宽度 + 'labelWidth' => 150, + ], + // 动态修改其他字段规则 详见下方联动小节 + 'compute' => [ + "will_set_field" => [ + "when" => ['=', 1], + "set" => [ + // + ] + ] + ], + // 该字段规则回调方法,可以用于重置字段规则 + 'render' => function () { + }, + // 是否虚拟字段,虚拟字段在查询脚手架model时,会忽略该字段 + 'virtual_field' => true, + // 该字段在表单中是否渲染,默认true + 'form' => false, +], +``` + +*rule: 后端字段验证规则* + +rule完整支持 hyperf 原生的 validation 的校验[规则]([https://hyperf.wiki/#/zh-cn/validation?id=%e9%aa%8c%e8%af%81%e8%a7%84%e5%88%99](https://hyperf.wiki/#/zh-cn/validation?id=验证规则)), 且切封装了高度灵活的自定义校验 `app/Service/ValidationCustomRule.php` 其中定义的方法均可在`rule` 中直接使用, 还支持`cb_***` 调用定义在当前控制器中的自定义验证. + +> 注意:目前还没有根据该规则生成前端的验证规则,前端目前只验证了是否必填的 + +## 内置组件 + +type:表单项类型,以下是支持的组件列表,以下所有组件 props 均可支持原始文档中的所有属性 + +### 1. 普通输入框 + +[原始文档](http://www.form-create.com/v2/iview/components/input.html) + +```php +[ + "field_name|字段名" => "required|***" + // or + "field_name|字段名" => [ + "type" => "input" // 可省略, 默认 input + "rule" => "required|***", + "default" => "默认值" // 非必须, + "info" => "字段提示文字", + "props" => [ + "showCopy" => true, // 开启 copy 功能 + ] + ] +] +``` + +### 2. 数字(整数) + +[原始文档](http://www.form-create.com/v2/iview/components/input-number.html) + +```php +[ + "field_name|字段名" => [ + "type" => "number" + ] +] +``` + +### 3. 数字(两位小数) + +[原始文档](http://www.form-create.com/v2/iview/components/input-number.html) + +```php +[ + "field_name|字段名" => [ + "type" => "float", + "props" => [ + "precision" => 2, // 小数保留位数, 默认2 + ] + ] +] +``` + +### 4. 多行输入 + +[原始文档](http://www.form-create.com/v2/iview/components/input.html) + +```php +[ + "field_name|字段名" => [ + "type" => "textarea", + "props" => [ + "row" => 6, // 行数, 默认6 + ] + ] +] +``` + +### 5. 开关(0/1) + +[原始文档](http://www.form-create.com/v2/iview/components/switch.html) + +```php +[ + "field_name|字段名" => [ + "type" => "switch" + ] +] +``` + +### 6. 时间控件 + +[原始文档](http://www.form-create.com/v2/iview/components/date-picker.html) + +```php +[ + "field_name|字段名" => [ + "type" => "datetime" + ] +] +``` + +### 7. 时间区间 + +[原始文档](http://www.form-create.com/v2/iview/components/date-picker.html) + +```php +[ + "field_name|字段名" => [ + "type" => "datetime_range", + "props" => [ + "range" => [ + "after" => date('Y-m-d'), + "before" => date('Y-m-d', strtotime('+6 days')) + ], + // or 简写 + "range" => "afterToday", // afterToday 今天之后, beforeToday 今天之前, 包含今天 + ] + ] +] +``` + +### 8. 日期 + +[原始文档](http://www.form-create.com/v2/iview/components/date-picker.html) + +```php +[ + "field_name|字段名" => [ + "type" => "date" + ] +] +``` + +### 9. 日期区间 + +[原始文档](http://www.form-create.com/v2/iview/components/date-picker.html) + +```php +[ + "field_name|字段名" => [ + "type" => "date_range" + ] +] +``` + +### 10. 下拉选择框 + +[原始文档](http://www.form-create.com/v2/iview/components/select.html) + +```php +[ + "field_name|字段名" => [ + "type" => "select", + "options" => [ // 远程搜索是无需 支持回调函数 function() { return 备选项; } + 1 => "lable1", + 2 => "lable2", + ], + "props" => [ + "selectApi" => "/coupon/act", // 远程搜索模式 + "multiple" => true, // 是否多选, 默认false + "multipleLimit" => 10, // 多选时的上限 + ] + ] +] +``` + +### 11. 上传图片 + +[原始文档](http://www.form-create.com/v2/iview/components/upload.html) + +```php +[ + "field_name|字段名" => [ + "type" => "image", + "props" => [ + // 上传张数上线, 默认1单个 + "limit" => 1, + //支持下载 + "downloadable" => true, + // 限制上传文件的后缀名 + "format " => ['jpg', 'jpeg', 'png', 'gif'], + // 限制上传文件的大小 单位是 kb + "maxSize" => 200, + // 上传的目标 bucket + 'bucket' => 'aliyuncs', + // 是否为私有 + 'private' => true, + ] + ] +] +``` + +### 12. 上传文件 + +[原始文档](http://www.form-create.com/v2/iview/components/upload.html) + +```php +[ + "field_name|字段名" => [ + "type" => "file", + "props" => [ + // 上传张数上线, 默认1单个 + "limit" => 1, + //支持下载 + "downloadable" => true, + // 限制上传文件的后缀名 + "format " => ['doc', 'exl', 'ppt'], + // 限制上传文件的大小 单位是 kb + "maxSize" => 200, + ] + ] +] +``` + +### 13. 级联选择器 + +```php +[ + "field_name|字段名" => [ + "type" => "cascader", + "props" => [ + "limit" => 1, // 上传张数上线, 默认1单个 + ] + ] +] +``` + +### 14. json 组件 + +```php +[ + "field_name|字段名" => [ + "type" => "json", + ] +] +``` + +### 15. 富文本 + +```php +[ + "field_name|字段名" => [ + "type" => "html", + ] +] +``` + +### 16. 图标选择器 + +```php +[ + "field_name|字段名" => [ + "type" => "icon-select", + ] +] +``` + +示例效果: + +![icon](http://qupinapptest.oss-cn-beijing.aliyuncs.com/1/202005/9e62be2e0affafc0cdee8bc7fba3c0fd.png) + +### 17. 嵌套表单 SubForm + +```php +'test|嵌套表单' => [ + 'type' => 'sub-form', + 'children' => [ // 子表单的规则, 同一级规则 + 'test_sub|嵌套1' => 'required', + 'test_sub1|嵌套2' => 'required', + ], + 'repeat' => true, // 是否可动态添加 + 'default' => [ // 默认值 + [ + 'test_sub' => 1, + 'test_sub1' => 1, + ], + [ + 'test_sub' => 1, + 'test_sub1' => 1, + ], + ], +], +``` + +示例效果: + +![subForm](http://qupinapptest.oss-cn-beijing.aliyuncs.com/img/Snipaste_2020-02-21_19-29-35.png) + +### 18. 区域输入框 + +[前端文档](http://localhost:8080/hyperfdoc/frontend/components/3_InputRange.html) + +```php +'test|区域输入框' => [ + 'type' => 'inputRange', + // value值 type: Array or String + // - Array:例如:[1, 10], 返回结果也将是数组 + // - String:例如:1,10, 返回结果也将是字符串 + 'value' => [1, 10] or '1,10', + 'props' => { + // 是否允许清除 + 'clearable': true, + // 可控制item宽度等样式 默认宽度300px + 'style': 'width: 300px', + // 开始值和结束值的placeholder + 'placeholder': ['min', 'max'] + }, +], +``` + +## 组件联动 + +```php +// depend +[ + "field_1" => "", + "field_2" => [ + "depend" => [ + "field" => "field_1", // 依赖字段 + "value" => '1' // 当 field_1 = 1 时 field_2 此项才会显示 + ] + ] +] + +// hidden +[ + "field_1" => "", + "field_2" => [ + "hidden" => [ + "field" => "field_1", // 影响字段 + "value" => '1' // 当 field_2 = 1 时 field_1 项会隐藏 + ] + ] +] + +// 备选项 条件控制 +[ + "field_1" => [ + "type" => "select", + "options" => [ + [ + "value" => 1, + "lable" => "是" + ], + [ + "value" => 0, + "lable" => "否", + // 当 disabled_when 条件运算结果, 即为 disabled 的值 + "disabled_when" => [ + "field_1", '=', 0 + ], + // or + "disabled_when" => [ + ["field_2", '=', 0], + ["field_3", '!=', 4], + ], + ] + ] + ] +] + +// 进阶用法 compute 动态计算 +[ + "field" => [ + "compute" => [ + "when" => ['=', 1], // 注意这里只有个 比较操作符 和 比较值 + // set 操作项 + "set" => [ + "field_2" => [ + // 此处支持控件除 type 外, 完整属性设置 + "value" => 1, + // 此处支持 值为 callable + "value" => function() { return time();}, + // 重写rule + "rule" => "required" + "props" => [ + // ... + ] + ], + // ... "field_3" + ], + // append 项, 尚未实现 + // remove 项, 尚未实现 + ] + ] +] +``` diff --git a/docs/backend/functions.md b/docs/backend/functions.md new file mode 100644 index 0000000..63142e7 --- /dev/null +++ b/docs/backend/functions.md @@ -0,0 +1,78 @@ +## 数组函数 + +#### array_group_k2k + +#### array_group_by + +#### array_node_append + +#### array_map_recursive + +#### array_copy + +#### array_sort_by_key_length + +#### array_sort_by_value_length + +#### array_to_kv + +#### array_flat + +#### array_depth + +#### array_merge_node + +#### array_change_v2k + +#### array_group + +#### array_last + +#### array_split + +#### array_get_by_keys + +#### array_remove + +#### array_get_node + +#### array_remove_keys_not_in + +#### array_remove_keys + +#### mt_array_merge + +## 系统函数 + +#### server + +#### swoole_server + +#### dispatcher + +#### register_route + +#### move_local_file_to_oss + +#### oss_private_url + +#### call_self_api + +#### select_options + +#### process_list_filter + +#### get_sub_dir + +#### db_complete + +#### format_exception + +## 内置常量 + +`DAY`, `HOUR`, `MINUTE`, `YES`, `NO` + + + + + diff --git a/docs/backend/list.md b/docs/backend/list.md new file mode 100644 index 0000000..d096714 --- /dev/null +++ b/docs/backend/list.md @@ -0,0 +1,571 @@ +![](http://km.innotechx.com/download/attachments/82253382/image2020-5-8_17-43-53.png?version=1&modificationDate=1588931034077&api=v2) + +```php +return [ + ...... + // 列表定义 + 'table' => [ + // 树型结构列表,默认false + "is_tree" => true + // tree 节点为非必须, 默认pid的名称为pid, 不同时需要重写 + "tree" => [ + "pid" => "pid" + ], + // tabs 列表页分页签 + 'tabs' => [], + // 定义渲染列表, 未定义则获取 form 中所有 + 'columns' => [], + // 订单行操作按钮 + 'rowActions' => [], + // 列表上方批量操作的按钮 + 'batchButtons' => [], + // 页面上方操作按钮 + 'topActions' => [], + ], +]; +``` + +## 列定义 + +在`columns`中定义列表中显示的字段与表头,具体配置如下: + +```php +'columns' => [ // 非必须项, 无则从form转义 + '字段', // 简写模式, 直接从form配置转义 + // or + [ + 'field' => 'mall_name', + 'title' => '店铺', + // 是否显示该字段,默认false + 'hidden' => true, + // 字段渲染规则,默认为空, 详见列渲染 + 'type' => '', + // 是否虚拟字段,虚拟字段在查询脚手架model时,会忽略该字段 + 'virtual_field' => true, + // 设置Popover提示信息,其中 + 'popover' => [ + 'messages' => [ + '原因:{remark}', + ], + 'when' => [ + ['status', '=', Activity::STATUS_OFF] + ] + ], + // 表头说明 + 'info' => '括号内为商家承担', + // 定义该字段显示在哪些tab选项中 + 'depend' => [ + 'tab' => [(string)Coupon::TYPE_MALL_MONEY_OFF], + ], + // 按字段升降查询功能 + 'sortable' => true, + // 是否允许编辑,调用*/rowchange/:id接口 + 'edit' => true, + // 允许编辑的条件 + 'when' => [ + ['status', '=', Activity::STATUS_OFF] + ], + // 枚举值,可以options中的数据转换成Tag显示效果,https://element.eleme.cn/#/zh-CN/component/tag + 'options' => [], + 'enum' => [ // tag 的 type 类型, 参见 element 标签 + 0 => 'info', + 1 => 'success', + ], + // 列宽设置,默认为均分模式,不支持百分比 + 'width': '100px', + ], +], +``` + +## when用法 + +### 使用说明 + +```php +'when' => ["field_1", ">", 1], +// or 多个条件时为"与"判断 +'when' => [ + ["field_1", ">", 1], + ["field_2", "=", 1] +] +``` + +::: warning 注意 +操作符支持`=`、`>`、`>=`、`<`、`<=`、`!=`、`in`、`not_in`,多个条件时为"与"判断,且注意参数的数据类型是否一致。 +::: + +- 可以控制行操作及批量按钮是否显示; +- 可以控制批量操作的过滤掉不满足条件的行; + +``` +'batchButtons' => [ + [ + ..... + // 控制根据条件显示,这里的条件字段来源为queryString + 'when'=> [ + [字段, 操作符, 值] + ], + // 为区别控制显示的when关键字,这里使用`selectFilter` + 'selectFilter' => [ + [字段, 操作符, 值] + ] + ] +] +``` + +- 控制列表行的某一列的编辑状态 +- 控制列渲染的popover模式下的启用状态 + +### 查看源码 + +```javascript +export function whereFilter(obj, where, fakeKey) { + if (!where) { + return true + } + let ret = true + let real_where = where + if (where[0] && typeof where[0] === 'string') { + real_where = [where] + } + for (let i = 0; i < real_where.length; i++) { + const item = real_where[i] + const key = fakeKey ? item[0].replace('.', '-') : item[0] + if (item[1] === '=') { + ret = obj[key] === item[2] + } + if (item[1] === '>') { + ret = obj[key] > item[2] + } + if (item[1] === '<') { + ret = obj[key] < item[2] + } + if (item[1] === '>=') { + ret = obj[key] >= item[2] + } + if (item[1] === '<=') { + ret = obj[key] <= item[2] + } + if (item[1] === '!=') { + ret = obj[key] !== item[2] + } + if (item[1] === 'in') { + ret = item[2].indexOf(obj[key]) !== -1 + } + if (item[1] === 'not_in') { + ret = item[2].indexOf(obj[key]) === -1 + } + if (!ret) { + return false + } + } + return ret +} +``` + +## 列渲染 + +渲染类型: + +- `number`、`switch`、`input` +- `icon`、`image`、`extrude`、`tag`、`link`、`iframe`、`html` + +### number + +**渲染条件**: `'edit' => true` 且 满足when中定义的条件 + +**用法**: + +```php +'type' => 'number', +'edit' => true`, +'when' => [ + ['status', '=', Activity::STATUS_OFF] +], +``` + +### switch + +**渲染条件**: `'edit' => true` 且 满足when中定义的条件 + +**用法**: + +```php +'type' => 'switch', +'edit' => true`, +'when' => [ + ['status', '=', Activity::STATUS_OFF] +], +``` + +### input + +**渲染条件**: `'edit' => true` 且 满足when中定义的条件 +**用法**: + +```php +'edit' => true`, +'when' => [ + ['status', '=', Activity::STATUS_OFF] +], +``` + +::: warning 注意 +目前行编辑的表单组件只支持以上三种类型,且不能定义该组件的props +::: + +### icon + +**渲染条件**: `'type' => 'icon'` + +**效果展示**: + +### image + +**渲染条件**: `'type' => 'image'`,数据为数组时可以渲染多张图片 + +**效果展示**:![u](http://km.innotechx.com/download/attachments/82253382/image2020-5-8_14-6-56.png?version=1&modificationDate=1588918016653&api=v2) + +#### extrude + +**用法**: + +```php +[ + "field" => "field_1", + "type" => 'extrude', + "render" => function($field_value, $row) { + // return "<优惠券|balck|yellow>*****"; 单个 + // or 支持多个 + return [ + "<优惠券{replace_field}|balck|yellow>*****{replace_field}" + ]; // 格式 <文字|背景色|文字色>, 支持前端变量替换 + } +] +``` + +**效果展示**:![o](http://km.innotechx.com/download/attachments/47482497/Snipaste_2020-02-20_09-19-03.png?version=1&modificationDate=1582161564093&api=v2) + +### tag + +**用法**: + +```php +[ + "field" => "field_1", + "options" => [ + 0 => '禁用', + 1 => '启用', + ], + "enum" => [ // tag 样式, 参见 https://element.eleme.io/#/zh-CN/component/tag + 0 => 'info', + 1 => 'success' + ] +] +``` + +**效果展示**:![p](http://km.innotechx.com/download/attachments/47482497/Snipaste_2020-02-20_09-27-03.png?version=1&modificationDate=1582162039284&api=v2) + +### link + +**用法**: + +```php +[ + "field" => "field_1", + "href" => "http://hyperfadmin.com/page/{id}" +] +// or +[ + "field" => "field_1", + "href" => [ + "href" => "http://hyperfadmin.com/page/{id}", + "type" => "primary", + "target" => "_blank" + ] +] +``` + +**效果展示**:![p](http://km.innotechx.com/download/attachments/47482497/Snipaste_2020-02-20_09-22-39.png?version=1&modificationDate=1582161776808&api=v2) + +### iframe + +**用法**: + +```php +[ + 'field' => 'state_info', + 'title' => '运行状态', + 'type' => 'iframe', + // 列以button形式显示,style控制按钮type, [info|success|primary|danger|warning] + 'style' => 'primary', + // 弹出窗口宽度,默认500px + 'width' => '500px', + // 弹出窗口高度,默认600px + 'height' => '600px' + "render" => function($field_value, $row) { + return "url";// 返回 iframe的src路径 + } +] +``` + +### html + +**用法**: + +```php +[ + 'field' => 'state_info', + 'title' => '运行状态', + 'type' => 'html', + "render" => function($field_value, $row) { + return '

xxxxxxx
xxxxx

'; // + } +] +``` + +### supperButton + +**用法**: + +```php +[ + 'field' => 'field', + 'type' => 'supperButton', + // supperButton的配置信息 必须有 + // 配置文档详见supperButton部分 + 'config' => { + + } +] +``` + +### popover弹出框 + +支持以上除编辑模式下的列渲染类型 + +**用法**: + +```php +[ + 'field' => 'state_info', + 'title' => '运行状态', + 'popover' => [ + 'messages' => [ + '上线时间: {state.start_time}', + '最后活跃时间: {state.last_time}', + '运行次数: {state.counter}', + ], + 'when' => ["field_1", ">", 1], + // or + 'when' => [ + ["field_1", ">", 1], + ["field_2", "=", 1] + ] + ] +] +``` + +**效果展示**:![popover弹出框](http://qupinapptest.oss-cn-beijing.aliyuncs.com/img/Snipaste_2020-02-21_18-06-06.png) + +### progress + +**用法**: + +```php +[ + 'field' => 'sync_progress', + 'title' => '同步进度', + 'type' => 'progress', + // 'props' => [] +] +``` + +> props里是progress组件的属性配置,请参考[Progress 进度条](https://element.eleme.cn/#/zh-CN/component/progress#attributes) + +**效果展示**:![Progress 进度条](http://qupinapptest.oss-cn-beijing.aliyuncs.com/img/Snipaste_2020-02-21_18-06-06.png) + + +## 搜索项 + +```php +[ + ...... + // 搜索条件, 前端页面会根据此处配置渲染搜索条件,可以像表单一样配置规则 + // 搜索条件中支持模糊搜索也很简单 %field_name%, field_name%, %field_name, 如此定义字段即可 + 'filter' => [ + 'id', + 'username%', + 'create_at|创建时间' => [ + 'type' => 'date_range', + 'search_type' => 'between', + 'default' => [date('Y-m-d', time() - DAY * 7), date('Y-m-d', time() + DAY)] + ] + ], +] +``` + +## tab切换 + +```php +'tabs' => [ + [ + 'label' => '平台券', + 'value' => (string) Coupon::TYPE_PLATFORM_MONEY_OFF, + 'icon' => 'el-icon-s-grid', + ], + [ + 'label' => '店铺券', + 'value' => (string) Coupon::TYPE_MALL_MONEY_OFF, + 'icon' => 'el-icon-s-grid', + ], +], +'columns' => [ + [ + 'field' => 'state_info', + 'title' => '运行状态', + // 定义该字段显示在哪些tab选项中 + 'depend' => [ + 'tab' => [(string)Coupon::TYPE_MALL_MONEY_OFF], + ], + ] +] +``` + +**效果展示**:![](http://qupinapptest.oss-cn-beijing.aliyuncs.com/img/Snipaste_2020-02-21_19-46-14.png) + +## 操作按钮 + +支持 列操作按钮, 批量操作按钮, 列表页顶部按钮,前端组件为`SuperButton` + +### 基础属性 + +```php +[ + "text" => "", //按钮文案,支持参数替换 + "type" => "jump", // 按钮类型 默认 jump, 可选 form, api + "target" => "", // 动作目标 本地路由, 网址, 后端api,支持参数替换 + "props" => [ // 按钮属性, 更多可见 https://element.eleme.io/#/zh-CN/component/button + "icon" => "", // 按钮图标 默认无 + "circle" => false, // 圆角 默认false + "size" => "small", // 默认 small, 可选 medium / small / mini + "type" => "info", //默认text, primary / success / warning / danger / info / text + ], + "when" => [ // 当前按钮的显示条件, 默认无 + ["field_1", "=", 1] // filter过滤的比对数据为当前行 或者 当前页面基础数据 + ] +] + +// 除以上基础属性外, 根据按钮类型, 也会有其他额外属性, 具体见下面 +``` + +### 普通跳转按钮 + +```javascript +[ + "text" => "编辑", + "target" => "/user/12" +] +// or +[ + "text" => "文档", + "target" => "http://hyperf.wiki" +] +``` + +### 动作按钮(请求后端API) + +点击按钮后, 提示二次确认, 确认后请求后端api + +```javascript +[ + "text" => "删除", + "target" => "/user/12", + "method" => "POST", // 请求api的方式, 默认POST +] +``` + +### 表单型按钮 + +点击按钮后将以弹窗形式渲染指定表单, 然后搜集后端数据请求到指定api + +```javascript +[ + "text" => "审核通过", + "target" => "/user/12", + "method" => "POST", // 请求api的方式, 默认POST + "rules" => [ // 表单的rule规则具体参见表单部分 + "reason|原因" => [ + "rule" => "required" + ] + ] +] + +// 有联动时 + +[ + "text" => "审核通过", + "target" => "/user/12", + "method" => "POST", // 请求api的方式, 默认POST + "rules" => [ + // 待补充 + ] +] + +// or + +[ + "text" => "审核通过", + "target" => "/user/form", // get 方式拉取表单配置, post 方式保存数据 + "method" => "POST", // 请求api的方式, 默认POST +] +``` + +### 列表型按钮 + +点击按钮后将以弹窗的形式渲染指定列表 + +```php +[ + 'type' => 'table', // 调用 src/components/Scaffold/tablist.vue 渲染 + 'target' => '', // target 留空 + 'props' => [ + 'listApi' => '/merchantlog/list?merchant_id={id}', // 列表数据拉取接口 + 'infoApi' => '/merchantlog/info', // 列表 配置拉取接口 + 'options' => [ // 表单的配置项 + 'showFilter' => false, + 'createAble' => false + ] + ], + 'text' => '招商记录', +] +``` + +### 抽屉型按钮 + +点击按钮后将打开抽屉, 抽屉内部指定动态调用指定组件 + +```php +[ + 'type' => 'drawer', + 'target' => '', // target 留空 + 'text' => '查看日志', + 'props' => [ + 'component' => 'SocketList', // 需动态调用的组件 src/components/Common 下 + 'componentProps' => [ // 组件的 props + 'url' => env('OMS_WEBSOCKET_URL') . '/cronlog?name={name}' + ], + // drawer** 为抽屉属性的定义 + // 详见 https://element.eleme.io/#/zh-CN/component/drawer + 'drawerWithHeader' => false, + 'drawerSize' => '80%', + 'drawerTitle' => '{title}日志', + 'drawerDirection' => 'ttb' + ] +] +``` + +### 下拉按钮 + +上面 SuperButton 的结构改为数组形式即可 + diff --git a/docs/backend/scaffold.md b/docs/backend/scaffold.md new file mode 100644 index 0000000..4ec3032 --- /dev/null +++ b/docs/backend/scaffold.md @@ -0,0 +1,408 @@ +## 路由注册 + +一个独立的业务模块需要在`config/routes/`下添加业务的路由文件,在该文件内完成业务模块所有的路由定义。可以使用`register_route`方法来定义您的路由。 + +```php + 如果完全是自定义的前端页面,建议不使用`register_route`注册路由,`register_route`内部会注册一些脚手架路由 + +**脚手架路由** + +| uri | 请求方式 | 控制器方法 | 说明 | +| :----------------------------------------------------------- | :------- | :---------------- | :---------------------------------------------- | +| `path`/list.json
`path`/info | GET | info | 下发列表页的配置 | +| `path`/form.json
`path`/form
`path`/{id:\d+}.json
`path`/{id:\d+} | GET | form、edit | 下发表单配置 | +| `path`/list | GET | list | 下发列表数据 | +| `path`/form | POST | save | 新增时数据保存接口 | +| `path`/{id:\d+} | POST | save | 编辑时数据保存接口 | +| `path`/delete | POST | delete | 删除接口 | +| `path`/batchdel | POST | batchDelete | 批量删除接口 | +| `path`/rowchange/{id:\d+} | POST | rowChange | 行编辑数据保存接口 | +| `path`/childs/{id:\d+} | GET | getTreeNodeChilds | 树结构的列表页动态获取子节点的接口 | +| `path`/newversion/{id:\d+}/{last_ver_id:\d+} | GET | newVersion | 表单编辑时或数据对象的版本信息接口 | +| `path`/export | POST | export | 导出任务接口 | +| `path`/act | GET | act | 可用于当前model提供select组件远程搜索的数据接口 | +| `path`/import | POST | import | 导入接口 | + +## 脚手架概览 + +在编写控制器时需`继承`脚手架的抽象类`AbstractController`,并在`scaffoldOptions`方法中定义页面的配置。 + +```php + '/custom/path', + // 是否允许创建, 默认 true, false怎隐藏页面列表上方的新建按钮 + 'createAble' => false, + // 是否允许删除, 默认 true + 'deleteAble' => true, + // 是否开启通知查询功能,开启后在页面路由发生变化时,会根据当前页面参数查询页面有没有通知消息 + 'noticeAble' => true, + // 是否需要分页器 默认true + 'paginationEnable' => true, + // 是否显示导出按钮, 默认true + 'exportAble' => true, + // 列表页是否默认执行查询, 默认执行查询 + 'defaultList' => false, + // 搜索条件, 前端页面会根据此处配置渲染搜索条件,可以像表单一样配置规则 + // 搜索条件中支持模糊搜索也很简单 %field_name%, field_name%, %field_name, 如此定义字段即可 + 'filter' => ['id', 'username%', 'create_at'], + // 列表的基础筛选条件, 列表的查询均会携带上此处的条件, 详情请查看where2query方法 + 'where' => [ + 'type' => User::STATUS_ON, + ], + // 筛选条件是否同步到地址栏 + "filterSyncToQuery" => false, + // 列表的排序 + 'order_by' => 'id desc', + // 表单页面的UI配置, 详参 http://form-create.com/v2/iview/global.html + 'formUI' => [ + 'form' => [ + 'lableWidth' => '300px' + ], + 'submitBtn' => [ + 'innerText' => '这是提交按钮' + ] + ], + // form表单的定义, 核心配置, 不可或缺,请求请查看表单页配置 + 'form' => [ + 'field|字段名称' => [ + // 字段验证规则 + 'rule' => 'required|max', + 'type' => 'input', + 'info' => '字段备注', + ], + ], + // 页面提示 + 'notices' => [ + [ + 'type' => 'warning', + 'message' => '提示信息', + 'actionsPlacement' => 'right', + 'closable' => true, + 'actions' => [ + [ + 'props' => [ + 'size' => 'mini', + 'type' => 'success', + ], + // 勿动!! 选品页面是监听点击事件同当前 text 比对. + 'text' => '点我更新', + 'type' => 'native', + ] + ], + 'when' => function($filters) { return true;} + ] + ], + // 第三方数据补充, 子项定义规范, 详见下方列表第三方数据补充部分 + 'hasOne' => [ + 'mt_oms.mt_oms.user_role:user_id,role_id' + ], + // 列表定义 + 'table' => [ + // tabs 列表页分页签 + 'tabs' => [], + // 定义渲染列表, 未定义则获取 form 中所有 + 'columns' => [], + // 订单行操作按钮 + 'rowActions' => [], + // 列表上方批量操作的按钮 + 'batchButtons' => [], + // 页面上方操作按钮 + 'topActions' => [], + ], + ]; + } +} +``` + + +## 内置钩子 + +```php + // 列表页下发配置接口的前置钩子 + public function beforeInfo(&$info) {} + + // 列表页执行搜索前的钩子, 可用于修改 where 条件 + public function beforeListQuery(&$conditions) {} + + // 列表数据响应前的钩子, 可用于补充额外数据 + public function beforeListResponse(&$list) {} + + // form 规则下发前的干预钩子 + public function meddleFormRule($id, &$form_rule) {} + + // form 响应前的钩子 + public function beforeFormResponse($id, &$record) {} + + // 表单保存前端钩子函数 + public function beforeSave($pk_val, &$data) {} + + // 表单保存后的钩子函数 + public function afterSave($pk_val, &$data) {} + + // 删除前的回调钩子 + public function beforeDelete($pk_val) {} + + // 删除后的回调钩子 + public function afterDelete($pk_val, $deleted) {} +``` +## 表单定义 + +```php +public function scaffoldOptions() +{ + return [ + .... + // 表单配置 + 'form' => [ + // 字段验证规则 + // 请参考 https://hyperf.wiki/#/zh-cn/validation?id=%e9%aa%8c%e8%af%81%e8%a7%84%e5%88%99 + 'rule' => 'required|max:10', + // 请参考 http://www.form-create.com/v2/element-ui/components/input.html + 'type' => 'input', + // 表单默认值 + 'default' => '', + 'info' => '字段备注', + // 只读属性,当编辑时有效 + 'readonly' => true, + // 表单选项,只有支持options选项的组件设置才有效,可以定义一个callback方法,可以参考formOptionsConvert方法 + 'options' => [], + // 其他组件属性,请参考具体组件的props的定义 + 'props' => [], + // 定义依赖项 + 'depend' => [ + 'field' => 'target_type', + 'value' => [], + ], + // col 布局规则 http://www.form-create.com/v2/element-ui/col.html + 'col' => [ + // 表单长度 + 'span' => 12, + // 标签宽度 + 'labelWidth' => 150, + ], + // 动态修改其他字段规则 详见下方联动小节 + 'compute' => [ + "will_set_field" => [ + "when" => ['=', 1], + "set" => [ + // + ] + ] + ], + // 该字段规则回调方法,可以用于重置字段规则 + 'render' => function () { + }, + // 开启input框的复制功能 + 'copy_show' => true, + ], + ]; +} +``` + +## 列表定义 + +```php +public function scaffoldOptions() +{ + return [ + .... + // 非必须项, 没有定义则从form转义 + 'columns' => [ + '字段名', // 简写模式, 直接从form配置转义 + [ + 'field' => 'mall_name', + 'title' => '店铺', + // 字段渲染规则,默认为空 + 'type' => '', + // 是否虚拟字段,虚拟字段在查询脚手架model时,会忽略该字段 + 'virtual_field' => true, + // 表头说明 + 'info' => '括号内为商家承担', + // 定义该字段显示在哪些tab选项中 + 'depend' => [ + 'tab' => [(string)Coupon::TYPE_MALL_MONEY_OFF], + ], + // 按字段升降查询功能 + 'sortable' => true, + // 是否允许编辑,调用*/rowchange/:id接口 + 'edit' => true, + // 枚举值,可以options中的数据转换成Tag显示效果,https://element.eleme.cn/#/zh-CN/component/tag + 'options' => [], + 'enum' => [ // tag 的 type 类型, 参见 element 标签 + 0 => 'info', + 1 => 'success', + ], + // 单独处理某个字段 + 'render' => function($val, $row) { return $val;} + ], + ], + ]; +} +``` + +## 按钮 + +`rowActions`, `topButtons`, `topActions`, `notices.*.actions` 的节点定义 + +1. 页面跳转 + + ```php + [ + 'type' => 'jump', + 'target' => '/crontab/{id}', // 本地路由或三方地址 + 'text' => '编辑', + 'props' => [] // element el-button 的属性 + ] + ``` + +2. 请求后端api + + ```php + [ + 'type' => 'api', + 'target' => '/resource/delete', // 支持变量替换 + 'text' => '删除', + 'method' => 'POST', // 默认POST + 'props' => [ + 'type' => 'danger', + ], + // 当前按钮可以定义依赖条件, 动态显示 + 'when' => [ + ['gid', '=', Resource::RESOURCE_ROOT_ID] + ] + ], + ``` + +3. model弹窗表单 + + ```php + // 直接定义表单规则 rules + [ + 'action' => 'module', + 'target' => '/user/test/{id}', + 'text' => '弹窗', + // rules 的定义同 form rule + 'rules' => [ + 'file|视频' => [ + 'type' => 'file', + ], + ], + ] + + // 调用其他Controller form表单 + [ + 'action' => 'module', + // 若没有rules节点则自定调用 target 接口拉取表单配置 + 'target' => '/user/form', + 'text' => '弹窗', + ] + + // 调用其他 Controller 的列表 + [ + 'type' => 'table', + 'target' => '', + 'props' => [ + 'listApi' => '/role/list?id={id}', + 'infoApi' => '/role/info', + 'options' => [ + 'showFilter' => false, + 'createAble' => false + ] + ], + 'text' => '**记录', + ] + ``` + + 按钮较多时均可调整为按钮组 + + ```php + [ + [ + $action_conf1, + $action_conf2 + ] + ] + ``` + + + +## 关联数据 + +```php +public function scaffoldOptions() +{ + return [ + .... + // 一对一关系 + 'hasOne' => [ + // 此处定义了补充的第三方数据是什么, 从哪里取 + // [pool.]db.table:[local_key->]foreign_key,other_key + 'hyperf_admin.hyperf_admin.user_role:id->user_id,role_id', // 完整定义 + 'hyperf_admin.user_role:id->user_id,role_id', // 缺省 pool + 'hyperf_admin.user_role:user_id,role_id', // 缺省 pool,local_key + 'hyperf_admin.user_role:user_id,role_id as rid', // 补充字段使用别名, 避免覆盖list中同名字段 + ], + // 一对多或多对多关系 + 'hasMany' => [ + 'hyperf_admin.hyperf_admin.operator_log:id->user_id,username' + ] + ]; +} +``` + +`[pool.]db.table:[local_key->]foreign_key,other_key` + +分别对应`连接池`(非必须, 默认dfault), `库名`, `表名`, `本地关联字段`(非必须, 默认id), `逻辑外键`, `其他要补充的字段`, 若系统为查询到第三方数据, 相应的补充字段将初始为 `null` + +## 页面提示 + +```php +public function scaffoldOptions() +{ + return [ + .... + // 页面提示信息 + 'notices' => [ + [ + 'type' => 'warning', + 'message' => '提示信息', + 'actionsPlacement' => 'right', + 'closable' => true, + 'actions' => [], // 按钮 + 'when' => function($filters) { return true;} + ] + ] + ]; +} +``` diff --git a/docs/backend/super-button.md b/docs/backend/super-button.md new file mode 100644 index 0000000..99b060e --- /dev/null +++ b/docs/backend/super-button.md @@ -0,0 +1,132 @@ +支持 列操作按钮, 批量操作按钮, 列表页顶部按钮 + +### 基础属性 + +```php +[ + "text" => "", //按钮文案 + "type" => "jump", // 按钮类型 默认 jump, 可选 form, api + "target" => "", // 动作目标 本地路由, 网址, 后端api + "props" => [ // 按钮属性, 更多可见 https://element.eleme.io/#/zh-CN/component/button + "icon" => "", // 按钮图标 默认无 + "circle" => false, // 圆角 默认false + "size" => "small", // 默认 small, 可选 medium / small / mini + "type" => "info", //默认text, primary / success / warning / danger / info / text + ], + "when" => [ // 当前按钮的显示条件, 默认无 + ["field_1", "=", 1] // filter过滤的比对数据为当前行 或者 当前页面基础数据 + ] +] + +// 除以上基础属性外, 根据按钮类型, 也会有其他额外属性, 具体见下面 +``` + +#### 普通跳转按钮 + +```javascript +[ + "text" => "编辑", + "target" => "/user/12" +] +// or +[ + "text" => "文档", + "target" => "http://hyperf.wiki" +] +``` + +#### 动作按钮(请求后端API) + +点击按钮后, 提示二次确认, 确认后请求后端api + +```javascript +[ + "text" => "删除", + "target" => "/user/12", + "method" => "POST", // 请求api的方式, 默认POST +] +``` + +#### 表单型按钮 + +点击按钮后将以弹窗形式渲染指定表单, 然后搜集后端数据请求到指定api + +```javascript +[ + "text" => "审核通过", + "target" => "/user/12", + "method" => "POST", // 请求api的方式, 默认POST + "rules" => [ // 表单的rule规则具体参见表单部分 + "reason|原因" => [ + "rule" => "required" + ] + ] +] + +// 有联动时 + +[ + "text" => "审核通过", + "target" => "/user/12", + "method" => "POST", // 请求api的方式, 默认POST + "rules" => [ + // 待补充 + ] +] + +// or + +[ + "text" => "审核通过", + "target" => "/user/form", // get 方式拉取表单配置, post 方式保存数据 + "method" => "POST", // 请求api的方式, 默认POST +] +``` + +#### 列表型按钮 + +点击按钮后将以弹窗的形式渲染指定列表 + +```php +[ + 'type' => 'table', // 调用 src/components/Scaffold/tablist.vue 渲染 + 'target' => '', // target 留空 + 'props' => [ + 'listApi' => '/merchantlog/list?merchant_id={id}', // 列表数据拉取接口 + 'infoApi' => '/merchantlog/info', // 列表 配置拉取接口 + 'options' => [ // 表单的配置项 + 'showFilter' => false, + 'createAble' => false + ] + ], + 'text' => '招商记录', +] +``` + +抽屉型按钮 + +点击按钮后将打开抽屉, 抽屉内部指定动态调用指定组件 + +```php +[ + 'type' => 'drawer', + 'target' => '', // target 留空 + 'text' => '查看日志', + 'props' => [ + 'component' => 'SocketList', // 需动态调用的组件 src/components/Common 下 + 'componentProps' => [ // 组件的 props + 'url' => env('OMS_WEBSOCKET_URL') . '/cronlog?name={name}' + ], + // drawer** 为抽屉属性的定义 + // 详见 https://element.eleme.io/#/zh-CN/component/drawer + 'drawerWithHeader' => false, + 'drawerSize' => '80%', + 'drawerTitle' => '{title}日志', + 'drawerDirection' => 'ttb' + ] +] +``` + +### SuperButtonGroup 下拉按钮 + +上面 SuperButton 的结构改为数组形式即可 \ No newline at end of file diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d1ca708a45327a00feab009724d4be2ecf7cc816 GIT binary patch literal 4286 zcmbtV2~3;k8Fv06W+9fPQChJqN?pQX2oM4Zi4d`WY;!pV%w-OT!^RghiKdR_Bw$Qf zysX=*D67({YO5Vm)J5A;WnJWUNtJ+a47PtC7=yXmMccHrsnkTk^6mW$KQX3WqUJ*n z@BO^r^?znCHo+f@#lZg=SGO=2FEbbnHUL}<5`g`&#;f|fiL9TGTzuui-T0lqzDuxv zVZ01>Z?fJ-;2O|*Ed4MUtNwZYyMFn1BKg9|rQ{37Q)$}U1WP}X2>S4@3nl^PepPQ| zC+m&lyDpfwKX0#C^~vW)5>kdtcT&%r!#z`mMp9CTO!m}4;|@Hhlp$k%>X6xxsx!sM z;&@(v+Mp#ZU1uH#?7&=U=Pk9Mf6UgJn6C^Ph%}w)jdYz!n?7L51SryVmWynijv*+V`<_Rsq;Y!T%n>o~gCHli6>{$a&h#^;&mkpS2U9Z#a+4esjX^K1)5s?m+FOY~X%c z|D3bdo}9C`^Vz3tMAjKg0@#KJ7i`-KdX71_HPjgS&M44I-vFEgnm3!K5-+R)wGm zC)G|lPvt_S#&tB(AFTsdE^s@5n?u4~j*ioI-bp(v@5HDh|AZq2`|?gX3iqC{|Z6}J=t`B)CCn{FCzTqCF@^DS80bK076Lx$4Pe-$2 z^AbSf`;5fEWq19%g^4d^U~YQ9=zJ{kI+*!g zpXmyYnY9dpz&z{50mYw*{7%EI?8e^-4<56>^`m{VG)Ta8bZ?rE$?dfL4EHaF^^?l6 zKgA~|B@}DihC{LuE#yb`kJOnz+$&8wc+@t&r|}Q0)jSX@qv*BqyaL%Lh8L5InVX0> zyk__%3GX}ZABoM-b9iO_F2wD$YS_G?9Xyr$0r(b+)ov2xLwX(@-@$yLkd&MfyV1eu{RsQ!z{bB7k-gXUVZZbSi=%N2C%lx7>R(d!QU+uXp=yb>Nlul~pF0R}O9OtXO zx)QY;uY1D~sC-v|mq!OmWYqRDh0j3SSn}mAW=SVyD3QBS7@b}{DWjm5E4aEw=;0Ib z6wVarzZ|uG{9P_WZh0gpt(SIsdrRb08*P`jQ)v}4|25jCWoZYsqpY2p1E6x5mlUYH zYU7wb8xCnv5a}DoLSqFtm<(^)F!ktnkqZUhL6zm&?$`t;> zNIt9|ZlCB8ws{q_zp}-%wNg4^EN${^IIqKPK0I{0`AC1u&3cW0q5Q$YW5~n zO8pC>7B31z>fHxCSt6;Aw(kk0Zv2L<8y6>~TezF$uR%@$s-FzeK>*@oAb#)Vu00;x}Kd7$@Ry_gc z2s{YK>jR5*^%JbfdaOfC40V^I{S9@21W9esArVg|MaO8nwbV&8)C7JksR^K`Bj{?V z4XnL!n4_`MPe^KlHyi7w3TVzq*;GB*-CR9&G13>UOT-g#P1V7xO;wZV|Dm~h`pxLt zmgCdExgB!jXItR;^%BgLDYb~PS%~&{6HM{OENkze} zz-@04O=d=NMeD7HL%FTOX?t76^oDmMI`>)a_KHw^YsK_c;MF`!OrT@?{?(8+Q|?hFPj(1OJ*FMf;oYV|7>-U9QeJG^JiJWaYI%* zqwOf4S$7vBb7Se9{28`_KYL5Tn|%ksQWnlV)BKgivx$)Zx`ID94jfeq!SplLecL<~ zSfI@qHe31N1Gl##8`ge{t`l=|04AMV}!Vf O3;)NyLg>pMp#K5&z)EER literal 0 HcmV?d00001 diff --git a/docs/frontend/chart.md b/docs/frontend/chart.md new file mode 100644 index 0000000..662be34 --- /dev/null +++ b/docs/frontend/chart.md @@ -0,0 +1 @@ +图表 \ No newline at end of file diff --git a/docs/frontend/form.md b/docs/frontend/form.md new file mode 100644 index 0000000..fddf835 --- /dev/null +++ b/docs/frontend/form.md @@ -0,0 +1 @@ +表单 \ No newline at end of file diff --git a/docs/frontend/list.md b/docs/frontend/list.md new file mode 100644 index 0000000..662be34 --- /dev/null +++ b/docs/frontend/list.md @@ -0,0 +1 @@ +图表 \ No newline at end of file diff --git a/docs/guide/desc.md b/docs/guide/desc.md new file mode 100644 index 0000000..60e5684 --- /dev/null +++ b/docs/guide/desc.md @@ -0,0 +1,37 @@ +`hyperf-admin`是前后端分离的后台管理系统, 前端基于`vue`的 `vue-admin-template`, 针对后台业务`列表`, `表单`等场景封装了大量业务组件, 后端基于`hyperf`实现, 整体思路是后端定义页面渲染规则, 前端页面渲染时首先拉取配置, 然后组件根据具体配置完成页面渲染, 方便开发者仅做少量的配置工作就能完成常见的`CRUD`工作, 同时支持自定义组件和自定义页面, 以开发更为复杂的页面. + +### 架构 + +![hyperf-admin架构](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/sJaJti.png) + +前端为`vue multiple page`多页模式, 可以按模块打包, 默认包含两个模块`default` 默认模块, `system`系统管理模块, 绝大部分业务组件在`src/components`目录, 前端文档详见 [这里](/frontend/) + +后端为`composer包`模式, 目前包含组件 + +- 基础组件 + - `composer require hyperf-admin/base-utils` hyperf-admin的基础组件包, 脚手架主要功能封装 + - `composer require hyperf-admin/validation` 参数验证包, 对规则和参数提示做了较多优化 + - `composer require hyperf-admin/alert-manager` 企微/钉钉机器人报警包 + - `composer require hyperf-admin/rule-engine` 规则引擎 + - `composer require hyperf-admin/event-bus` mq/nsq/kafka消息派发器 + - `composer require hyperf-admin/process-manager` 进程管理组件 +- 业务组件 (业务组件为包含特定业务功能的包) + - `composer require hyperf-admin/admin` 系统管理业务包 + - `composer require hyperf-admin/dev-tools` 开发者工具包, 主要是代码生成, 辅助开发 + - `composer require hyperf-admin/cron-center` 定时任务管理, 后台化管理任务 + - `composer require hyperf-admin/data-focus` 数据面板模块, 帮你快速制作数据大盘 + +后端的详细文档见[这里](/backend/) + +### 依赖 & 参考 + +- 前端 + - [Vue](https://github.com/vuejs/vue) + - [ElementUI](https://github.com/ElemeFE/element) + - [FormCreate](http://www.form-create.com/v2/guide) + - [vue-admin-template](https://github.com/PanJiaChen/vue-admin-template) + - [Vue 渲染函数 & JSX](https://cn.vuejs.org/v2/guide/render-function.html) +- 后端 + - [Hyperf](http://hyperf.wiki/) + - [Swoole](http://wiki.swoole.com) + diff --git a/docs/guide/dev_example.md b/docs/guide/dev_example.md new file mode 100644 index 0000000..6c91ff6 --- /dev/null +++ b/docs/guide/dev_example.md @@ -0,0 +1,119 @@ +我们通过一个具体案例, 来看看如何应用`hyperf-admin`快速实现 + +### 1.需求描述 + +​ 实现一个某校年级内各班级学生的各科成绩管理后台, 要求如下 + +1. 列表显示学生 年级,班级,学习,学科,成绩,时间,性别,年龄 +2. 可以按成绩 倒序/正序排列 +3. 可以批量导入/导出学生成绩 +4. 可以通过 年级,学生名称,班级 等条件筛选 +5. 最好列表可以分页签直接显示各科成绩 +6. 没有原型图 + +### 2. 数据库定义 + +这里不做太复杂的设计, 仅用一张表来完成此需求 + +```sql +CREATE TABLE `student_score` ( + `id` int(12) unsigned NOT NULL AUTO_INCREMENT, + `grade` tinyint(4) unsigned NOT NULL COMMENT '年级', + `class` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '班级', + `subject` tinyint(4) unsigned NOT NULL COMMENT '学科', + `score` int(12) unsigned NOT NULL DEFAULT '0' COMMENT '分数', + `name` varchar(10) NOT NULL DEFAULT '' COMMENT '学生名称', + `sex` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '性别, 0女生, 1难受', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +接下来在`MySql`中创建表 + +### 3.功能开发 + +1. `hyperf`中添加db信息 + + ```php + // config/autoload/databases.php + 'local' => db_complete([ + 'host' => '127.0.0.1', + 'database' => 'test', + 'username' => 'root', + 'password' => 'root' + ]) + ``` + +2. 通过`DevTools`开发者工具创建 `student_score` 相关的 `Model`, `Controller` + + ![YPdEli](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/YPdEli.png) + + 选择好相应的表后, 点击提交, 此时工具已经帮我们创建好相应的`app/Controller/StudentScoreController.php`和`app/Model/Test/StudentScore.php` + +3. 添加目录和菜单 + + ![cs0SYX](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/cs0SYX.png) + + 注册路由 + + ```php + // config/routes.php + register_route('/student_score', StudentScoreController::class); + ``` + + 此时我们也已经完成了基础的`CRUD`开发 + + ![MEoM4p](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/MEoM4p.png) + + 哦对了, 还有各种筛选条件呢? 也很简单, 在 `scaffoldOptions` 中增加 `filter`配置即可 + + ```php + public function scaffoldOptions() + { + return [ + 'filter' => [ + 'grade', 'class', 'subject', 'name%', + 'score|分数' => [ + 'type' => 'input-range', + 'select_type' => 'between' + ] + ], + ]; + } + ``` + + ![u68v1D](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/u68v1D.png) + + 还有, 大家别忘了, 需求中还要去可以按页签显示, 改怎么办呢, 这个ui可有点复杂啊, 不过在`hyperf-admin`里也同样简单 + + `scaffoldOptions` 中增加 `table.tabs`配置即可 + + ```php + public function scaffoldOptions() + { + return [ + 'table' => [ + 'tabs' => [ + [ + 'label' => '语文', + 'value' => 1, + 'icon' => 'el-icon-s-grid', + ], + [ + 'label' => '数学', + 'value' => 2, + 'icon' => 'el-icon-s-grid', + ], + ] + ], + ]; + } + ``` + + ![Ax0WWD](https://cdn.jsdelivr.net/gh/daodao97/FigureBed@master/uPic/Ax0WWD.png) + +至此我们已经完成了绝大部分的功能开发, 如果使用熟练, 我们应该能在十分钟内完成整个功能的前后端开发, 而且还支持复杂的前端效果. + +?> 当然`hyperf-admin`还支持更多复杂的功能, 快快用你明亮的眼睛去发现他吧. diff --git a/docs/guide/install.md b/docs/guide/install.md new file mode 100644 index 0000000..2dd9fe9 --- /dev/null +++ b/docs/guide/install.md @@ -0,0 +1,39 @@ +?> `hyperf-admin`目前尚未开源, 敬请期待. + +## 前端 + +```shell +# 环境依赖 +# 1. node ^v11.2.0 https://nodejs.org/zh-cn/download/ +# 2. npm ^6.4.1 +git clone https://github.com/hyperf-admin/hyperf-admin-front.git +cd hyperf-admin-front +npm i +npm run dev +``` + +!> 请根据实际情况修改`vue.config.js`中的代理 `proxy.target`地址 + +```shell +# 打包 +npm run build:prod +npm run build:test +``` + +## 后端 + +```shell +# 环境依赖 php ^7.1 composer swoole +# 下载demo项目 +git clone https://github.com/hyperf-admin/hyperf-admin-skeleton.git +cd hyperf-admin-skeleton +composer i +# 启动 +composer watch +``` + +## nginx配置 + +```nginx +# conf +``` diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..97d7fe8 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,63 @@ + + + + + hyperf-admin + + + + + + + +
+ + + + + + + + + + + diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..810be24dad52e37bab87c0b1a8a49310b7de6acf GIT binary patch literal 21908 zcmZ^KV|XS@w{~pXn%LIF_Dt}E6We-XO>BE&+qP}n6WdO{+53IZIs1FBbGonUTD|JN zSFKuItA12>hkjR(M1;eG0|5a+l$H`x`cr58RbZh0Tq`Nv7yneCj!KduAeEB@$A50> zjn$=1P(4c4_;D15>oFJfhAdr8{ARq>y`2Q_ifKvY(1NH~Q0`lhyg8ZjA zfU^EAWBif*g3Uo7{>@GLQ~z~H{V9K}{;U3O?rLmhtq-(yAYo(V0RdrUW@YAO=Hz8z zAz|V8Tlk{{%Le;zD;e3~|0Dlp&MHl7>`w)2C#B&C0s@czR{;h2m5u`f0$yycq7GD- zm*X|GwPw^evNbSfbhWnoiv_~x%KJyOHU{dGxLRA;IP$vkll_h0{UiUSW+Ef`8v?ZC zCsUXIP9kdSU`)cn$j-=2CICl5Lc-@@WWuW?Ch>3jpF4gsGa%58mx;;6#f8y@jnUS@ zl!=9hhlh!om5G&=;SYkr(ai>^@5*4~_~lzS-v7@1bxgF5l)`sLSyZQ#UPC$M# zvcEd|@8e&70?keSrzac7e-G==0Ga-3VPauqX8PY~#;)f72kozx|C9D7M*lsm|F1Jw zeY<~={tm{!eB}Gf170O#M_VhWzlH*^F$W5;^8M}Df2#jyWdCAm{0~il>%TbvRQ@MH z{eL6;Q~95SZw}_hf1>}FV*)JycIuzH|HO;hTH84oJ39V_=lD1NpSpkG{|X`R-&0vb z%-q`8=5G!Q2NxgH|Jw4OOi^1aTL)!3eM95F9Q&K{57mF7|L#Zq|Lo@cYI9$+5R6?d0QiM6E`t^ps~PT8Op%S$-u&*@?RPHFZw?)|7P=E}yJPTu2Y zz6*q~Shc?aeNQDSNAb*)rGY`Uj*sQpuVtwSyfItyP>C;`Dq_cko>Ct3R2?1OV;7U3 zULISWKZqVX+a5WHg1}#$FE7SZQ`t->(|H_CB&dh7{lM0O6-JnA!Mp%;t4nCP9b}0n zBC!*w*#+MdOq5U=Fy64jhGU-8kvw7O!l(y2gcQs9MZ&y7y0l_hi7&ZIf31_}*XC@T zVK?%`exzMy@B2`d6Z5r*OBDKKlO}lUiUaN9pmvL06@Y-kw0?N8 zCMNhPtliY+G*{Cb4Hy*7Fb)9NRG0$3WJy@|oKPhy$P0@bXA8&y!t_Sg?31|B4yy^s2}}6UGnfJRuY%0-{VJLW~^&$nCN@?SEi5pi)!}hS!_YL!Zr2>i!K{i zXg^%2yjkd6M%T-%5;s<|eax{i%Ia<~zLwv*e)=6Z}A}43v$r(xPF2XA3tbmp! zdZYxW%jP;Uxh#badIojDkK|6F{!!N49Rhu~Um~(rd9W*3LZPOpA z`eL5%Gj)jmgWj|_heln696f}I#v;Fj!m61>tAf@*G1!R{lcP@tij}nTr0CRS97T&% zA+^f-)E6k$gA}>y@7%`~%%EeuEp&~V@X4*Hwok(ln(|Rh@bwpI-rGw}WQFG9;873Y zrFsI1#I-lw7sIu_s|N73ZWkN1a!~2SpD&5I0I>ZrWHTT}m^a}uBb6c}Ow@4H5qBeG z+yzRF>Bu)M0I}hDh3d2CYe5TubNtV-^jaxbvw0PTMeZH{2xb2m>Kkeu~oa! zw{8K27{7M-NcTOGC{2n>Ya06weOC0j7@+vzwu=pT*P<4s%nfM`xW^HfjLcrltqw@% z^pcN(8Kiu@I)!Hg4H*7EANe!SzG?Y9h{1XjV^%*W(1_}teuW-);iZMquAW6;p!rJq ziXkNcxGrf`vJz_)P3p>(x!owPv_9o@#2ee#-YM4O}H;(HJ+Z)^F|@2MZMM;YLV=E*56C9oc!)|FN2^k__HUQcbSq%-OE6mNpe(w zZk-%(U9D>JQRgK_hbCJ{C@>vbG>_^nK$~JSw@lNalCutVzi$!!y@Q!WB?b{P8#IY` zO%9mz4R`~25EA_<1ofua^Pp^Vf&s{lpQ+G^nsq@F$z2mK0VN3i^B{7zCIA38k9p=K zm&|)j9Af`xAPm25{`C&7c=_Q*Ta&|3l3BQz7G`4&TJ|dLCUDdqS#8AB} zL4uK=f@mO*%T5jFcWGYH0l>G!>pLF#W%W5Bej z=E`sb>TiD8dBVdcB$?Jq+fqnhloxxbpvbk%^MM-q^kuH<;Fn4kWj7jEh?KC~93$b@vd6@8p#7 z(9FR?Zc&v3A@?;uX$4oEzi)hQ^;+c%^Tj-?X1Q6%|D*^mNlJ;!abwKSNi8Is%>I4| zdDgqNG(+4zeLtqw*gqr~H-#8>eDkfNL_-C31N^qT1;tX^s@B(arB#iFoh-#W&PP1! zzn{%5xE0Ot!~qj38@DJD5r!IVVz4mV_goEJ2)!2 zAf`fhX4mR+OYa!tT(}5D!td3`(8rSekN9TPQ%;%Bhd!!dnwNsn+;iuAd!yskyZn&1 zkzIFi_PlM7XyI@KHf?sV&=Mw>3Fq`0(_Aj@`I|Jr1d0^Fht>zhjr4%cMbVMHAGatQcS>rd4i2ry2>kI0S4?H*(Cw+rg%1*&Kn-m*^Y*n3TR*xWXYoRJMS-mt8 zb)IdvR8?Cc2=COU80!GZjlf)UT<&>OSpu$+Ae~xlbgymRL~8}nl3ml%gZGCls+(;%lX4)`zQ$N zO2+xAM*BDJOO(ke2ckb2Ol{?2Z)x&ZDN>p?Cv<}J+Q#AcC#&bH0$|IGwUu)PQWSp6 zH@6Fpav`EsqHF#-@eK&4WmboKF5ktDc1pd+^V*IQ=Kuclwz@FxwxMujjP&{;hc>7Q0F>KwfZ|ai!r}73bmQe2@7~0exf+Rb ztJoo}dWLjs13Hs+CETs1-0WvWpJRx_Zyj3Wr0b-Z#X3R(Z?C*E0wQ>oA`JC(dVC38neMj}5$4Uk z@VdSqa~fq8Lkio=S@ksC1rd>~wVD3h9>}qe)s<#^L7iT;Ibmsw=^luSCi9H*uyfWh zmg+O)l@@3BjZI4|dW=vnKLp@zx?a~z$_0KcSuHX?<3lwj&FNA{6_3v0EKK@Im*J>F z4is6Y7T0B2m>lJl{5J%%T1UyfDEV&w1ha_k+ zOrO$(>ujph_QTB1sgxNaM3w^0Q-V(Jt;!}qaZZL7*Er?cJ^9$POIlH=?4iMasR;Qw zsax4AWchS;bZVw5pp57!r*3;S=wc?hGX*fZ2X;*J1O+xWi))2@yEd;%owDp6#EoNx znY_(X^=G7DRt+U6Qx(|HSM_EsuzMp(r0QtVhWQu02HSeIU#E|?iGW{DX~1iJqUPqQ z(IV%v=)h{xp4JY2FWa!({Kj~R9P248w6xZ1xUh)B0163Ew!!7HOj6uiUt|H}6~VLk zt%8hDpi<0ZIUB?1>=Eg;NkBTGP&TA6S`P03)A5^OT%mn`AOZ5wOuz5ZaPr}-o%QlL zJXqm+UgB57C@_hFO4Jr#3(CdZ{oP=@8ol8`CEBN>p_P+WYl)kE%9rIxF*El|$;Rj@ zZ;!<`*BO!S-L3JI+s7Iw%@fkR4m7V zkm9i;%(M@_`B$?Z5K4^1QlF}6&KW0IjDYrrLwP);_an+9I{&fAWkjXuvT|JsGhCF~ zQP_R-EP8c%w`F1KpE#;g~eLZ>+cT&1D(8ttLiPaifD!nb)}4m zc{PIH8mBySwMz>P?HXI*ZoJ|Ew3T=pmqx?yyIAQRl=SKnfCPd8wxCaGw zn9+(A^<@LV@FKZuSF%{&6k6OsnSjHViZP9_Qtwq|fa7Tj*n-5jpt08x3iZztK18xc zKMth4+PvGaTofEdXAt!H5*#ql;HjK0u;UIU(n`P@ADJ97{4ySmZ}oc$?Bn*`T7=C+*n7(l08J zR!C_~Z^#F^YZ-g^UU{L+j&TU7ZY*6i662YOGLutUrFYhiU zoCLdWFG5Fj?r)+ssYv&Nc=4EDW(P7Fjd5jMv6{W;U*-`shmNm;F<M}TvC^%FV2X%G}7a;$gNHdUOgZ4Z_oT>_f%0=tMb*ENod=Z^PhdQ4*UIHx<{~~oXKC0nWFn_% zy~bRPKy0cYppNb1sr9 z>lDmGq;`0R#I8@a_)R3Q__}DM!-4M@6Bs*udUfws_wI3dUbf#$ukE-Km?kxa!~IyQ zTqC)u)HsX5gGQQF!3#Y1yblza69xT5>|Wf=?1^JI%_K5{|l%#;=rq*3?I=0CnDTIuO^bT9m|bNICh1f z#KZ{*G=lid5mdxF(U_G1eJP!(3IKg<#=7D_#h?{rqtb0g_;MMz2M}z3f^nNL^>n^Y z2|gj1t&d;dl?^}ED-vERO*iX*QN3!3fK#5F%~eFm!^dJ*ipXP%h80X#jZKOG2 zSDsMVJ2p(wY{Gl-;?KEU5j!>4ydD<)662;brnxoUsv1$AA_1k zsT+xWN^x|7}8*89)3SGSU@HWOa$`+BhW``Q7TMqq#uM~&~Jm8~#~m~G!yLYrv=;06vX+me@n z8E!%p{sSq_(2Dnqb(_C({+3ER)fe#3>{2i82Tkw1aEfhTYi7Lr5JG#}w4PmWGNls! zLU5)gKkjpLVmJ&s%tnWLESF0TvQp1CdxqrVpd%FuY@;o>&>4=#m;ru)M$Kj=g8m<4 z#IK#Vo5>{xP^=yNEYBZMSY!>QwDi#AN4zeUrO9=imy~eu8}!JL6xh#1WiV`tF#(s; z3-%iU#9G1;iheylyQPkWW2|@k1McLyecK!sKhFcL!Wg}A9B!5ZQX@Q$HH-Rgnig?e zp~87WgwseAf*7xDUUNFRq@%_>W+lx>FJnjK?M|R$J}wwN*cmHOaBLR8ULv?tn}h8W zFKRpMtJ$CgT;yVEc>u6ei7&Q%msu`DSzO+`kX?Hx9$9>k8K<0AVmN7MtKh=rhC-Z> z2n)Y4i3TS}Z>$Pylf#ao-E8#9_g4PdJ6TYBRV39Wc`)RL)c(=m8wuU2RLjLpoj7wz zl6uP*^R8Zpduot+kC8k9w1XELW>B|KGhR{EcDJD$V=WZQ;+<37SKe-iur`_!W&(NWp7@aH1s7C!?T_}-wlG@A`t8b}{2cP37 zcCALocw<%p|7 zyfj>?_?x>!rh^S4VaJP4^wg?W?C0{OE8+|Mzl?v)P0#!GvbXf zc_-3-P)L;M^;3%?r=LOKY+1Bg$OjjLP?5}2DDO`T9z5>JEh z@2TT)b|X9pr^ZT&P7b&q`l!U^G5(ld**^oSW6{$^h;AlyuvmlV47)Nisv`8d#a7L! zHkrl7Jal}%YzlmLbAxG(leffnO?5_SD#(-M6-UJB>|@o2W&$I!rkl2jjvocZD77uk zN5gunmZBxkD6P-QjS#76NV+?HzotY9RN*75{Q7?^79>l;^kw_^y{jyle_ zg6p3sP8M3#-;-#Og(z7i#8r1&S&^^S{oPXBR~EnjlMRO1+p4xhE*_HB$Cis6SkuT3 z@dZxaJ>LXT)8V~eh;^`AZhNuXcm+OIIa~0K4F^1~yh1~B5j}GpwoN#v1- zZvv#3x28N)lL-lAcuT^`@`TBeGR?XyL zWdwycwrX2Dbu@UzC$?oM+^n*kB1EThiZ&e(QqSVv|xJt6qF2+Dy^fIwc=ctxLF zxx1Hbf3lKeSyJh_W6kqwvykB=0!ZjNi5?QAQp*@_&p`nLc#osJM4{P2puJ3LiFLdL z)U^DwAwx(8B4v-0`dKtcdF#cxD!204=%9etTTh5rVTujhDTcT-9~uH?uM~X(?)nKC zVYM4l<&hH3VJQ7K5ounlmvOM1G&=e-c4Zcn8p&QGj5G!5%NQL!Wk(F>1-Z(lk!gX5 zHnl!j5}Hi0<7t8f-0%3(V@K|j523KByo8$haN%rt%_h0i7xLe2V5s?RN6jNk!;)(` z!@zNIj|yYl+Gv0324+pi>hm(tGl}jLVo0)xjbZgYu9RYgYHKF4$hZth63Hbn;(1H!r*`~c zoH?Z3Vu4&po-=)CLXU>%dA<5lPZHVvvmE$46v_-;&eTCHqOA|++NF~VU=~>VChZJ1 zxGh4ajEFqbz9<$|XiPw~`#BYY{~(!f*!yNU10hz2+$#Z*vT2xkx{wQ)gjUmVO(s^P z$_7=aCP!4Kss@x;8X^mk)=4%#L!n@eNt8y;+&Gf1osG{tuAt}Sy%QaBSZr6#yS1iy;R+gd zI2rxSe)uMgxhz(ZKbGytHb{;S>%I^%Q54&6{CcjN=w=89{66i2az1BSR*yuzMNBd7PlB@++ zXd$jRU-!3afAuy03g6h0Lea)oh2ab^t{IJvIO^$A|7xOdm~2vYRt0B$?q%6BW zJ_R}`ffX^QK;(KT_!|)m%&nDO295TuSuv90Se+f#=PDj_tHwxDyJlC=tJT2&jAG zn)p3fkYvAX+*777o`6fpd$l4m#2zJdv@?%L?|#8l8?3SwtYmhFgc5!kY?sm5XuB0v z36Ms~mLcSTK}7GLsf?&M6$&`(i;o=ElVl8pG*adGzGtWFQS*ZN%iP((bMA|x&Z&~e zK@l&^*j~1`9$gL59n5EGzRoB|H!<-72IcwX(__|)qT7QIW=!01bt&O8h=TZ@q5((L zdu7xI^Q&cLDl`n?gdAy5`6fSI4kl407jzEF+nRtk^#tD< zZ@$GleE?w`nmEJaJ9OapPq~|XcuIsQ?`Rwd!s40IQ-v5Qm2bPNbaY+O(C2EL1QH>V>5S{OJhLd&>Pi@^T zRbDkn-Lt(~N5&0EQvf*q%ve9}r=Ca8Au^^C(JF*^$YtEj*-~p28WeijLr|7BGbawh zG)T%BZ^SqLH5|%wkfWdBALI#tR~=opVn+R0qTJ#nT&*PLVjX&Dx% zPPj5JGlO1e#M>8wbJS2n3-?9*$F{i{0-654D&0~d8AHoQJ%RQ@rI6CP=V~9Np(Ud8 zYG_u8U7lM828USh0E-bqv%!QgmljRKydZ9#eGqiofjZAf;{cpTxk!XPHDFdD8|U}L zmD#6%&+v7izDEhUj3&cR1DeH>g4Cu5H+`&eoO|$6{nBlC1a|995+CYPXz*}a5{kJU z7+i#4V~ufIp`?S@%d6O8@TZfL8t<%<$e<`^!PDqZgM0P+j1JUAYy>}>c|Kf2y#(PR z6vhzgG!_Kf`@&VP&`zqH8jdmQJ{-vm=yd?vSJ)AV$u7WhBzWn7U5ymj7xVL#PFwG4 z(5^W~!?^YW?}{{?D`+{3ed%1aehG17fVkWUkd=VZ54Dbc5J(>W)2Z0zOi;d~Q{c~A zBtwH))A+1;pqsJYDfE%pyV@ZJWxf1!hdVC$okw?ybKv)#;Q9`dFUpshAt@$`T9YI1 zxLZex+Ev}|!oDI;Y;BiWf@!K%p-+*1pF6iwkz|o{uUILGxz~Ycd zt({P9s+Q;S7AZDbEfr2nwH67tGZdK9a@k3Y@zgNV?tEFQ%^PB`BK*NeLFJZkegqna zC*)&DU->G*Bo(m)SvW~uY3vx>dYRmbdiJr4zWWV^p&HEm6!c;#@m6+L3KvSA4whqIxYd(w8&0B?qna4o^L3-pm@FFzRz;nLn_E{!Fw)K;HJq`z3oFclY~;ug^>&B+8wBcoO4s%Ex0TAEjm zgN>W1-KN|Mv3YF=p;%|+6z&dFqoL5I&j*M|i;RGq57E>pVB%QpkkWl|=f$&{Ui`EB zY;zqK)5ws;=q}n~nL4hsL|Z2f0lBPAI2ghpgZ6ITEwA$X)h{I$ZC9B+nRo4yTtFMI zO*E(h0BBGP?z}k;da}KLMi>)DawXnmeE_2<|E4CiRoa6&2RhS<)>nVei;^^#0^62a z8ZYzaG~i^K4kqnTCAm;*Z795}N3&HMxl)I;?1}r(niNWgaiBbdP5e^`(AY0#-=~fV zma#9_m;BU?QmV>Y+KIGk2p`r8DLEmYDtP>PbI7z{ZBbGkeg;C|2|J9v5Kh~s8ssxz$L4yO`#{Cb zZUC5sxdN>KHRl^WvTz07?Uwg`fvlQ#oM1=oiAg!fc^So<`x8BdR6HbZ$``;DcGkkcBrwe}0VUCU)ikNa1fQlm2MuzmsN)n6(t4g)}N zowuadahr;i;Gi__5aw#}E&hB+`>PAMqz&dqwssO5aQ{0>@Fh;Y4(jTgY|cDOrS)D{ z_$?&z_srkml1r_Wt$Eeb*cgvK*YH+uwtJNey^0OKEhpd+lUtWO)>x|H93bDtq3yKr zZy?jY;8f{#hqG}Nj;7KTenFFIjVo3ZF%RA~oC%wyV&X~%_t%CJc(teaQPkaxG~Qut z1fmfAC?pdlwuMa0q4yq*rb#zNAlF?7Ek_l{y7AMzPAv!J$BzUo!=H`Z?s2j##v=fah-B^6S3~l`=f%pS9J`>)O?&!IJz(Xu*EjIK&rP7W(51SlG0z zI!{E6G$3dwv8&jg6|p2SF0rRm4oD(zN6dCp6gReNZTuh;F9R%P`~tp%hEziwFa2T%t3;D^_pbGdOmr|xF|jWt^xR<@Y#b!uX~Y-4 zl?VhwDpe{6`ZLqSt3)9<`JI9;cEe)}osTHtn?erOPdv;>iN1r4_%ES@G*wz;+CC@- zK2wwb_%c{&4STq~Clox3qlF5r2xFO+_X0C!J(r1az@bY3Ei}o@%r1VQI|h7)vf9Wz zY@EM37zB~F_$dDZG}lx*JG-Q1J=v*`@WTK$1xyNiu>m|GP`Yh$(0!*!QmsEO+HoB1 z(KHJg6)do(kB5>Ic2Z_O7#_JAf%gCeqF)ZvKg0cldD~oNh*L`87ahnKI)Gh=#KW4a zl+Y(mX3Il8LQY^~Nia}v_6M{WtOc>`ia!nsagoIKN81K?NKY3OzfH-mH6Cr3hI^=l z?JVo&qc3KyL=r?=bj75IQ@ zUFuDyvraZn5}|P_x_btz3e&*#IGO!8Bf@G^U~Ds%i|(14(8*%XRilqlG-gM-IJK1B4DdzkOz=jC6%A@xQ);;G)6rD= zv+|;Prh1$%3!8{F3ir(}bL&a3f=V}yTPMvL@KW1+3Q;tM+h47g(2D00msI*nLbnuYHHQz&Zm44}8IsLQh*+JZHK- zqlG;cM^!dlXN+Vc%fN?>ARN%Qp6E32NLMGwXuvemqu68)vNleytD=Id-{zoq|F;w_2#t8&i{v@QoqU)orD^hy)0QCxL%~ z8ed_UBr*DDbH!8pBX!4(g0Q1#0QhP1I#KE968VfZeuqzcQD0|#v^(#-g6l*7t@>tS zj)FTJQy~q9E(6yz&~SHy5-qEYmRp`>{7WDAj_aWn*dFgozCUsm5-3)tK8#$JTaJq1 z)2@H({pBF)JC#rd*4zpkx=8(C#He(4e8}3x+dkkFwm?fseP8MpBaLmY16uOU8ex2sJP33fN&pyL`hK4$ zV=Dj)68Z)i!-gm?5F}Y-b*-3+WIj~;)qF7c3)Eq69^$0!CSi!rkm>F&fG6TM)7+rD z6oQDm;8hw1NMJ$=mhQNrhQ%MFt0KV>^gP@v{%dHjXG*kZH&|EqIG zI_+TGI47IN`fM$%aTaEt6ea{O{Y5AeITd>&v!w9IVC*qTy%zlmY=LG9LDmrwdMW_psy%Yi%+k^;jsy#W;oQVW(rc1EC zo9PcU@=R(^=a7Ns#87`V&i<}LM^C<8Cbwrz1rI1#N>-(|8(1ce9QU%)2}@W z91p>6rbMYxyw;ls9Ny7MT7XMDW#ts-J z-|(L@lFxGHX!re`sBE{R|eM-N2o4D+s;tm}hvL#2j+#N%Nd`9(G_Iyf&Lg8^1?EZMuROX`` zg}?42lwQ%oiJJE7%L)dS{JAW4D7zC&jOb}V0SGnPPDpd1y!>&!gWloTNi{08e}#Zf z9CcS0INaQ5UDErBaG%iwN^VujJvpk`?EWY`Df0c1^7d8+Hk6jam>lg;PsI({pc0}b z2^GP(+OB}^p`73?UPoIC1Qd4|mm4MPxBI+tQ~JE;bwUo3@<0>&L7%=5S}zSf{PN3i-7*BZs$8UBP+*zT?`ban zf%jq%jmg0dyvzop&)=q21}hV}mt&-F6TUMPep##_-Rd;xd;7XfoJQ~k+-_+gVt{9; zVFcHAY8*n6l_2A#h37s@RqzKqk$Nt)C-x;$)IL|y!0rrGaP`{`uw`7AbubKI5y5?} zPe68a!G~_j$8COlv4P8|vXKO5dU4Noso#Z7?qa!D)&Ug5 z%5gEdK+)e>j5RH|imWLab969#)SVWx048>PC9dTMxlf+r1!;xq!yelM8479B5 zrin?Ua_agQS$Q`Q#5VrkuJ*d==EDKU*SmffDld~oeX_P%9NcZs=y%>bTfUjStk7^Z zfKa;v_oeQVm3ly+|JE!*9Mbn`droltqnezWuM}Vh1Jb&L53dxEwCi!q;f^qNO)u;3 z(3cshO@_zlbS^c!vyfDgM%U>WsadWsDo9gynv z2nme=PJ@dg(lnBK83m)!gfTwp5rB5PS7xtP1AY$Ey+WnFbaR)w@0U{QOc4V<+*Oa# z-O3TNI#3u%BVe9-BrxRZQQL5hb#F2FS(4>u;^eoo$7<*@w2pIqop z=y}=Obouj*0bCy|wqS$j)`$94m_zbs`HO1&pZ_UFF&q~r{pYgRFsfaFhe7rDs}1IZ zJW`x{y#f_!S?fh3+7zgc5KBx~+}Nx44+&yh8Y#NfSl6)R2(Z7YbFSl}wo1SUPi2vO zpBOMvT=@K1_^x^S_b+kcPaQr#?D|c}I)TX4*hwY`@DP&(8>l*3ES~MRwa56d>TY(= zpX#o1%aOUGon|X9D{IbzACqAlZE*1C4s7M%*MUmxda8X{CZ)dC0+E;2ehXHa^_zJ z*6Inuy}QIOHN}EPuMAuWyEso0vmQEtBA+HjhS#%@eCP?AH0l21OmH1sj_;X|S;3%wMd{xAta%9R5SrA8;ACyXX2g6!^%15OHYD%94>v4QZ2I|1^Qb?aJx zZMb;gQw`U)5i{ZSARF}D!`A!AulKaDaO_|)bsF|D=gLiGPanI3Adc73(G_uy8>BI!QdAL+R0?Q} znIs5f%?h5-#B>`;O|e)5blKECW?2Fu$u_>BdLa8VxvMl^T3z!Mj3{9OC(WXVPt)@63FG^s>E7LM0Y?7oV|&~Q zzQ;1l7vU@EHHjalZv4;93iIx{;92D7z*VZIL+k)uSHtT`jx~KnF+Qvq1j&u)EoG5v@N9_`xe zBD|5CtYTzFRmEeo!VE}Q(lnyOiPc2!F`-Z4=L$}>ha6n$^^18PwEe7k(G{<`iWn1_ z_0v}T&i7R){Y@g58?jFTy337<+a1>)z{u?LRU2|IzPdfVx?i!Sm$b@AI2LuI2)+Ur zKU^y$u)otv!sxCp{_Qh8Q>(@wWUjHwv}>dNV#PKQ@FZVyq2eBBMDs1YIE8D_K66YE zu>O=`Ov>#dz~)I$_n8xWn|aJ!F3NVuZ-2c;dS87qiCQr#QxVGSjm-PAd-N7s(iuu+ytsoF90o*xD5^*1bPxsrI z>&J#3_X#M%R7Ct!C{C0cnY|P_E7aWfzLa3_(c8rjS2-ARh&d8{!jWBO5569QSaBnK zq`fEZ4L5a(MmDkrI;6A7$)0b8V_{;k=d71~8T-!jFb6%cwTZVq&7Nw{{5pW2$~_2s z0WNqNwXXDn9+#v`=xg@~l9LwW{D5t%;}VnUyU0r#`5pG0;Ns z)f*X=WQAtY3l8H}(U{aEP%hFIWLyY}D<&w*9dRG{MxfrHRCei4r=e5?i{DX?p?Qwn z2nTPJM2Cb~mIyoEI~79D(oS+z)Hu%JD&JTNCOy@HRQ4;SpMdsk75Vm9>%5-iow`A& zmdYnPF;C1v8f#=k`;)I1@RX5iEg*pRN{&LUn>BRzvQ}rrWj!+pIGty(%n9}O>+Kk- zLz@y>bo;FoVpY60FXx%|r!;J@?WeS@VJoDo7S#}zvlj|QT*Weh=rL)I9)8jLo9ul0 zN^jY#mYyz6`&Zo5<)}}KE;T}MHf(p=7*%SItE)TMXohY5-Ug9FO?_SuLGC%YTuB`9 zJj`U8+&}^$tS$bJ5lEkJ{;TvDZAvE&?F1Xa5eHJ-tf`JHpBSeNA_&_(lR`2_**$x+ zsbcOz{NLyLid@c{XRaA!k$X^)2pf>z;5?lhJ9ZH_KV$6Dci&8g_ilB|C(0M#<{0QQ zubEl7pcXKy+f-4AKTR6rR^QD{l6b+K5Kg@3g@?_e`t$O`lbsPfLck)nIG{x7*2<0+ zyJpi->*flg2^URuNJd$Y@9b21Zq}l+5MI{?hJpA=Uz{Z>pBVe5ZK}@w=2MQ7-c63k z(>))RnYCrP?_u$Rh27=sPLYAdo0{^h=mms{+O*q7LbSh)Bv$92B3-z6$@~hSXS@d) zmrpmNQ29HW>m@}*kOoQ?R*nbwYIBk|?cZ?d=i5O2cDd*CMtS>DkJGN+TrW)zY%9;# z7ebD)-ksIIl|Q6~jXU=iK*g-Yh1BSj9)yE?3q~LKo`AK$|`tbM_`Uc0&_?m6Y|%TpGHJ!_0M8hChS9ss0xJ@5cJYZayE)hI`LUWK^*}oS zDG(+ex!+$tn>&}hF7Ugo`uMe))GABQyxJ7@mSJGpmVpV{rX)u$)T##;4%t-wl{4=O zki^f?*b3hxk%>ujQ6Mq#l_b`l6&3U)j3Q6H?4wcT-t<*~KiWcml($@gaV`l+XG0uw z%#w#LH1jEp4S^XXL*+;%2%V}+d`}(G&mtv#C*Fn2j*?e!TK9f}$nAQe6HNtNnJw{; zZbtL2u2G8Myt8q)0*AI(a6J$b*0YZmMJ!$@N#+f|y_+{5~l*?vv#d?%R83!9aq zji|+tx_ZplH`mdOt4EDUW$`ohDNzqa)rE)+D%V=t*g6iFri9f(hHO!fuDmh0FO$}k zsvng;=m7M1)DHyuh@R933*CedfsofI8~b@cvwe?7kazVAEjz?X9Dp-3g*rwto%wxd z`|yyDt&;TOtC9jWuF5>4HWYBkd|r9`-I2k?TyMu!zq=LV$D@&N)lBZqwv+RpPb7JP zBYj5_aw88V?{;QTYf$I*1?oXh%=w$Woi#Qb#wLnVsBxxb;it+I) z&i4ar%}JIpotE9qwGRq7?U;00Z9v+`3q=xBRRUh>2Dp-VL0kmzJ~{f@SKtTgTTQu> z1{(6`Ix^u=KPKat@jDq3u1S*j#p<_zN~+Bi9>8@XBH4taHy4rF=r&gvBI|}Zx1e%% z3L<{Khy75KIw75YI^-1VP2FkAE}J!S#L=6e<1?!uG*P{-N5A~=9@7WKn+^|jLBtCb z-eSWQU@2klw=OaNCiduu*izH7H*f-K9a{_ydl=J?Imhs_kzMnhyGZD=_qpth0F|)v zMr6TeIWt2(s185MzrNIi#DE`G^)mR0_V9t(U{|MsnBa4`R;@HlZEI^-Igd#58hmwM z-`Mc_+!5&Hc)U%8&9AlPCQH>3*oE(ag+<8!XNgNu8ZWB7IMetdem01ks%CYcB75pl z^pGIO_5T8?5?1Z!H$%DJ@oMN^!=msYwYPs2vIvWh)oE71HAG!lyly4k7C^tmzGZkBW+yc9J_WVLRMek^XrX)n{JPNh^rfvhc2f4+z!# z1E@TT+ncFqKQww{9WFc@j5E}Op4k_<)Wg$BJ5|`kx=LIukH@?gnV!g##<;Ksl8FZn zznkT;{@yo@vkc}zTA~}h1`VwlTU(Z2Q;I^yJm?cI!E1MKT#^my(e#$Rt3^D=K|Zu# zzm$#LV+tbS1x9A!)M3|e=PXHs1I{v-TJ#ZUY^R}-w*bAlK_mx#?2{kN&6rv)}@6bG%+9hN`( z%Fw5l&aW=cx{SB*&VE-|!nhV)$OvpI9zcC@&59C=U|&$7ya{vHBf}qDwZ1O%kP3HF zrdxQ|fQgXA*CEqgiG*`$j(z{?)2&eL1gYEEqnXE- znaa$Q9$iI`-lTA1FmLyg+na2VhSi}2H3?nruZ1}!|Ccnv5&EzwIG~>rmDi&KJp&#x z$HCA`0fF<(*rS;F(4$_$==IGZ4=&qsgtAzXY*MB}h%S&O@C&mvx^Kg-vk_{+Gb0~a z`9($S6b}og_G+eR#to>et86v{w~*T~Zu*mEnQsk!^!pttP*i+gbay{=&*E0BPHG>8 znFae7p9kqH%J$`dia@SxOQ-~~JUFOP)G+S^u9N+`Jay4MgWeFDw#;6bQ<2)C50*DF zOYuZ|qRGrqO!L)o!Q5ivVo?qaS--!6K}8*J4Ctf^dA2l_^I$JLm2=<*=tBVC8O339 zHWquoQLI^i9~WF%T)#O?oLW+*vpnWZ)l^%6us?+%n0mo<89MN0Vl;=p<>@#=EjkT# z^FQd71av`ejLn^XzBN?s#p6A>8xN0__|1a5d!hk|Yq5{p2|Ra|B*nEGd0RSu=U_10 zhQtvoy`2^8?8~;SOY!ci>7l5=)33Qa>-gsnehd8 z59k8cxGE=88(mmx+lIh@N)i4%qv_t+U4onG7v~DJtVWa2nf??~vK8H^frshOg^u!& zTgFAYV&0vRZqR8SfZ3oGD#ez%Sa`A@X1K>S)&EIVnHloPvOQIOuj_A|!dkg{ZL^ZF zY^v$jX!ssYK?6LVJ9yIAOMqZ7P&wFIUs(w%Ju>ov+&Oifuj=AY#&pT>vBIl_++bgC zv1R=X`_W!k1WmmhVCRzm)^_JUGi;jenCZN)@;>*TfrGIma4G_Ii@8wSCYzOzn9q-R zFgLHt$Z;R%+~2c}?vXcO8~T&5@3bwd%lpSiyixCrA&+38bprzsTC5KC^_V_2bJ^mMP{Zt`kHyrqf1?Sia3LB&lC;5%~8;!ih5wj>P<=-(o^rlsCaNf{H z`A}W#qPcF+41Xk{)>_AcVDCrK@~#GnT5+R5=oeg6^N(gtqgVI1<&doil9tz2`0wr2 z56+z*B2Xs=w5{n;Yb|V0_l&r2_4K5XD}9*#aQ7CTsAnq9r@ak%q0K5DwjiJR=$%&K z%Q7tSSgLLJt*GmXZ_VYf{HiG=Ow=3}+{ zeQp_!@xFPucWdnU^fZ{Z6A91m8Rh^3^8kM|z4}asaSPsvXA;6*M>7kBTs%N(0Kd`+ zCM~Rh5)+^;?l;<&9-J5KjoAsYO|`^k$e3+lh^MguzNG~i;l#Re>Il$ixc>%RJ8n88B+mMz4iAI1M z+1^r1e4!ecFICl*GHh-f@xN=hEc2*_8cVvR!Jfk+LcD}AJ645ZBM&S?ERD$3s-pgG z_iRuv~KC* zqvc-h?)3s+|3;viW!JbL1>6sA zJP3qv)&AGr;`u%nH@z)YE} zIxubsBJ&fak?}l#mn+^Y@yJX0FxWPusi#p_4W%pTioK2=<4U|z0QU%^%A%N=Ly9C;^lMnop%ktcikel zxEO9JM!u1VuLZQfmX>O@2IFDwS!o2%7D`Van8*(fvuOuD#CX9hMxPwTa+gv~YGoLI z?lCQaqo4HMAyeNmF1Y-N&)KF^{<;pEwxsiGKlA`|+z$n=ojnahhLf1ulFHrQXy!NR zWY`MGYf+(-dE^h2bX-apJ?s3fs|MYsaL(EVP`6yD^y&`z_$*Y)*6bO%gQQ_v>mlV{ z8Gg6TaAXjN7gJ&#a_0ulQt(=xGVrkgX;UCJHg_3qVX$v&9GiVbhF?3K3`gML0sQia zN7rUo<&#!9i8&8+=XBXcSZEyPDj^uABU;z4JkU{1i2GSK7Jbtw}y4!UH64}7*{Iy;qM7}R%rmn zJYu1x%IPQGyY@}D#h>xZ?!G3P|12o|ze0Bpr5{dmK5SJMwm!Ek_1!Hi*KT!->sYw@ zr9hQBoy{O%rn&#ywLj)mZ^CRrN6ul|rU>jC2h+y&*%9BG6I_lkS+uzic7G6TI}Z9= zR=FllU2bMkB=k@^MQ`3(^fsjW6uQ#@ZD5=PFOFE-k=^IpSzD0eZ6qGx@yb;S6rOK<}$Ywlpq!`*)hoR=W9y)%<7nL^fV zbkh%?70jn|fGha=fu3D(q|!sM;&-BZw%rK8cyQZ(AT!hWy31|uokU$Fcq~w@QXpmb zYI(g^K@v@ZYqu?!U>c#T>ZhTo`p0xUfag5atsm@DaLAOMaAAv4Y@!&pHldS#`Gl!? zOWc;~tKf0XJ-YdGNZART(gH3l+NOV;e9mh`-yeM2=40jwx5PX`;i{B$a~|z>33T(x z;I_`OZKdov$9d4}?j09g=W2m;5GIW?eZ4wjzrBZ`f6+hAp2#naSo`2AK27mP-g8^_ zZ-3u2Tb0qvVK2K;#aw0isMo?)W1r}z@yzkcmb7=zbes0EaJ5H)q>^slL*0j zQnifJ(2HJ|8BiPB%McaJmI-CGsw)ogf-Ij4NOd4U;b2>~IcQ|8PdmO zrYq6Ziy==wKqbRlf;KSul&Tri)5&hJEW`q7^X}Ld5Wr-6D=E2ZH2Uk$OY&TedgIeO z@pqdavsC3e3_KP=Y954J$17v**!o3$m}AdJ4+SWLDuehc*do)Lkueq0snitmy_0^u z^?NGrl>=7#OY zLIq!+5Zr%E%Ec4sD0)6j(vfk-1@{z3)acV#l=4DGc8m2c7>-4j-#p#1Dbgm^B{r-A;abRIh7>)_}8#h~GgyiSP7>?99dg1>8o6(l3W{i$000000NkvXX Hu0mjf$OFAS literal 0 HcmV?d00001 diff --git a/docs/ws.js b/docs/ws.js new file mode 100644 index 0000000..6fed32c --- /dev/null +++ b/docs/ws.js @@ -0,0 +1,83 @@ +/* =========================================================== + * docsify sw.js + * =========================================================== + * Copyright 2016 @huxpro + * Licensed under Apache 2.0 + * Register service worker. + * ========================================================== */ + +const RUNTIME = 'docsify' +const HOSTNAME_WHITELIST = [ + self.location.hostname, + 'fonts.gstatic.com', + 'fonts.googleapis.com', + 'cdn.jsdelivr.net' +] + +// The Util Function to hack URLs of intercepted requests +const getFixedUrl = (req) => { + var now = Date.now() + var url = new URL(req.url) + + // 1. fixed http URL + // Just keep syncing with location.protocol + // fetch(httpURL) belongs to active mixed content. + // And fetch(httpRequest) is not supported yet. + url.protocol = self.location.protocol + + // 2. add query for caching-busting. + // Github Pages served with Cache-Control: max-age=600 + // max-age on mutable content is error-prone, with SW life of bugs can even extend. + // Until cache mode of Fetch API landed, we have to workaround cache-busting with query string. + // Cache-Control-Bug: https://bugs.chromium.org/p/chromium/issues/detail?id=453190 + if (url.hostname === self.location.hostname) { + url.search += (url.search ? '&' : '?') + 'cache-bust=' + now + } + return url.href +} + +/** + * @Lifecycle Activate + * New one activated when old isnt being used. + * + * waitUntil(): activating ====> activated + */ +self.addEventListener('activate', event => { + event.waitUntil(self.clients.claim()) +}) + +/** + * @Functional Fetch + * All network requests are being intercepted here. + * + * void respondWith(Promise r) + */ +self.addEventListener('fetch', event => { + // Skip some of cross-origin requests, like those for Google Analytics. + if (HOSTNAME_WHITELIST.indexOf(new URL(event.request.url).hostname) > -1) { + // Stale-while-revalidate + // similar to HTTP's stale-while-revalidate: https://www.mnot.net/blog/2007/12/12/stale + // Upgrade from Jake's to Surma's: https://gist.github.com/surma/eb441223daaedf880801ad80006389f1 + const cached = caches.match(event.request) + const fixedUrl = getFixedUrl(event.request) + const fetched = fetch(fixedUrl, { cache: 'no-store' }) + const fetchedCopy = fetched.then(resp => resp.clone()) + + // Call respondWith() with whatever we get first. + // If the fetch fails (e.g disconnected), wait for the cache. + // If there’s nothing in cache, wait for the fetch. + // If neither yields a response, return offline pages. + event.respondWith( + Promise.race([fetched.catch(_ => cached), cached]) + .then(resp => resp || fetched) + .catch(_ => { /* eat any errors */ }) + ) + + // Update the cache with the version we fetched (only for ok status) + event.waitUntil( + Promise.all([fetchedCopy, caches.open(RUNTIME)]) + .then(([response, cache]) => response.ok && cache.put(event.request, response)) + .catch(_ => { /* eat any errors */ }) + ) + } +}) diff --git a/src/admin/.gitignore b/src/admin/.gitignore new file mode 100644 index 0000000..82cfc4e --- /dev/null +++ b/src/admin/.gitignore @@ -0,0 +1,3 @@ +.idea +composer.lock +vendor diff --git a/src/admin/composer.json b/src/admin/composer.json new file mode 100644 index 0000000..ef12db6 --- /dev/null +++ b/src/admin/composer.json @@ -0,0 +1,31 @@ +{ + "name": "rock-admin/admin", + "type": "project", + "license": "MIT", + "authors": [ + { + "name": "daodao97", + "email": "daodao97@foxmail.com" + } + ], + "require": { + "rock-admin/base-utils": "~0.0.1", + "rock-admin/validation": "~0.0.1" + }, + "autoload": { + "psr-4": { + "Rock\\Admin\\": "./src" + }, + "files": [ + "src/funcs/common.php" + ] + }, + "config": { + "sort-packages": false + }, + "extra": { + "hyperf": { + "config": "Rock\\Admin\\ConfigProvider" + } + } +} diff --git a/src/admin/src/ConfigProvider.php b/src/admin/src/ConfigProvider.php new file mode 100644 index 0000000..22d268f --- /dev/null +++ b/src/admin/src/ConfigProvider.php @@ -0,0 +1,36 @@ + [ + InstallCommand::class, + UpdateCommand::class, + ], + 'dependencies' => [], + 'listeners' => [], + 'publish' => [], + 'middlewares' => [ + 'http' => [ + CorsMiddleware::class, + AuthMiddleware::class, + PermissionMiddleware::class, + HttpLogMiddleware::class + ] + ] + ]); + } +} diff --git a/src/admin/src/Controller/AdminAbstractController.php b/src/admin/src/Controller/AdminAbstractController.php new file mode 100644 index 0000000..0dc28a2 --- /dev/null +++ b/src/admin/src/Controller/AdminAbstractController.php @@ -0,0 +1,83 @@ +auth_service = make(AuthService::class); + $this->permission_service = make(PermissionService::class); + + parent::__construct($container, $request, $response); + } + + public function getRecordHistory() + { + $history_versions = $this->getEntity()->lastVersion($this->previous_version_number); + $history_versions = array_node_append($history_versions, 'user_id', 'username', function ($uids) { + $ret = User::query()->select(['id', 'username'])->whereIn('id', $uids)->get(); + if (!$ret) { + return []; + } + $ret = $ret->toArray(); + array_change_v2k($ret, 'id'); + foreach ($ret as &$item) { + $item = $item['username']; + unset($item); + } + return $ret; + }); + return $history_versions; + } + + /** + * 新版本检查 + * + * @param int $id + * @param int $last_ver_id + * + * @return array + */ + public function newVersion(int $id, int $last_ver_id) + { + $last = $this->getEntity()->lastVersion(); + if (!$last || $last->id == $last_ver_id) { + return $this->success(['has_new_version' => false]); + } + if ($last->user_id == auth()->get('id')) { + return $this->success(['has_new_version' => false]); + } + $user = User::query()->find($last->user_id); + return $this->success([ + 'has_new_version' => true, + 'message' => sprintf("%s在%s保存了新的数据, 请刷新页面获取最新数据", $user->username, $last->create_at), + ]); + } + + public function userId() + { + return auth()->get('id'); + } +} diff --git a/src/admin/src/Controller/CommonConfigController.php b/src/admin/src/Controller/CommonConfigController.php new file mode 100644 index 0000000..9762dd7 --- /dev/null +++ b/src/admin/src/Controller/CommonConfigController.php @@ -0,0 +1,126 @@ + ['name%'], + 'form' => [ + 'id|#' => '', + 'namespace|命名空间' => [ + 'rule' => 'required', + 'type' => 'select', + 'options' => function ($field, $data) { + $namespaces = $this->getModel()->where(['name' => 'namespace'])->value('value'); + $options = []; + foreach ($namespaces as $value => $label) { + $options[] = [ + 'value' => $value, + 'label' => $label, + ]; + } + + return $options; + }, + 'default' => 'common', + ], + 'name|名称' => [ + 'rule' => 'required|unique:hyperf_admin.common_config,name', + 'readonly' => true, + ], + 'title|可读名称' => [ + 'rule' => 'required', + ], + 'rules|规则' => [ + 'type' => 'json', + 'rule' => 'json', + 'depend' => [ + 'field' => 'is_need_form', + 'value' => CommonConfig::NEED_FORM_YES, + ], + ], + 'remark|备注' => 'max:100', + 'is_need_form|是否使用表单' => [ + 'rule' => 'integer', + 'type' => 'radio', + 'options' => CommonConfig::$need_form, + 'default' => CommonConfig::NEED_FORM_YES, + ], + 'value|配置值' => [ + 'type' => 'json', + 'rule' => 'json', + 'depend' => [ + 'field' => 'is_need_form', + 'value' => CommonConfig::NEED_FORM_NO, + ], + ], + ], + 'table' => [ + 'columns' => [ + 'id', + [ + 'field' => 'namespace', + 'enum' => [ + 'default' => '通用', + ], + ], + 'name', + 'title', + [ + 'field' => 'is_need_form', + 'hidden' => true, + ], + ], + 'rowActions' => [ + ['action' => '/cconf/{id}', 'text' => '编辑'], + [ + 'action' => '/cconf/cconf_{name}', + 'text' => '表单', + 'when' => [ + ['is_need_form', '=', CommonConfig::NEED_FORM_YES], + ], + ], + ], + ], + ]; + } + + public function detail($key) + { + $conf = CommonConfigService::getConfigByName($key); + if (!$conf || !$conf['rules']) { + return $this->fail(ErrorCode::CODE_ERR_PARAM, '通用配置未找到 ' . $key); + } + $rules = $this->formOptionsConvert($conf['rules'], true, false, false, $conf['value']); + $compute_map = $this->formComputeConfig($rules); + + return $this->success([ + 'form' => $rules, + 'compute_map' => (object)$compute_map, + ]); + } + + public function saveDetail($key) + { + $conf = CommonConfig::query()->where(['name' => $key])->select([ + 'id', + 'rules', + ])->first(); + if (!$conf) { + return $this->fail(ErrorCode::CODE_ERR_PARAM, '参数错误'); + } + + $saved = $conf->fill(['value' => $this->request->all()])->save(); + + return $saved ? $this->success() : $this->fail(ErrorCode::CODE_ERR_SYSTEM); + } +} diff --git a/src/admin/src/Controller/LogController.php b/src/admin/src/Controller/LogController.php new file mode 100644 index 0000000..709fed1 --- /dev/null +++ b/src/admin/src/Controller/LogController.php @@ -0,0 +1,147 @@ + true, + 'createAble' => false, + 'order_by' => 'id desc', + 'filter' => [ + 'page_url', + 'page_name', + 'action', + 'operator_id', + 'relation_ids', + 'client_ip', + 'detail_json', + 'create_at', + ], + 'formUI' => [ + 'form' => [ + 'size' => 'mini', + ], + ], + 'form' => [ + 'id|#' => 'required|int', + 'page_url|页面URL' => 'required|string', + 'page_name|页面名称' => 'required|string', + 'action|动作' => 'required|string', + 'relation_ids|处理ID' => 'required|string', + 'client_ip|客户端IP' => 'required|string', + 'operator_id|操作人' => [ + 'rule' => 'required|int', + 'type' => 'select', + 'props' => [ + 'allowCreate' => true, + 'filterable' => true, + 'remote' => true, + 'selectApi' => '/search/user', + ], + ], + 'detail_json|其他内容' => 'string', + 'create_at|记录时间' => [ + 'type' => 'date_range', + ], + ], + 'table' => [ + 'columns' => [ + [ + 'field' => 'operator_id', + 'hidden' => true, + ], + ['field' => 'id', 'title' => 'ID', 'hidden' => true], + [ + 'field' => 'create_at', + 'title' => '记录时间', + 'width' => '150px', + ], + [ + 'field' => 'nickname', + 'title' => '操作人', + ], + [ + 'field' => 'page_url', + 'title' => '页面URL', + 'width' => '150px', + ], + [ + 'field' => 'page_name', + 'title' => '页面名称', + 'width' => '220px', + ], + 'action', + [ + 'field' => 'relation_ids', + 'title' => '处理ID', + 'width' => '150px', + ], + [ + 'field' => 'detail_json', + 'hidden' => true, + ], + [ + 'field' => 'description', + 'title' => '描述', + 'virtual_field' => true, + 'width' => '480px', + 'render' => function ($field, $row) { + if (!is_array($row['detail_json'])) { + $row['detail_json'] = json_decode($row['detail_json'], true); + } + + return $row['detail_json']['description'] ?? ''; + }, + ], + [ + 'field' => 'client_ip', + 'title' => '客户端IP', + 'width' => '100px', + ], + [ + 'field' => 'remark', + 'title' => '备注', + 'virtual_field' => true, + 'width' => '200px', + 'render' => function ($field, $row) { + if (!is_array($row['detail_json'])) { + $row['detail_json'] = json_decode($row['detail_json'], true); + } + + return $row['detail_json']['remark'] ?? ''; + }, + ], + ], + ], + ]; + } + + public function beforeListQuery(&$filters) + { + foreach ($filters as $field => $filter) { + if (in_array($field, [ + 'page_url', + 'page_name', + 'action', + 'detail_json', + 'relation_ids', + 'client_ip', + ])) { + $filters[$field] = ['like' => "%$filter%"]; + } + } + if (!empty($filters['create_at']['between'])) { + $filters['create_at']['between'][0] = Carbon::parse($filters['create_at']['between'][0])->toDateTimeString(); + $filters['create_at']['between'][1] = Carbon::parse($filters['create_at']['between'][1] . ' +1 day last second') + ->toDateTimeString(); + } + unset($filters); + } +} diff --git a/src/admin/src/Controller/MenuController.php b/src/admin/src/Controller/MenuController.php new file mode 100644 index 0000000..88af348 --- /dev/null +++ b/src/admin/src/Controller/MenuController.php @@ -0,0 +1,572 @@ + false, + 'createAble' => false, + 'where' => [ + 'pid' => 0, + ], + 'formUI' => [ + 'form' => [ + 'size' => 'mini', + ], + ], + 'form' => [ + 'id|#' => 'int', + 'module|#' => [ + 'type' => 'hidden', + 'render' => function ($field, &$data) { + $data['value'] = request()->input('module', $data['value'] ?? 'system'); + }, + ], + 'type|菜单类型' => [ + 'type' => 'radio', + 'default' => 1, + 'options' => ['目录', '菜单', '权限'], + 'compute' => [ + [ + 'when' => ['in', [0, 2]], + 'set' => [ + 'is_scaffold' => [ + 'type' => 'hidden', + ], + 'other_menu' => [ + 'type' => 'hidden', + ], + 'path' => [ + 'type' => 'hidden', + ], + 'page_type' => [ + 'type' => 'hidden', + ], + ], + ], + [ + 'when' => ['=', 1], + 'set' => [ + 'path' => [ + 'rule' => 'required', + ], + 'label' => [ + 'title' => '菜单标题', + 'col' => [ + 'span' => 12, + ], + ], + ], + ], + [ + 'when' => ['=', 2], + 'set' => [ + 'icon' => [ + 'type' => 'hidden', + ], + 'is_menu' => [ + 'type' => 'hidden', + ], + 'label' => [ + 'title' => '权限名称', + 'col' => [ + 'span' => 24, + ], + ], + ], + ], + [ + 'when' => ['=', 0], + 'set' => [ + 'permission' => [ + 'type' => 'hidden', + ], + 'label' => [ + 'title' => '菜单标题', + 'col' => [ + 'span' => 12, + ], + ], + ], + ], + ], + ], + 'icon|菜单图标' => [ + 'type' => 'icon-select', + 'options' => [ + 'example' => 'example', + ], + 'col' => [ + 'span' => 12, + ], + ], + 'sort|菜单排序' => [ + 'type' => 'number', + 'default' => 99, + 'col' => [ + 'span' => 12, + ], + ], + 'label|菜单标题' => [ + 'rule' => 'required|string|max:10', + 'col' => [ + 'span' => 12, + ], + ], + 'path|路由地址' => [ + 'rule' => 'string|max:100', + 'default' => '', + 'col' => [ + 'span' => 12, + ], + ], + 'is_menu|菜单可见' => [ + 'type' => 'radio', + 'options' => [ + 0 => '否', + 1 => '是', + ], + 'default' => 1, + 'col' => [ + 'span' => 12, + ], + ], + 'is_scaffold|渲染方式' => [ + 'type' => 'radio', + 'options' => [ + 1 => '脚手架', + 0 => '自定义', + ], + 'default' => 1, + 'col' => [ + 'span' => 12, + ], + 'compute' => [ + 'when' => ['=', 0], + 'set' => [ + 'view' => [ + 'rule' => 'required', + ], + ], + ], + ], + 'view|组件路径' => [ + 'rule' => 'string|max:50', + 'default' => '', + 'depend' => [ + 'field' => 'is_scaffold', + 'value' => 0, + ], + ], + 'scaffold_action|预置权限' => [ + 'type' => 'checkbox', + 'virtual_field' => true, + 'options' => function ($field, $data) { + $scaffold_permissions = config('scaffold_permissions'); + $options = []; + foreach ($scaffold_permissions as $key => $permission) { + $options[] = [ + 'value' => $key, + 'label' => $permission['label'], + ]; + } + + return $options; + }, + 'info' => '新增和编辑会创建/form或/:id的前端路由', + 'depend' => [ + 'field' => 'is_scaffold', + 'value' => 1, + ], + ], + 'permission|权限标识' => [ + 'type' => 'select', + 'default' => [], + 'options' => function ($field, $data) { + return $this->permission_service->getSystemRouteOptions(); + }, + 'props' => [ + 'multiple' => true, + ], + ], + 'pid|上级类目' => [ + 'rule' => 'array', + 'type' => 'cascader', + 'default' => [], + 'options' => function ($field, $data) { + $module = request()->input('module', $data['module'] ?? 'system'); + + return (new Menu())->tree([ + 'module' => $module, + 'type' => [0, 1], + ]); + }, + 'props' => [ + 'style' => 'width: 100%;', + 'clearable' => true, + 'props' => [ + 'checkStrictly' => true, + ], + ], + ], + 'roles|分配角色' => [ + 'rule' => 'array', + 'type' => 'cascader', + 'virtual_field' => true, + 'props' => [ + 'style' => 'width: 100%;', + 'props' => [ + 'multiple' => true, + 'leaf' => 'leaf', + 'emitPath' => false, + 'checkStrictly' => true, + ], + ], + 'render' => function ($field, &$data) { + $id = (int)$this->request->route('id', 0); + $data['value'] = $this->permission_service->getMenuRoleIds($id); + $data['options'] = $this->permission_service->getRoleTree(); + }, + ], + ], + 'table' => [ + 'is_tree' => true, + 'tabs' => function() { + $conf = \HyperfAdmin\Admin\Service\CommonConfig::getValByName('website_config'); + $system_module = $conf['system_module'] ?? []; + return array_map(function ($item) { + return [ + 'label' => $item['label'], + 'value' => $item['name'], + 'icon' => $item['icon'] + ]; + }, $system_module); + }, + 'rowActions' => [ + [ + 'text' => '编辑', + 'type' => 'form', + 'target' => '/menu/{id}', + 'formUi' => [ + 'form' => [ + 'labelWidth' => '80px', + 'size' => 'mini', + ], + ], + 'props' => [ + 'type' => 'primary', + ], + ], + [ + 'text' => '加子菜单', + 'type' => 'form', + 'target' => '/menu/form?pid[]={id}&module={tab_id}', + 'formUi' => [ + 'form' => [ + 'labelWidth' => '80px', + 'size' => 'mini', + ], + ], + 'props' => [ + 'type' => 'success', + ], + ], + [ + 'text' => '删除', + 'type' => 'api', + 'target' => '/menu/delete', + 'props' => [ + 'type' => 'danger', + ], + ], + ], + 'topActions' => [ + [ + 'text' => '清除权限缓存', + 'type' => 'api', + 'target' => '/menu/permission/clear', + 'props' => [ + 'icon' => 'el-icon-delete', + 'type' => 'warning', + ], + ], + [ + 'text' => '公共资源', + 'type' => 'jump', + 'target' => '/cconf/cconf_permissions', + 'props' => [ + 'icon' => 'el-icon-setting', + 'type' => 'primary', + ], + ], + [ + 'text' => '新建', + 'type' => 'form', + 'target' => '/menu/form?module={tab_id}', + 'formUi' => [ + 'form' => [ + 'labelWidth' => '80px', + 'size' => 'mini', + ], + ], + 'props' => [ + 'icon' => 'el-icon-plus', + 'type' => 'success', + ], + ], + ], + 'columns' => [ + ['field' => 'id', 'hidden' => true], + ['field' => 'pid', 'hidden' => true], + [ + 'field' => 'label', + 'width' => '250px', + ], + [ + 'field' => 'is_menu', + 'enum' => [ + 0 => 'info', + 1 => 'success', + ], + 'width' => '80px;', + ], + [ + 'field' => 'icon', + 'type' => 'icon', + 'width' => '80px;', + ], + 'path', + 'permission', + [ + 'field' => 'sort', + 'edit' => true, + 'width' => '170px;', + ], + ], + ], + 'order_by' => 'sort desc', + ]; + } + + protected function beforeFormResponse($id, &$record) + { + if (in_array($record['type'], [ + 1, + 2, + ]) + && !empty($record['permission'])) { + $record['permission'] = array_map(function ($item) use ($record) { + if (!Str::contains($item, '::')) { + $http_method = FrontRoutes::$http_methods[$record['http_method']]; + + return "{$http_method}::{$item}"; + } + + return $item; + }, array_filter(explode(',', $record['permission']))); + } + $scaffold_action = json_decode($record['scaffold_action'], true); + $record['scaffold_action'] = $scaffold_action ? array_keys($scaffold_action) : []; + $record['pid'] = (new Menu())->getPathNodeIds($id); + } + + protected function beforeSave($id, &$data) + { + if ($data['type'] == 1) { + if ($data['path'] == '#' || $data['path'] == '') { + $this->exception('菜单路由地址不能为空或"#"', ErrorCode::CODE_ERR_PARAM); + } + $paths = array_filter(explode('/', $data['path'])); + if (count($paths) > 5) { + $this->exception('路由地址层级过深>5,请设置精简一些', ErrorCode::CODE_ERR_PARAM); + } + } else { + $data['path'] = '#'; + } + $data['is_menu'] = $data['type'] == 2 ? 0 : $data['is_menu']; + $data['permission'] = implode(',', $data['permission'] ?? []); + $pid = array_pop($data['pid']); + if ($pid == $id) { + $pid = array_pop($data['pid']); + } + $data['pid'] = (int)$pid; + if ($data['type'] > 1) { + $parent_info = $this->getModel()->find($data['pid']); + if (!$parent_info || $parent_info['type'] != 1) { + $this->exception('菜单类型为权限时请选择一个上级类目', ErrorCode::CODE_ERR_PARAM); + } + } + $data['status'] = YES; + } + + protected function afterSave($pk_val, $data, $entity) + { + // 更新预置的脚手架权限 + $scaffold_action = json_decode($entity->scaffold_action, true); + $action_keys = $scaffold_action ? array_keys($scaffold_action) : []; + $need_del_ids = $scaffold_action ? array_values($scaffold_action) : []; + $router_ids = []; + if (!empty($data['scaffold_action'])) { + $need_del_ids = collect($scaffold_action)->except($data['scaffold_action'])->values()->toArray(); + $scaffold_action = collect($scaffold_action)->only($data['scaffold_action'])->toArray(); + $paths = array_filter(explode('/', $data['path'])); + array_pop($paths); + $prefix = implode('/', $paths); + foreach ($data['scaffold_action'] as $k => $action) { + if (in_array($action, $action_keys)) { + continue; + } + $action_conf = config("scaffold_permissions.{$action}"); + $menu = [ + 'pid' => $pk_val, + 'label' => $action_conf['label'], + 'path' => !empty($action_conf['path']) ? "/{$prefix}" . $action_conf['path'] : '', + 'permission' => str_replace('/*/', "/{$prefix}/", $action_conf['permission']), + 'is_scaffold' => $action_conf['type'] == 1 ? 1 : 0, + 'module' => $data['module'], + 'type' => $action_conf['type'], + 'sort' => 99 - $k, + 'status' => 1, + ]; + $model = make(FrontRoutes::class); + $model->fill($menu)->save(); + $scaffold_action[$action] = $model->id; + $router_ids[] = $model->id; + } + } else { + $scaffold_action = ''; + } + $entity->scaffold_action = json_encode($scaffold_action); + // todo entity + //$entity->save(); + // 删除路由 + if (!empty($need_del_ids)) { + $this->getModel()->destroy($need_del_ids); + make(RoleMenu::class)->where2query(['router_id' => $need_del_ids])->delete(); + } + // 分配角色 + if (!empty($data['roles'])) { + $role_menus = []; + $router_ids[] = $pk_val; + foreach ($data['roles'] as $role_id) { + foreach ($router_ids as $router_id) { + $role_menus[] = [ + 'router_id' => $router_id, + 'role_id' => $role_id, + ]; + } + } + make(RoleMenu::class)->insertOnDuplicateKey($role_menus); + } else { + // 删除当前菜单已分配的角色 + make(RoleMenu::class)->where2query(['router_id' => $pk_val])->delete(); + } + // 清除缓存 + $this->permission_service->getPermissionCacheKey(0, true); + } + + protected function afterDelete($pk_val, $deleted) + { + if ($deleted) { + // 删除子菜单 + $sub_ids = $this->getModel()->where2query(['pid' => $pk_val])->select(['id'])->get()->toArray(); + if ($sub_ids) { + $sub_ids = array_column($sub_ids, 'id'); + $this->afterDelete($sub_ids, $deleted); + } + if (is_array($pk_val)) { + make(FrontRoutes::class)->where2query(['id' => $pk_val])->delete(); + } + make(RoleMenu::class)->where2query(['router_id' => $pk_val])->delete(); + } + } + + public function clearPermissionCache() + { + $this->permission_service->getPermissionCacheKey(0, true); + + return $this->success(); + } + + public function getOpenApis() + { + $field = $this->request->input('field', 'open_api'); + $conf = CommonConfig::query()->where([ + 'namespace' => 'system', + 'name' => 'permissions', + ])->value('value')[$field] ?? []; + $router = container(DispatcherFactory::class)->getRouter('http'); + $data = $router->getData(); + $options = []; + foreach ($data as $routes_data) { + foreach ($routes_data as $http_method => $routes) { + $route_list = []; + if (isset($routes[0]['routeMap'])) { + foreach ($routes as $map) { + array_push($route_list, ...$map['routeMap']); + } + } else { + $route_list = $routes; + } + foreach ($route_list as $route => $v) { + $route = is_string($route) ? rtrim($route) : rtrim($v[0]->route); + $route_key = "$http_method::{$route}"; + if (in_array($route_key, $conf)) { + continue; + } + // 过滤掉脚手架页面配置方法 + $callback = is_array($v) ? ($v[0]->callback) : $v->callback; + if (!is_array($callback)) { + continue; + } + [$controller, $action] = $callback; + if (empty($action) || in_array($action, $this->permission_service->scaffold_actions)) { + continue; + } + $options[] = [ + 'id' => $route_key, + 'controller' => $controller, + 'action' => $action, + 'http_method' => $http_method, + ]; + } + } + } + $right_options = []; + foreach ($conf as $route) { + [$http_method, $uri] = explode("::", $route, 2); + $dispatcher = container(DispatcherFactory::class)->getDispatcher('http'); + $route_info = $dispatcher->dispatch($http_method, $uri); + if (!empty($route_info[1]->callback[0])) { + $right_options[] = [ + 'id' => $route, + 'controller' => $route_info[1]->callback[0], + 'action' => $route_info[1]->callback[1], + 'http_method' => $http_method, + ]; + } + } + + return $this->success([ + 'left' => $options, + 'right' => $right_options, + ]); + } + + public function beforeListQuery(&$where) + { + $where['module'] = $this->request->input('tab_id', 'default'); + } +} diff --git a/src/admin/src/Controller/RoleController.php b/src/admin/src/Controller/RoleController.php new file mode 100644 index 0000000..b27db1b --- /dev/null +++ b/src/admin/src/Controller/RoleController.php @@ -0,0 +1,198 @@ + true, + 'deleteAble' => true, + 'importAble' => true, + 'filter' => ['name'], + 'where' => [ + 'pid' => 0, + ], + 'form' => [ + 'id' => 'int', + 'name|名称' => [ + 'rule' => 'required|max:20', + 'type' => 'input', + 'props' => [ + 'size' => 'small', + 'maxlength' => 20, + ], + ], + 'pid|上级角色' => [ + 'rule' => 'int', + 'type' => 'select', + 'info' => '没有上级角色则为一级角色', + 'default' => 0, + 'props' => [ + 'multipleLimit' => 1, + ], + 'options' => function ($field, &$data) { + $options = $this->permission_service->getAllRoleList(['pid' => 0], [ + 'id as value', + 'name as label', + ]); + array_unshift($options, ['value' => 0, 'label' => '无']); + + return $options; + }, + ], + 'sort|排序' => [ + 'rule' => 'int', + 'type' => 'number', + 'default' => 0, + ], + 'permissions|权限设置' => [ + 'rule' => 'Array', + 'type' => 'el-cascader-panel', + 'virtual_field' => true, + 'props' => [ + 'style' => 'height:500px;', + 'props' => [ + 'multiple' => true, + 'leaf' => 'leaf', + 'checkStrictly' => false, + ], + ], + 'render' => function ($field, &$data) { + $id = (int)$this->request->route('id', 0); + [ + $data['value'], + $data['props']['options'], + ] = $this->permission_service->getPermissionOptions($id); + }, + ], + 'user_ids|授权用户' => [ + 'type' => 'select', + 'props' => [ + 'multiple' => true, + 'selectApi' => '/user/act', + 'remote' => true, + ], + 'virtual_field' => true, + 'render' => function ($field, &$data) { + $id = (int)$this->request->route('id', 0); + $data['value'] = $this->permission_service->getRoleUserIds($id); + if (!empty($data['value'])) { + $data['options'] = select_options($data['props']['selectApi'], $data['value']); + } + }, + ], + ], + 'table' => [ + 'columns' => [ + ['field' => 'id', 'hidden' => true], + ['field' => 'pid', 'hidden' => true], + 'name', + [ + 'field' => 'sort', + 'edit' => true, + ], + ], + 'rowActions' => [ + [ + 'action' => '/role/{id}', + 'text' => '编辑', + ], + [ + 'action' => 'api', + 'api' => '/role/delete', + 'text' => '删除', + 'type' => 'danger', + ], + ], + ], + 'order_by' => 'pid asc, sort desc', + ]; + } + + protected function beforeListResponse(&$list) + { + $ids = array_column($list, 'id'); + $children = $this->getModel()->where2query(['pid' => $ids])->get()->toArray(); + $list = array_merge($list, $children); + $list = generate_tree($list); + } + + protected function beforeSave($pk_val, &$data) + { + $data['permissions'] = $data['permissions'] ? json_encode($data['permissions']) : ''; + unset($data); + } + + protected function afterSave($pk_val, $data) + { + $data['permissions'] = json_decode($data['permissions'], true); + if (empty($data['permissions'])) { + return true; + } + // 1、删除角色拥有的菜单 + $id = $data['id'] ?? 0; + if ((int)$id == $pk_val) { + // 删除角色关联的菜单 + RoleMenu::where('role_id', $pk_val)->delete(); + // 删除角色关联的用户 + $user_ids = $this->permission_service->getRoleUserIds($pk_val); + if (!empty($user_ids)) { + make(UserRole::class)->where2query([ + 'role_id' => $pk_val, + 'user_id' => array_values(array_diff($user_ids, $data['user_ids'] ?? [])), + ])->delete(); + } + // 清除缓存 + $this->permission_service->getPermissionCacheKey(0, true); + } + $menu_ids = []; + foreach ($data['permissions'] as $permissions) { + unset($permissions[0]); + $menu_ids = array_merge($menu_ids, $permissions); + } + // 2、保存角色新分配的菜单 + $role_menus = []; + $menu_ids = array_unique($menu_ids); + foreach ($menu_ids as $menu_id) { + $role_menus[] = [ + 'role_id' => $pk_val, + 'router_id' => (int)$menu_id, + 'create_at' => date('Y-m-d H:i:s'), + 'update_at' => date('Y-m-d H:i:s'), + ]; + } + if (!empty($role_menus)) { + RoleMenu::insertOnDuplicateKey($role_menus, [ + 'role_id', + 'router_id', + ]); + } + // 3、保存角色关联的用户 + if (!empty($data['user_ids'])) { + $user_role_ids = []; + foreach ($data['user_ids'] as $user_id) { + $user_role_ids[] = [ + 'role_id' => $pk_val, + 'user_id' => (int)$user_id, + 'create_at' => date('Y-m-d H:i:s'), + 'update_at' => date('Y-m-d H:i:s'), + ]; + } + if (!empty($user_role_ids)) { + UserRole::insertOnDuplicateKey($user_role_ids, [ + 'role_id', + 'user_id', + ]); + } + } + + return true; + } +} diff --git a/src/admin/src/Controller/SystemController.php b/src/admin/src/Controller/SystemController.php new file mode 100644 index 0000000..6f9f504 --- /dev/null +++ b/src/admin/src/Controller/SystemController.php @@ -0,0 +1,29 @@ +success([ + 'state' => $swoole_server->stats(), + ]); + } + + public function config() + { + $config = CommonConfig::getValue('system', 'website_config', [ + 'open_export' => false, + 'navbar_notice' => '', + ]); + + return $this->success($config); + } +} diff --git a/src/admin/src/Controller/UploadController.php b/src/admin/src/Controller/UploadController.php new file mode 100644 index 0000000..74b2eda --- /dev/null +++ b/src/admin/src/Controller/UploadController.php @@ -0,0 +1,58 @@ +request->file('file'); + if(!$file->isValid()) { + return $this->fail(ErrorCode::CODE_ERR_PARAM); + } + $tmp_file = $file->toArray()['tmp_file']; + $md5_filename = md5_file($tmp_file); + $path = '1/' . date('Ym') . '/' . $md5_filename . '.' . $file->getExtension(); + $bucket = $this->request->input('bucket', 'aliyuncs'); + $private = $this->request->input('private', false); + try { + $uploaded = move_local_file_to_oss($tmp_file, $path, $private, $bucket); + if($uploaded === false) { + return $this->fail(ErrorCode::CODE_ERR_SERVER, '上传失败'); + } + [$width, $height] = getimagesize($tmp_file); + $info = [ + 'path' => $uploaded['path'], + 'url' => $uploaded['file_path'], + 'key' => 'file', + 'size' => $file->toArray()['size'], + 'width' => $width, + 'height' => $height, + ]; + + return $this->success($info); + } catch (OssException $e) { + Log::get('upload')->error($e->getMessage()); + + return $this->fail(ErrorCode::CODE_ERR_SERVER, $e->getMessage()); + } + } + + public function privateFileUrl() + { + $oss_path = $this->request->input('key'); + if(!$oss_path) { + return $this->fail(ErrorCode::CODE_ERR_PARAM); + } + $private_url = oss_private_url($oss_path); + if(!$private_url) { + return $this->fail(ErrorCode::CODE_ERR_SYSTEM); + } + + return $this->response->redirect($private_url); + } +} diff --git a/src/admin/src/Controller/UserController.php b/src/admin/src/Controller/UserController.php new file mode 100644 index 0000000..4357cb1 --- /dev/null +++ b/src/admin/src/Controller/UserController.php @@ -0,0 +1,325 @@ + true, + 'deleteAble' => true, + 'defaultList' => true, + 'filter' => ['realname%', 'username%', 'create_at'], + 'form' => [ + 'id' => 'int', + 'username|登录账号' => [ + 'rule' => 'required', + 'readonly' => true, + ], + 'avatar|头像' => [ + 'type' => 'image', + 'rule' => 'string', + ], + 'realname|昵称' => '', + 'mobile|手机' => '', + 'email|邮箱' => 'email', + 'sign|签名' => '', + 'pwd|密码' => [ + 'virtual_field' => true, + 'default' => '', + 'props' => [ + 'size' => 'small', + 'maxlength' => 20, + ], + 'info' => '若设置, 将会更新用户密码' + ], + 'status|状态' => [ + 'rule' => 'required', + 'type' => 'radio', + 'options' => User::$status, + 'default' => 0, + ], + 'is_admin|类型' => [ + 'rule' => 'int', + 'type' => 'radio', + 'options' => [ + NO => '普通管理员', + YES => '超级管理员', + ], + 'info' => '普通管理员需要分配角色才能访问角色对应的资源;超级管理员可以访问全部资源', + 'default' => NO, + 'render' => function ($field, &$rule) { + if ($this->auth_service->isSupperAdmin()) { + $rule['type'] = 'hidden'; + } + }, + ], + 'role_ids|角色' => [ + 'rule' => 'array', + 'type' => 'el-cascader-panel', + 'virtual_field' => true, + 'props' => [ + 'props' => [ + 'multiple' => true, + 'leaf' => 'leaf', + 'emitPath' => false, + 'checkStrictly' => true, + ], + ], + 'render' => function ($field, &$data) { + $id = (int)$this->request->route('id', 0); + $data['value'] = $this->permission_service->getUserRoleIds($id); + $data['props']['options'] = $this->permission_service->getRoleTree(); + }, + ], + 'create_at|创建时间' => [ + 'form' => false, + 'type' => 'date_range', + ], + ], + 'hasOne' => [ + 'hyperf_admin.hyperf_admin.user_role:user_id,role_id', + ], + 'table' => [ + 'columns' => [ + 'id', + 'realname', + 'username', + [ + 'field' => 'mobile', + 'render' => function ($field, $row) { + return data_desensitization($field, 3, 4); + }, + ], + ['field' => 'avatar', 'render' => 'avatarRender'], + 'email', + [ + 'field' => 'status', + 'enum' => [ + User::USER_DISABLE => 'info', + User::USER_ENABLE => 'success', + ], + ], + [ + 'field' => 'role_id', + 'title' => '权限', + 'virtual_field' => true, + ], + ], + 'rowActions' => [ + ['action' => '/user/{id}', 'text' => '编辑',], + ], + ], + ]; + } + + public function menu() + { + $module = $this->request->input('module', 'default'); + $user = auth()->user(); + $base_path = BASE_PATH . '/runtime/menu/'; + $cache_key = $this->permission_service->getPermissionCacheKey($user['id']); + $cache_file = "{$base_path}{$cache_key}/{$module}.menu.{$user['id']}.cache"; + $menu_list = file_exists($cache_file) ? require $cache_file : []; + if (empty($menu_list)) { + $where = [ + 'module' => $module, + 'type' => [0, 1], + ]; + if (!$this->auth_service->isSupperAdmin()) { + $user_role_ids = $this->permission_service->getUserRoleIds($user['id']); + $where['id'] = $this->permission_service->getRoleMenuIds($user_role_ids); + } + $menu_list = (new Menu())->tree($where, [ + 'id', + 'pid', + 'label as menu_name', + 'is_menu as hidden', + 'is_scaffold as scaffold', + 'path as url', + 'view', + 'icon', + ], 'id'); + if (!empty($menu_list)) { + if (file_exists($base_path)) { + rmdir_recursive($base_path); + } + mkdir($base_path . $cache_key, 0755, true); + file_put_contents($cache_file, 'success([ + 'menuList' => $menu_list, + ]); + } + + protected function beforeSave($pk_val, &$data) + { + if (!empty($data['pwd'])) { + $data['password'] = $this->passwordHash($data['pwd']); + } + } + + public function afterSave($pk_val, $data) + { + $role_ids = array_filter(array_unique($data['role_ids'])); + if ((int)$data['id'] == $pk_val) { + UserRole::where('user_id', $pk_val)->delete(); + // 清除缓存 + $this->permission_service->getPermissionCacheKey(0, true); + } + $user_roles = []; + foreach ($role_ids as $role_id) { + $user_roles[] = [ + 'user_id' => $pk_val, + 'role_id' => (int)$role_id, + ]; + } + if (!empty($user_roles)) { + UserRole::insertOnDuplicateKey($user_roles, ['user_id', 'role_id']); + } + + return true; + } + + public function login() + { + $username = $this->request->input('username', ''); + $password = $this->request->input('password', ''); + if (!$username || !$password) { + return $this->fail(ErrorCode::CODE_ERR_PARAM); + } + $user = $this->getModel()->where('username', $username)->first(); + if (!$user || $user['status'] !== YES) { + return $this->fail(ErrorCode::CODE_ERR_PARAM, '该用户不存在或已被禁用'); + } + if ($user->password !== $this->passwordHash($password)) { + return $this->fail(ErrorCode::CODE_ERR_PARAM); + } + $data = [ + 'iat' => Carbon::now()->timestamp, + 'exp' => Carbon::now()->addDay()->timestamp, + 'user_info' => [ + 'id' => $user->id, + 'name' => $user->username, + 'alias_name' => $user->realname, + 'email' => '', + 'avatar' => $user->avatar, + 'mobile' => $user->mobile, + ], + ]; + $token = JWT::token($data); + + return $this->success([ + 'id' => $user->id, + 'mobile' => $user->mobile, + 'name' => $user->realname, + 'avatar' => $user->avatar, + 'token' => $token, + ]); + } + + public function passwordHash($password) + { + return sha1(md5($password) . md5(config('password.salt'))); + } + + public function logout() + { + $user = $this->auth_service->logout(); + return $this->success(); + } + + public function act() + { + $attr = ['select' => ['id as value', 'realname as label']]; + $model = $this->getModel(); + $options = $model->search($attr, [], 'realname'); + + return $this->success($options); + } + + public function export() + { + $url = $this->request->input('url'); + $task = new ExportTasks(); + $task->name = $this->request->input('name'); + $task->list_api = $url; + $task->filters = array_filter($this->request->input('filters'), function ($item) { + return $item !== ''; + }); + $task->operator_id = $this->user()['id'] ?? 0; + if ((new ExportService())->getFirstSameTask($task->list_api, $task->filters, $task->operator_id)) { // 如果当天已经有相同过滤条件且还未成功生成文件的任务 + return $this->success([], '已经有相同的任务,请勿重复导出'); + } + $task->current_page = 0; + $saved = $task->save(); + log_operator($this->getModel(), '导出', $task->id ?? 0); + $limit_max = ExportTasks::LIMIT_SIZE_MAX; + return $saved ? $this->success([], '导出任务提交成功, 请在右上角小铃铛处查看任务状态,您将最多导出' . $limit_max . '条数据。') : $this->fail(ErrorCode::CODE_ERR_SERVER, '导出失败'); + } + + public function exportTasks() + { + /** @var ExportService $export */ + $export = make(ExportService::class); + $list = $export->getTasks(null, $this->auth_service->get('id'), [ + 'id', + 'name', + 'status', + 'total_pages', + 'current_page', + 'download_url', + 'create_at', + ]); + + return $this->success([ + 'list' => $list, + ]); + } + + public function exportLimit() + { + $limit_max = ExportTasks::LIMIT_SIZE_MAX; + return $this->success([ + 'max' => $limit_max + ]); + } + + /** + * 导出任务重试 + * 重新丢入队列 + * + * @param int $id 重试id + * + * @return Mixed + */ + public function exportTasksRetry($id) + { + $export_tasks = ExportTasks::find($id); + $updated = false; + if ($export_tasks) { + $updated = $export_tasks->update([ + 'status' => 0, + 'current_page' => 0 + ]); + } + return $this->success([ + 'retry' => $updated + ]); + } +} diff --git a/src/admin/src/Crontab/ExportTask.php b/src/admin/src/Crontab/ExportTask.php new file mode 100644 index 0000000..55307db --- /dev/null +++ b/src/admin/src/Crontab/ExportTask.php @@ -0,0 +1,33 @@ +info(__METHOD__ . ' ==================> started'); + $list = $export->getTasks(0, 0, ['*'], $params ?? []); + $ids = is_array($list) ? array_column($list, 'id') : $list->pluck('id')->toArray(); + if($ids) { + ExportTasks::whereIn('id', $ids) + ->update(['status' => ExportTasks::STATUS_PRE_PROCESSING]); // 设置预处理状态 + } + foreach($list as $task) { + $export->processTask($task); + } + } + + protected function evaluate(): bool + { + return true; + } +} diff --git a/src/admin/src/Install/InstallCommand.php b/src/admin/src/Install/InstallCommand.php new file mode 100644 index 0000000..0304a19 --- /dev/null +++ b/src/admin/src/Install/InstallCommand.php @@ -0,0 +1,29 @@ +setDescription('install db from hyperf-admin.'); + } + + public function handle() + { + $db_conf = config('databases.hyperf_admin'); + if (!$db_conf || !$db_conf['host']) { + $this->output->error('place set hyperf_admin db config in env'); + } + + $sql = file_get_contents(__DIR__ . '/install.sql'); + + $re = Db::connection('hyperf_admin')->getPdo()->exec($sql); + + $this->output->success('hyperf-admin db install success'); + } +} diff --git a/src/admin/src/Install/UpdateCommand.php b/src/admin/src/Install/UpdateCommand.php new file mode 100644 index 0000000..27f3fb1 --- /dev/null +++ b/src/admin/src/Install/UpdateCommand.php @@ -0,0 +1,44 @@ +setDescription('update db for hyperf-admin.') + ->addArgument('version', InputArgument::REQUIRED, 'the update db version.'); + } + + public function handle() + { + $version = $input->getArgument('version'); + $db_conf = config('databases.hyperf_admin'); + if (!$db_conf || !$db_conf['host']) { + $this->output->error('place set hyperf_admin db config in env'); + return 1; + } + + $update_sql_file = __DIR__ . "/update_{$version}.sql"; + + if (!file_exists($update_sql_file)) { + $this->output->error("the version {$version} file not found"); + return 1; + } + + $sql = file_get_contents($update_sql_file); + + $re = Db::connection('hyperf_admin')->getPdo()->exec($sql); + + $this->output->success('hyperf-admin db update success'); + + } +} diff --git a/src/admin/src/Install/install.sql b/src/admin/src/Install/install.sql new file mode 100644 index 0000000..fe19324 --- /dev/null +++ b/src/admin/src/Install/install.sql @@ -0,0 +1,141 @@ +-- rock-admin db 安装 + +CREATE TABLE `common_config` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `namespace` varchar(50) NOT NULL DEFAULT '' COMMENT '命名空间, 字母', + `name` varchar(100) NOT NULL COMMENT '配置名, 字母', + `title` varchar(100) NOT NULL DEFAULT '' COMMENT '可读配置名', + `remark` varchar(100) NOT NULL DEFAULT '' COMMENT '备注', + `rules` text COMMENT '配置规则描述', + `value` text COMMENT '具体配置值 key:value', + `permissions` text COMMENT '权限', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_need_form` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '是否启用表单:0,否;1,是', + PRIMARY KEY (`id`), + UNIQUE KEY `unique` (`name`,`namespace`), + KEY `namespace` (`namespace`), + KEY `update_at` (`update_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通用配置'; + +CREATE TABLE `export_tasks` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '' COMMENT '任务名称', + `list_api` varchar(255) NOT NULL COMMENT '列表接口', + `filters` text COMMENT '过滤条件', + `status` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '任务状态: 0未开始, 1进行中, 2已完成, 3失败', + `total_pages` int(11) unsigned NOT NULL DEFAULT '1' COMMENT '总页数', + `current_page` int(11) unsigned NOT NULL DEFAULT '1' COMMENT '当前页', + `operator_id` int(11) unsigned NOT NULL COMMENT '管理员id', + `download_url` varchar(100) NOT NULL DEFAULT '' COMMENT '下载地址', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `host_ip` varchar(50) DEFAULT '' COMMENT '主机ip', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `front_routes` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `pid` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '父级ID', + `label` varchar(50) NOT NULL DEFAULT '' COMMENT 'label名称', + `module` varchar(50) NOT NULL DEFAULT '' COMMENT '模块', + `path` varchar(100) NOT NULL DEFAULT '' COMMENT '路径', + `view` varchar(100) NOT NULL DEFAULT '' COMMENT '非脚手架渲染是且path路径为正则时, vue文件路径', + `icon` varchar(50) NOT NULL DEFAULT '' COMMENT 'icon', + `open_type` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '打开方式 0 当前页面 2 新标签页', + `is_scaffold` tinyint(4) unsigned NOT NULL DEFAULT '1' COMMENT '是否脚手架渲染, 1是, 0否', + `is_menu` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '是否菜单 0 否 1 是', + `status` tinyint(4) unsigned NOT NULL DEFAULT '1' COMMENT '状态:0 禁用 1 启用', + `sort` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '排序,数字越大越在前面', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `permission` text NOT NULL COMMENT '权限标识', + `http_method` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '请求方式; 0, Any; 1, GET; 2, POST; 3, PUT; 4, DELETE;', + `type` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '菜单类型 0 目录 1 菜单 2 其他', + `page_type` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '页面类型: 0 列表 1 表单', + `scaffold_action` varchar(255) NOT NULL DEFAULT '' COMMENT '脚手架预置权限', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `global_config` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `namespace` varchar(50) NOT NULL DEFAULT '', + `name` varchar(100) NOT NULL, + `title` varchar(100) NOT NULL DEFAULT '', + `remark` varchar(100) NOT NULL DEFAULT '', + `value` longtext, + `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `unique_name` (`name`), + KEY `namespace` (`namespace`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全局配置'; + +CREATE TABLE `role_menus` ( + `role_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '角色ID', + `router_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '路由ID', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `role_router_id` (`role_id`,`router_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `roles` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `pid` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '父级ID', + `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称', + `permissions` text NOT NULL COMMENT '角色拥有的权限', + `status` tinyint(4) unsigned NOT NULL DEFAULT '1' COMMENT '状态:0 禁用 1 启用', + `sort` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '排序,数字越大越在前面', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; + +CREATE TABLE `user` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(20) NOT NULL DEFAULT '' COMMENT '用户名', + `realname` varchar(50) NOT NULL DEFAULT '', + `password` char(40) NOT NULL, + `mobile` varchar(20) NOT NULL DEFAULT '', + `email` varchar(50) NOT NULL DEFAULT '', + `status` tinyint(4) NOT NULL DEFAULT '1', + `login_time` timestamp NULL DEFAULT NULL, + `login_ip` varchar(50) DEFAULT NULL, + `is_admin` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'is admin', + `is_default_pass` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否初始密码1:是,0:否', + `qq` varchar(20) NOT NULL DEFAULT '' COMMENT '用户qq', + `roles` varchar(50) NOT NULL DEFAULT '10', + `sign` varchar(255) NOT NULL DEFAULT '' COMMENT '签名', + `avatar` varchar(255) NOT NULL DEFAULT '', + `avatar_small` varchar(255) NOT NULL DEFAULT '', + `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `user_role` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(11) unsigned NOT NULL DEFAULT '0', + `role_id` int(11) unsigned NOT NULL DEFAULT '0', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_role_id` (`user_id`,`role_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `operator_log` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `page_url` varchar(50) DEFAULT '' COMMENT '页面url', + `page_name` varchar(50) DEFAULT '' COMMENT '页面面包屑/名称', + `action` varchar(50) DEFAULT '' COMMENT '动作', + `operator_id` int(11) DEFAULT '0' COMMENT '操作人ID', + `nickname` varchar(50) DEFAULT '' COMMENT '操作人名称', + `relation_ids` text COMMENT '多个id-当前版本ID[id-current_version_id,]', + `detail_json` text COMMENT '需要灵活记录的json', + `client_ip` varchar(50) DEFAULT '' COMMENT '客户端地址', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4511 DEFAULT CHARSET=utf8 COMMENT='通用操作日志'; diff --git a/src/admin/src/Middleware/AuthMiddleware.php b/src/admin/src/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..a7a90f4 --- /dev/null +++ b/src/admin/src/Middleware/AuthMiddleware.php @@ -0,0 +1,54 @@ +log = $logger->get('auth'); + $this->auth_service = make(AuthService::class); + parent::__construct($container, 'http'); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // 检验登录状态 + $user = $this->auth_service->check(); + $this->log->info('当前登录用户信息', $user); + + return $handler->handle($request); + } +} diff --git a/src/admin/src/Middleware/PermissionMiddleware.php b/src/admin/src/Middleware/PermissionMiddleware.php new file mode 100644 index 0000000..9e06b96 --- /dev/null +++ b/src/admin/src/Middleware/PermissionMiddleware.php @@ -0,0 +1,147 @@ +container = $container; + $this->response = $response; + $this->request = $request; + $this->log = $logger->get('permission'); + $this->permission_service = make(PermissionService::class); + $this->auth_service = make(AuthService::class); + parent::__construct($container, 'http'); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $uri = $request->getUri(); + $path = $uri->getPath(); + $method = $request->getMethod(); + // 其他系统调用,走AKSK中间件验证 + $client_token = $request->getHeader('Authorization')[0] ?? ''; + if ($client_token) { + if (!$this->akSkAuth($uri, $method, $client_token)) { + return $this->fail(ErrorCode::CODE_ERR_DENY, '接口权限校验失败'); + } + + return $handler->handle($request); + } + // 内部请求时不做鉴权 + if ($this->getRealIp() == '127.0.0.1') { + return $handler->handle($request); + } + // 开放资源,不进行鉴权 + if ($this->permission_service->isOpen($path, $method)) { + return $handler->handle($request); + } + // 检验登录状态 + $user = $this->auth_service->user(); + if (empty($user)) { + return $this->fail(ErrorCode::CODE_LOGIN, '请先登录'); + } + + if (!$this->permission_service->hasPermission($path, $method)) { + return $this->fail(ErrorCode::CODE_NO_AUTH, "{$path}权限不足"); + } + + return $handler->handle($request); + } + + protected function getRealIp() + { + return $this->request->header('x-real-ip'); + } + + /** + * @param int $code + * @param string|null $message + * + * @return \Psr\Http\Message\ResponseInterface + */ + public function fail(int $code = -1, ?string $message = null) + { + $response = [ + 'code' => $code, + 'message' => $message ?: ErrorCode::getMessage($code), + 'payload' => (object)[], + ]; + $this->log->warning($this->request->getUri()->getPath() . ' fail', $response); + + return $this->response->json($response); + } + + private function akSkAuth($uri, $method, $client_token) + { + $host = env('APP_DOMAIN', ''); + $query = $this->request->getQueryParams(); + if (!empty($query)) { + ksort($query); + $query = http_build_query($query); + } else { + $query = ''; + } + $path = $uri->getPath(); + $content_type = $this->request->getHeader('Content-type')[0] ?? ''; + $body = $this->request->getParsedBody(); + if (!empty($body)) { + ksort($body); + $body = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $body = ''; + } + $ak = str_replace('ha ', '', explode(':', $client_token)[0] ?? ''); + $sk = config('client_user')[$ak] ?? ''; + $auth = new AKSK($ak, $sk); + $token = $auth->token($method, $path, $host, $query, $content_type, $body); + $this->log->info('aksk auth:', [ + 'client_token' => $client_token, + 'except_token' => $token, + ]); + + return $token === $client_token; + } +} diff --git a/src/admin/src/Model/CommonConfig.php b/src/admin/src/Model/CommonConfig.php new file mode 100644 index 0000000..1d47746 --- /dev/null +++ b/src/admin/src/Model/CommonConfig.php @@ -0,0 +1,48 @@ + 'array', + 'rules' => 'array', + 'is_need_form' => 'integer', + ]; + + const NEED_FORM_NO = 0; + + const NEED_FORM_YES = 1; + + public static $need_form = [ + self::NEED_FORM_NO => '否', + self::NEED_FORM_YES => '是', + ]; +} diff --git a/src/admin/src/Model/ExportTasks.php b/src/admin/src/Model/ExportTasks.php new file mode 100644 index 0000000..b7b3921 --- /dev/null +++ b/src/admin/src/Model/ExportTasks.php @@ -0,0 +1,54 @@ + 'array', + 'status' => 'integer', + 'total_pages' => 'integer', + 'current_page' => 'integer', + 'operator_id' => 'integer', + ]; + + const STATUS_NOT_START = 0; + + const STATUS_PRE_PROCESSING = 10; // 预处理状态 + + const STATUS_PROCESSING = 1; + + const STATUS_SUCCESS = 2; + + const STATUS_FAIL = 3; + + const LIMIT_SIZE_MAX = 30000; // 导出最大条数 +} diff --git a/src/admin/src/Model/FrontRoutes.php b/src/admin/src/Model/FrontRoutes.php new file mode 100644 index 0000000..b436dc2 --- /dev/null +++ b/src/admin/src/Model/FrontRoutes.php @@ -0,0 +1,102 @@ + 'integer', + 'open_type' => 'integer', + 'type' => 'integer', + 'is_menu' => 'integer', + 'status' => 'integer', + 'is_scaffold' => 'integer', + 'page_type' => 'integer', + 'sort' => 'integer', + ]; + + const HTTP_METHOD_ANY = 0; + + const HTTP_METHOD_GET = 1; + + const HTTP_METHOD_POST = 2; + + const HTTP_METHOD_PUT = 3; + + const HTTP_METHOD_DELETE = 4; + + const PAGE_TYPE_LIST = 0; + + const PAGE_TYPE_FORM = 1; + + const IS_MENU = 1; + + const IS_NOT_MENU = 0; + + const RESOURCE_OPEN = 0; + + const RESOURCE_NEED_AUTH = 1; + + public static $http_methods = [ + self::HTTP_METHOD_ANY => 'ANY', + self::HTTP_METHOD_GET => 'GET', + self::HTTP_METHOD_POST => 'POST', + self::HTTP_METHOD_PUT => 'PUT', + self::HTTP_METHOD_DELETE => 'DELETE', + ]; + + public function scopeDefaultSelect($query) + { + return $query->select([ + 'id', + 'pid', + 'label as menu_name', + 'is_menu as hidden', + 'is_scaffold as scaffold', + 'path as url', + 'view', + 'icon', + 'sort', + ]); + } + + public function scopeOnlyMenu($query) + { + return $query->where('is_menu', YES); + } +} diff --git a/src/admin/src/Model/GlobalConfig.php b/src/admin/src/Model/GlobalConfig.php new file mode 100644 index 0000000..278cbef --- /dev/null +++ b/src/admin/src/Model/GlobalConfig.php @@ -0,0 +1,96 @@ + 'int']; + + const CACHE_PREFIX = 'omsapi_config_'; + + const CACHE_TIME = 60 * 5; + + const VALUE_STATUS_YES = 1; //启用 + + const VALUE_STATUS_NO = 0; //禁用 + + const VALUE_STATUS_MAP = [ + self::VALUE_STATUS_YES => '启用', + self::VALUE_STATUS_NO => '禁用', + ]; + + const NAMESPACE_AB_WHITE_LIST = 'ab_white_list'; + + public static function getConfig($name, $default = null, $cache = false) + { + $cache_key = self::getConfigCache($name); + $cache_config = Redis::get($cache_key); + if($cache_config) { + return json_decode($cache_config, true); + } + $item = static::query()->where('name', $name)->get()->toArray(); + if(empty($item)) { + return $default; + } + $config = json_decode($item[0]['value'], true); + if(is_null($config)) { + return $default; + } + if($cache) { + Redis::setex($cache_key, self::CACHE_TIME, json_encode($config)); + } + + return $config; + } + + public static function getConfigCache($name) + { + return self::CACHE_PREFIX . $name; + } + + public static function setConfig($name, $value, $ext = [], $raw = true) + { + $namespace = ''; + if(($pos = strpos($name, '.')) !== false) { + $namespace = substr($name, 0, $pos); + } + if($raw) { + $value = json_encode($value); + } + $ins = [ + 'name' => $name, + 'value' => $value, + 'namespace' => $namespace, + ]; + if($ext) { + $ins = array_merge($ins, $ext); + } + + return static::query()->updateOrInsert(['name' => $name], $ins); + } +} diff --git a/src/admin/src/Model/OperatorLog.php b/src/admin/src/Model/OperatorLog.php new file mode 100644 index 0000000..d2be479 --- /dev/null +++ b/src/admin/src/Model/OperatorLog.php @@ -0,0 +1,23 @@ + 'array', + 'params' => 'array', + 'user_id' => 'integer', + 'req_id' => 'integer', + ]; + + /** + * 获取产生当前版本的用户信息 + * + * @return \Hyperf\Database\Model\Model|\Hyperf\Database\Model\Relations\BelongsTo|object|null + */ + public function getUser() + { + return $this->belongsTo(User::class, 'user_id', 'id')->first(); + } +} diff --git a/src/admin/src/Model/Role.php b/src/admin/src/Model/Role.php new file mode 100644 index 0000000..9d9306e --- /dev/null +++ b/src/admin/src/Model/Role.php @@ -0,0 +1,49 @@ + 'integer', + 'permissions' => 'array', + 'sort' => 'integer', + 'name' => 'string', + 'pid' => 'integer', + 'id' => 'integer', + 'value' => 'integer', + ]; + + public function menus() + { + return $this->hasMany('App\System\Model\MtOms\RoleMenu', 'role_id'); + } + + public function resources() + { + return $this->hasMany('App\System\Model\MtOms\RoleResource', 'role_id'); + } +} diff --git a/src/admin/src/Model/RoleMenu.php b/src/admin/src/Model/RoleMenu.php new file mode 100644 index 0000000..b698767 --- /dev/null +++ b/src/admin/src/Model/RoleMenu.php @@ -0,0 +1,28 @@ + 'integer', + 'router_id' => 'integer', + ]; +} diff --git a/src/admin/src/Model/User.php b/src/admin/src/Model/User.php new file mode 100644 index 0000000..e7682bf --- /dev/null +++ b/src/admin/src/Model/User.php @@ -0,0 +1,85 @@ + 'int', + 'status' => 'integer', + 'is_admin' => 'integer', + 'is_default_pass' => 'integer', + 'value' => 'integer', + ]; + + const USER_ENABLE = 1; + + const USER_DISABLE = 0; + + public static $status = [ + self::USER_DISABLE => '禁用', + self::USER_ENABLE => '启用', + ]; + + public function getRolesAttribute($value) + { + return explode(',', $value); + } + + public function setRolesAttribute($value) + { + return implode(',', $value); + } + + public function getRealnameAttribute($value) + { + return $value ?: $this->username; + } +} diff --git a/src/admin/src/Model/UserRole.php b/src/admin/src/Model/UserRole.php new file mode 100644 index 0000000..2aeb82b --- /dev/null +++ b/src/admin/src/Model/UserRole.php @@ -0,0 +1,29 @@ + 'integer', + 'role_id' => 'integer', + ]; +} diff --git a/src/admin/src/Model/Version.php b/src/admin/src/Model/Version.php new file mode 100644 index 0000000..e44cf01 --- /dev/null +++ b/src/admin/src/Model/Version.php @@ -0,0 +1,76 @@ + 'array', + 'modify_fields' => 'array', + ]; + + public function versionable() + { + return $this->morphTo(null, 'table', 'pk'); + } + + /** + * 获取产生当前版本的请求信息 + * @return \Hyperf\Database\Model\Model|\Hyperf\Database\Model\Relations\BelongsTo|object|null + */ + public function getRequest() + { + return $this->belongsTo(RequestLog::class, 'req_id', 'req_id')->first(); + } + + /** + * 获取产生当前版本的用户信息 + * @return \Hyperf\Database\Model\Model|\Hyperf\Database\Model\Relations\BelongsTo|object|null + */ + public function getUser() + { + return $this->belongsTo(User::class, 'user_id', 'id')->first(); + } + + /** + * TODO: 该方法应该不可用 + * 回滚版本 + * @return mixed + */ + public function revert() + { + $model = new $this->versionable_type(); + $model->unguard(); + $model->fill($this->content); + $model->exists = true; + $model->reguard(); + + unset($model->{$model->getCreatedAtColumn()}); + unset($model->{$model->getUpdatedAtColumn()}); + if (method_exists($model, 'getDeletedAtColumn')) { + unset($model->{$model->getDeletedAtColumn()}); + } + + $model->save(); + return $model; + } +} diff --git a/src/admin/src/Model/Versionable.php b/src/admin/src/Model/Versionable.php new file mode 100644 index 0000000..71050b1 --- /dev/null +++ b/src/admin/src/Model/Versionable.php @@ -0,0 +1,195 @@ +versioning_enable; + } + + /** + * 启动版本控制 + * + * @return $this + */ + public function enableVersioning() + { + $this->versioning_enable = true; + return $this; + } + + /** + * 关闭版本控制 + * + * @return $this + */ + public function disableVersioning() + { + $this->versioning_enable = false; + return $this; + } + + /** + * 定义版本关联 + * + * @return mixed + */ + public function versions() + { + return $this->morphMany(Version::class, null, 'table', 'pk'); + } + + public function getMorphClass() + { + if (strpos($this->getTable(), '.') !== false) { + return $this->getTable(); + } + return $this->getConnectionName() . '.' . $this->getTable(); + } + + /** + * 是否是一次有效的版本控制 + * + * @return bool + */ + private function isValid() + { + $versionable_fields = $this->getVersionableFields(); + return $this->versioning_enable + && Context::has(ServerRequestInterface::class) + && $this->isDirty($versionable_fields); + } + + public function getVersionableFields() + { + $remove_version_keys = $this->versioning_expect_fields ?? []; + $remove_version_keys[] = $this->getUpdatedAtColumn(); + if (method_exists($this, 'getDeletedAtColumn')) { + $remove_version_keys[] = $this->getDeletedAtColumn(); + } + return collect($this->getAttributes())->except($remove_version_keys)->keys()->all(); + } + + public function saving(Saving $event) + { + $this->is_update = $this->exists; + } + + /** + * 监听saved事件保存表更数据 + * + * @param Saved $event + */ + public function saved(Saved $event) + { + if (!$this->isValid()) { + return; + } + + $request_log = $this->processRequest(); + + $version = new Version(); + $version->pk = $this->getKey(); + $version->table = strpos($this->getTable(), '.') ? $this->getTable() : $this->getConnectionName() . '.' . $this->getTable(); + $version->content = $this->getAttributes(); + $versionable_fields = $this->getVersionableFields(); + $changes = array_remove_keys_not_in($this->getChanges(), $versionable_fields); + $version->modify_fields = array_keys($changes); + $version->action = $this->is_update ? 'update' : 'insert'; + $version->user_id = $request_log->user_id; + /** @var ServerRequestInterface $request */ + $request = container(ServerRequestInterface::class); + $version->req_id = $request->getAttribute('_req_id'); + $version->save(); + + $this->clearOldVersions(); + } + + /** + * 记录数据版本前先记录请求 + * + * @return RequestLog + */ + private function processRequest(): RequestLog + { + /** @var ServerRequestInterface $request */ + $request = container(ServerRequestInterface::class); + + $req_id = $request->getAttribute('_req_id'); + if (empty($req_id)) { + $req_id = id_gen(); + $request = Context::set(ServerRequestInterface::class, $request->withAttribute('_req_id', $req_id)); + return RequestLog::create([ + 'host' => $request->getUri()->getHost(), + 'path' => $request->getUri()->getPath(), + 'method' => $request->getMethod(), + 'header' => $request->getHeaders(), + 'params' => $request->getParsedBody(), + 'user_id' => JWT::verifyToken(cookie('X-Token') ?? '')['user_info']['id'] ?? 0, + 'req_id' => $req_id, + ]); + } else { + return RequestLog::where('req_id', $req_id)->first(); + } + } + + /** + * 默认保留100个数据版本,可被覆盖 + */ + private function clearOldVersions() + { + $keep = $this->keep_version_count ?? 100; + $count = $this->versions()->count(); + if ($keep > 0 && $count > $keep) { + $this->versions()->limit($count - $keep)->delete(); + } + } + + /** + * 返回当前版本的数据 + * + * @return mixed + */ + public function currentVersion() + { + return $this->versions()->orderBy(Version::CREATED_AT, 'DESC')->orderBy('id', 'DESC')->first(); + } + + /** + * 前几个版本 + * + * @param int $previous + * + * @return mixed + */ + public function previousVersion(int $previous = 1) + { + return $this->versions()->orderBy(Version::CREATED_AT, 'DESC')->orderBy('id', 'DESC')->offset($previous)->first(); + } + + /** + * 最后几个版本 + * + * @param int $num + * + * @return mixed + */ + public function lastVersion($num = 1) + { + return $this->versions()->orderBy('id', 'DESC')->limit($num) + //->offset(1) // 排除当前版本 + ->get(); + } +} diff --git a/src/admin/src/Service/AuthService.php b/src/admin/src/Service/AuthService.php new file mode 100644 index 0000000..107dfca --- /dev/null +++ b/src/admin/src/Service/AuthService.php @@ -0,0 +1,70 @@ +info('hyperf_admin_token_payload', is_array($payload) ? $payload : [$payload]); + } + if(!$payload) { + return []; + } + $sso_user_info = Arr::get($payload, 'user_info'); + if(empty($sso_user_info)) { + return []; + } + $cache_key = config('user_info_cache_prefix') . md5(json_encode($sso_user_info)); + if(!$user = Redis::get($cache_key)) { + $user = container(UserService::class)->findUserOrCreate($sso_user_info); + $expire = $payload['exp'] - time(); + if($user && $expire > 0) { + // 缓存用户信息 + Redis::setex($cache_key, json_encode($user), $expire); + } + } else { + $user = json_decode($user, true); + } + + return $this->setUser($user); + } + + public function user() + { + return Context::get('user_info') ?? []; + } + + public function isSupperAdmin() + { + $user = $this->user(); + + return ($user['is_admin'] ?? NO) === YES; + } + + public function setUser($data) + { + return Context::set('user_info', $data); + } + + public function get($key) + { + return Arr::get($this->user(), $key); + } + + public function logout() + { + $user = $this->user(); + $cache_key = config('user_info_cache_prefix') . md5(json_encode($user)); + Redis::connection()->del($cache_key); + Context::set('user_info', null); + } +} diff --git a/src/admin/src/Service/CommonConfig.php b/src/admin/src/Service/CommonConfig.php new file mode 100644 index 0000000..e0c53ea --- /dev/null +++ b/src/admin/src/Service/CommonConfig.php @@ -0,0 +1,49 @@ +where('namespace', 'system') + ->where('name', 'namespace') + ->first() + ->toArray()['rules'] ?? []; + } + + public static function getValue($namespace, $name, $default = []) + { + if($record = CommonConfigModel::query() + ->where('namespace', $namespace) + ->where('name', $name) + ->first()) { + return $record->toArray()['value']; + } + + return $default; + } + + public static function getConfigByName($name) + { + $conf = CommonConfigModel::query()->where(['name' => $name])->select([ + 'id', + 'rules', + 'value', + ])->first(); + if(!$conf) { + return false; + } + + return $conf->toArray(); + } + + public static function getValByName($name) + { + $value = CommonConfigModel::query()->where(['name' => $name])->value('value'); + + return $value ?: []; + } +} diff --git a/src/admin/src/Service/ExportService.php b/src/admin/src/Service/ExportService.php new file mode 100644 index 0000000..d218f8a --- /dev/null +++ b/src/admin/src/Service/ExportService.php @@ -0,0 +1,197 @@ + ['list_api' => ['%123%', '%345'], 'filters' => ['%123%', '%456%']],'like' => ['list_pai' => ['%ttt%']], 'in' => ['operator_id' => [123,123,123]], 'not_in' => ['operator_id' => [456,5466,234]]] + * + * @return array + */ + public function getTasks($status = 0, $operator_id = 0, $columns = ['*'], $filter_options = []) + { + $query = ExportTasks::query()->select($columns); + if($status !== null) { + $query->where('status', $status); + } + if($operator_id) { + $query->where('operator_id', $operator_id); + } + if(!empty($filter_options['not_like'])) { + $query->where(function ($q) use ($filter_options) { + foreach($filter_options['not_like'] as $column => $likes) { + foreach($likes as $like) { + $q->where($column, 'not like', $like); + } + } + }); + } + if(!empty($filter_options['like'])) { + $query->where(function ($q) use ($filter_options) { + foreach($filter_options['like'] as $column => $likes) { + foreach($likes as $like) { + $q->where($column, 'like', $like); + } + } + }); + } + if(!empty($filter_options['in'])) { + $query->where(function ($q) use ($filter_options) { + foreach($filter_options['in'] as $column => $ins) { + $q->whereIn($column, $ins); + } + }); + } + if(!empty($filter_options['not_in'])) { + $query->where(function ($q) use ($filter_options) { + foreach($filter_options['not_in'] as $column => $ins) { + $q->whereNotIn($column, $ins); + } + }); + } + $query->orderBy('id', 'desc'); + + return $query->get() ?: []; + } + + public function processTask(ExportTasks $task) + { + try { + if(in_array(ExportTasks::find($task->id)->status, [ + ExportTasks::STATUS_PROCESSING, + ExportTasks::STATUS_SUCCESS, + ])) { + Log::get('export_service')->info('正在处理该任务,不要重复处理', [$task]); + + return; + } + $task->fill(['status' => ExportTasks::STATUS_PROCESSING])->save(); + $list_api = 'http://127.0.0.1:' . config('server.servers.0.port') . $task->list_api; + $query['_page'] = ($query['_page'] ?? 1); + $size = 100; + $query['_size'] = $size; + $query = array_merge($query, $task->filters); + $headers = [ + 'X-Real-IP' => '127.0.0.1', + ]; + $total = 999; + if(Str::endsWith($task->list_api, self::LIST_API_SUFFIX)) { + $info_api = 'http://127.0.0.1:' . config('server.servers.0.port') . str_replace(self::LIST_API_SUFFIX, self::INFO_API_SUFFIX, $task->list_api); + } else { + $subject = explode('/', $task->list_api)[1]; + $info_api = 'http://127.0.0.1:' . config('server.servers.0.port') . '/' . $subject . '/info'; + } + $info = Guzzle::get($info_api, [], $headers); + $table_headers = array_filter($info['payload']['tableHeader'], function ($item) { + return $item['hidden'] ?? true; + }); + $table_headers_str = []; + foreach($table_headers as $item) { + $table_headers_str[] = $item['title']; + } + $table_headers_str = implode(',', $table_headers_str); + $file_name = '《' . $task->name . '》下载-' . Carbon::now()->format('YmdHis') . '.csv'; + $file_path = '/tmp/' . $file_name; + file_put_contents($file_path, $this->encoding($table_headers_str) . PHP_EOL); + $counter = 0; + $parse = parse_url($list_api); + if(isset($parse['query'])) { + parse_str($parse['query'], $_query); + $query = array_merge($query, $_query); + } + while(($query['_page'] - 1) * $size < $total) { // offset值 + $ret = Guzzle::get($list_api, $query, $headers); + $total = $ret['payload']['total'] <= ExportTasks::LIMIT_SIZE_MAX ? $ret['payload']['total'] : ExportTasks::LIMIT_SIZE_MAX; + if(!is_array($ret['payload']['list'])) { + throw new \Exception('列表获取异常,任务id:' . $task->id); + } + foreach($ret['payload']['list'] as $item) { + $row = $this->getRow($table_headers, $item); + $counter++; + file_put_contents($file_path, $this->encoding($row) . PHP_EOL, FILE_APPEND); + } + $task->fill([ + 'total_pages' => ceil($total / $size), + 'current_page' => $query['_page'], + ])->save(); + $query['_page'] += 1; + } + $info = move_local_file_to_oss($file_path, '1/export_task/' . $file_name, true); + if($info) { + $task->fill([ + 'status' => ExportTasks::STATUS_SUCCESS, + 'download_url' => $info['path'], + ])->save(); + Log::get('export_service') + ->info(sprintf('export task success, file_name:%s id:%s rows:%s', $info['file_path'], $task->id, $counter), [], 'export_task'); + } else { + Log::get('export_service') + ->error(sprintf('export task fail id:%s', $task->id), [], 'export_task'); + $task->fill(['status' => ExportTasks::STATUS_FAIL])->save(); + } + } catch (\Exception $exception) { + Log::get('export_service') + ->error(sprintf('export task fail id:%s', $task->id), ['exception' => $exception], 'export_task'); + $task->fill(['status' => ExportTasks::STATUS_FAIL])->save(); + } + } + + public function getRow($table_headers, $data) + { + $arr = []; + foreach($table_headers as $item) { + if(isset($item['options'])) { + $arr[] = $item['options'][$data[$item['field']]] ?? ($data[$item['field']] ?? ''); + continue; + } + if(isset($item['enum'])) { + $arr[] = $item['enum'][$data[$item['field']]] ?? ''; + continue; + } + $arr[] = $data[$item['field']] ?? ''; + } + $arr = array_map(function ($item) { + $item = csv_big_num($item); + $item = preg_replace('/\\n/', ' ', $item); + + return $item; + }, $arr); + + return implode(',', array_map(function ($item) { + if(is_array($item)) { + return json_encode($item, JSON_UNESCAPED_UNICODE); + } + + return $item; + }, $arr)); + } + + public function encoding($str) + { + //return iconv('utf-8', 'gbk//ignore', $str); + return mb_convert_encoding($str, "GBK", "UTF-8"); + } + + public function getFirstSameTask($url, $filters, $operator_id) + { + return ExportTasks::where('filters', json_encode($filters)) + ->where('list_api', $url) + ->where('operator_id', $operator_id) + ->where('create_at', '>=', Carbon::today()->toDateTimeString()) + ->where('status', '!=', ExportTasks::STATUS_SUCCESS) + ->first(); + } +} diff --git a/src/admin/src/Service/GlobalConfig.php b/src/admin/src/Service/GlobalConfig.php new file mode 100644 index 0000000..3a0e62a --- /dev/null +++ b/src/admin/src/Service/GlobalConfig.php @@ -0,0 +1,67 @@ + $name, + 'value' => $value, + 'namespace' => $namespace, + ]; + if($ext) { + $ins = array_merge($ins, $ext); + } + $model = GlobalModel::query(); + $model->getConnection()->beginTransaction(); + $id = $model->where('name', $name)->value('id'); + if($id) { + $res = $model->where('id', $id)->update($ins); + } else { + $res = $model->insert($ins); + } + if(empty($res)) { + $model->getConnection()->rollBack(); + + return false; + } + $model->getConnection()->commit(); + + return true; + } + + public static function getConfig($name, $default = null) + { + $cache_key = self::getCacheKey($name); + $data = Redis::get($cache_key); + if($data !== false) { + return my_json_decode($data); + } + $data = GlobalModel::query()->where('name', $name)->select('value')->first()->toArray(); + $data = $data ?: $default; + Redis::setex($cache_key, json_encode($data, JSON_UNESCAPED_UNICODE), 5 * MINUTE); + + return $data; + } + + public static function getCacheKey($name) + { + return "omsapi:global_config:{$name}"; + } + + public static function getIdByName($name) + { + return GlobalModel::query()->where('name', $name)->value('id'); + } +} diff --git a/src/admin/src/Service/Menu.php b/src/admin/src/Service/Menu.php new file mode 100644 index 0000000..3526c33 --- /dev/null +++ b/src/admin/src/Service/Menu.php @@ -0,0 +1,95 @@ +newQuery(); + } + + public function getModuleMenus($module = '', $menu_ids = []) + { + $query = $this->query(); + if (!empty($menu_ids)) { + $query->where(function ($query) use ($menu_ids) { + return $query->whereIn('id', $menu_ids)->orWhere(function ($query) use ($menu_ids) { + return $query->where('is_menu', 0)->whereIn('pid', $menu_ids); + }); + }); + } + $query = $query->select([ + 'id', + 'pid', + 'label as menu_name', + 'is_menu as hidden', + 'is_scaffold as scaffold', + 'path as url', + 'view', + 'icon', + ])->where('status', 1); + if ($module) { + $query->where('module', $module); + } + $list = $query->orderBy('sort', 'desc')->get(); + if (empty($list)) { + return []; + } + $list = $list->toArray(); + foreach ($list as &$item) { + $item['hidden'] = !(bool)$item['hidden']; + $item['scaffold'] = (bool)$item['scaffold']; + unset($item); + } + + return generate_tree($list); + } + + public function tree( + $where = [], $fields = [ + 'id as value', + 'pid', + 'label', + ], $pk_key = 'value' + ) { + $where['status'] = 1; + $query = make(FrontRoutes::class)->where2query($where)->select($fields); + $list = $query->orderBy('sort', 'desc')->get(); + if (empty($list)) { + return []; + } + $list = $list->toArray(); + + return generate_tree($list, 'pid', $pk_key, 'children', function (&$item) use ($pk_key) { + $item[$pk_key] = (int)$item[$pk_key]; + $item['pid'] = (int)$item['pid']; + if (isset($item['hidden'])) { + $item['hidden'] = !(bool)$item['hidden']; + } + if (isset($item['scaffold'])) { + $item['scaffold'] = (bool)$item['scaffold']; + } + unset($item); + }); + } + + public function getPathNodeIds($id) + { + $parents = []; + while ($p = $this->getParent($id)) { + $id = (int)$p['pid']; + if ($id) { + $parents[] = $id; + } + } + + return array_reverse($parents); + } + + public function getParent($id) + { + return $this->query()->select(['id', 'pid'])->find($id); + } +} diff --git a/src/admin/src/Service/OperatorLogService.php b/src/admin/src/Service/OperatorLogService.php new file mode 100644 index 0000000..7b16bd6 --- /dev/null +++ b/src/admin/src/Service/OperatorLogService.php @@ -0,0 +1,92 @@ +header('page-url'); + $parse_url = parse_url($page_url); + $fragment = $parse_url['fragment'] ?? '/'; // 抽出#后面的部分 + $fragments = explode('?', $fragment); // 去掉querystring + $page_url = array_shift($fragments); + $page_name = urldecode(request()->header('page-name', '')); // 页面名称 + $relation_ids = json_encode($ids, JSON_UNESCAPED_UNICODE); // 如果没有版本启用,则只记录操作的id + // 关联id-版本id记录 + if(is_string($model) && $model) { + $model = make($model); + } + if($model + && $model instanceof BaseModel + && method_exists($model, 'isVersionEnable') + && $model->isVersionEnable()) { // 如果有版本,则记录版本id + $table = strpos($model->getTable(), '.') ? $model->getTable() : $model->getConnectionName() . '.' . $model->getTable(); + $relation_ids = Version::whereIn('pk', $ids) + ->where('table', $table) + ->selectRaw('concat_ws("-", pk, max(id)) as relation_ids, pk') // 最大版本id为当前版本id + ->groupBy('pk') + ->orderBy('pk', 'desc') + ->get() + ->pluck('relation_ids') + ->toArray(); + $relation_ids = json_encode($relation_ids, JSON_UNESCAPED_UNICODE); + } + // 其他记录 + $detail_json = []; + if($remark) { + $detail_json['remark'] = $remark; + } + if($options) { + $detail_json += $options; + } + // 用户信息 + $user_info = auth()->user() ?: (new UserService())->getUser($user_id); + $client_ip = request()->header('x-real-ip') ?? (request()->getServerParams()['remote_addr'] ?? ''); + $save_data = [ + 'page_url' => $page_url, + 'page_name' => $page_name, + 'action' => $action, + 'relation_ids' => $relation_ids, + 'client_ip' => $client_ip, + 'operator_id' => $user_info['id'] ?? 0, + 'nickname' => $user_info['realname'] ?? ($user_info['username'] ?? ''), + ]; + // {谁}于{时间}在{页面名称}{操作:新增|编辑|删除|导入|导出}了ID为{支持多个}的记录 + $now_date = Carbon::now()->toDateTimeString(); + $detail_json['description'] = "{$save_data['nickname']}于{$now_date} 在{$save_data['page_name']}页{$save_data['action']}了ID为" . implode('、', $ids) . '的记录'; + $detail_json = json_encode($detail_json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $save_data['detail_json'] = $detail_json; + + return (bool)OperatorLog::create($save_data); + } catch (\Exception $e) { // 如果发生错误,为避免中断主程,catch后记录错误信息即可 + Log::get('operator_log')->error('记录通用操作日志发生错误:' . $e->getMessage() . PHP_EOL . $e->getTraceAsString()); + + return false; + } + } +} diff --git a/src/admin/src/Service/PermissionService.php b/src/admin/src/Service/PermissionService.php new file mode 100644 index 0000000..1206c8a --- /dev/null +++ b/src/admin/src/Service/PermissionService.php @@ -0,0 +1,388 @@ +getRouter('http'); + $data = $router->getData(); + $options = []; + foreach($data as $routes_data) { + foreach($routes_data as $http_method => $routes) { + $route_list = []; + if(isset($routes[0]['routeMap'])) { + foreach($routes as $map) { + array_push($route_list, ...$map['routeMap']); + } + } else { + $route_list = $routes; + } + foreach($route_list as $route => $v) { + // 过滤掉脚手架页面配置方法 + $callback = is_array($v) ? ($v[0]->callback) : $v->callback; + if(!is_array($callback)) { + continue; + } + $route = is_string($route) ? rtrim($route) : rtrim($v[0]->route); + $route_key = "$http_method::{$route}"; + $options[] = [ + 'value' => $route_key, + 'label' => $route_key, + ]; + } + } + } + + return $options; + } + + public function getRolePermissionValues($router_ids, $module = 'system') + { + if(empty($router_ids)) { + return []; + } + $data = []; + $routers = make(Menu::class)->tree([ + 'module' => $module, + 'id' => $router_ids, + ]); + if(!empty($routers)) { + $paths = array_keys(tree_2_paths($routers, $module)); + foreach($paths as $path) { + $data[] = explode('-', $path); + } + } + + return $data; + } + + /** + * 构造角色权限设置options + * + * @return array + */ + public function getPermissionOptions($role_id = 0) + { + // todo 配置化 + $options = [ + [ + 'value' => 'default', + 'label' => '默认', + 'children' => make(Menu::class)->tree(['module' => 'default']), + ], + [ + 'value' => 'system', + 'label' => '系统', + 'children' => make(Menu::class)->tree(['module' => 'system']), + ], + ]; + $router_ids = $this->getRoleMenuIds([$role_id]); + $values = $this->getRolePermissionValues($router_ids, 'default'); + $values = array_merge($values, $this->getRolePermissionValues($router_ids, 'system')); + + return [$values, $options]; + } + + public function getAllRoleList($where = [], $fields = ['*']) + { + $model = new Role(); + $roles = $model->where2query($where) + ->select($fields) + ->orderByRaw('pid asc, sort desc') + ->get(); + + return $roles->toArray(); + } + + public function getRoleMenuIds($role_ids) + { + if(empty($role_ids)) { + return []; + } + $routes = RoleMenu::query() + ->distinct(true) + ->select(['router_id']) + ->whereIn('role_id', $role_ids) + ->get() + ->toArray(); + + return $routes ? array_column($routes, 'router_id') : []; + } + + public function getRoleTree() + { + $roles = make(Role::class)->search([ + 'select' => ['id as value', 'name as label', 'pid'], + 'limit' => 200, + 'order_by' => 'sort desc', + ], [ + 'status' => YES, + ], 'name', 'id', 'and', true); + + return generate_tree($roles, 'pid', 'value'); + } + + public function getUserRoleIds($user_id) + { + if(!$user_id) { + return []; + } + + return UserRole::query() + ->select(['role_id']) + ->where('user_id', $user_id) + ->get() + ->pluck('role_id') + ->toArray(); + } + + public function getRoleUserIds($role_id) + { + if(!$role_id) { + return []; + } + + return UserRole::query() + ->select(['user_id']) + ->where('role_id', $role_id) + ->get() + ->pluck('user_id') + ->toArray(); + } + + public function getMenuRoleIds($menu_id) + { + if(!$menu_id) { + return []; + } + + return RoleMenu::query() + ->select(['role_id']) + ->where('router_id', $menu_id) + ->get() + ->pluck('role_id') + ->toArray(); + } + + public function getUserResource($user_id) + { + if(!$user_id) { + return []; + } + $user_role_ids = $this->getUserRoleIds($user_id); + $role_menu_ids = $this->getRoleMenuIds($user_role_ids); + $list = make(FrontRoutes::class)->where2query([ + 'id' => $role_menu_ids, + 'type' => ['>' => 0], + 'status' => YES, + ])->distinct(true)->select([ + 'http_method', + 'path', + 'is_scaffold', + 'permission', + 'scaffold_action', + ])->get()->toArray(); + $resources = []; + foreach($list as $route) { + if(Str::contains($route['permission'], '::')) { + $permissions = array_filter(explode(',', $route['permission'])); + foreach($permissions as $permission) { + [ + $http_method, + $uri, + ] = array_filter(explode('::', $permission, 2)); + $resources[] = [ + 'http_method' => $http_method, + 'uri' => $uri, + ]; + } + } else { + // 这段代码为兼容老的数据 + $paths = array_filter(explode('/', $route['path'])); + $suffix = array_pop($paths); + $prefix = implode('/', $paths); + if($suffix == 'list') { + $action_conf = config("scaffold_permissions.list.permission"); + $scaffold_permissions = array_filter(explode(',', str_replace('/*/', "/{$prefix}/", $action_conf))); + foreach($scaffold_permissions as $scaffold_permission) { + [ + $http_method, + $uri, + ] = array_filter(explode('::', $scaffold_permission, 2)); + $resources[] = [ + 'http_method' => $http_method, + 'uri' => $uri, + ]; + } + } + if(empty($route['permission'])) { + continue; + } + $resources[] = [ + 'http_method' => FrontRoutes::$http_methods[$route['http_method']], + 'uri' => $route['permission'], + ]; + } + } + $user_open_apis = $this->getOpenResourceList('user_open_api'); + return array_merge($resources, $user_open_apis); + } + + public function getOpenResourceList($field = 'open_api') + { + $open_apis = CommonConfig::query()->where([ + 'namespace' => 'system', + 'name' => 'permissions', + ])->value('value')[$field] ?? []; + $data = []; + foreach($open_apis as $route) { + [$http_method, $uri] = explode("::", $route, 2); + $data[] = compact('http_method', 'uri'); + } + + return $data; + } + + public function getResourceDispatcher($user_id = 0, $auth_type = FrontRoutes::RESOURCE_OPEN) + { + $cache_key = $this->getPermissionCacheKey($user_id); + $options = [ + 'routeParser' => 'FastRoute\\RouteParser\\Std', + 'dataGenerator' => 'FastRoute\\DataGenerator\\GroupCountBased', + 'dispatcher' => 'FastRoute\\Dispatcher\\GroupCountBased', + 'routeCollector' => 'FastRoute\\RouteCollector', + ]; + if(!$dispatch_data = json_decode(Redis::get($cache_key), true)) { + /** @var RouteCollector $routeCollector */ + $route_collector = new $options['routeCollector'](new $options['routeParser'], new $options['dataGenerator']); + $this->processUserResource($route_collector, $user_id, $auth_type); + $dispatch_data = $route_collector->getData(); + if(!empty($dispatch_data)) { + Redis::setex($cache_key, json_encode($dispatch_data), 86400); + } + } + + return new $options['dispatcher']($dispatch_data); + } + + protected function processUserResource(RouteCollector $r, $user_id, $auth_type) + { + $resources = $auth_type == FrontRoutes::RESOURCE_OPEN ? $this->getOpenResourceList() : $this->getUserResource($user_id); + $route_keys = []; + foreach($resources as $resource) { + if(!$resource['uri']) { + continue; + } + $route_key = "{$resource['http_method']}::{$resource['uri']}"; + if(in_array($route_key, $route_keys)) { + continue; + } + $route_keys[] = $route_key; + $r->addRoute($resource['http_method'], $resource['uri'], ''); + } + } + + public function hasPermission($uri, $method = 'GET') + { + $auth_service = make(AuthService::class); + // 用户为超级管理员 + if($auth_service->isSupperAdmin()) { + return true; + } + $user = $auth_service->user(); + $dispatcher = $this->getResourceDispatcher($user['id'] ?? 0, FrontRoutes::RESOURCE_NEED_AUTH); + $route_info = $dispatcher->dispatch($method, $uri); + + return $route_info[0] === $dispatcher::FOUND; + } + + public function isOpen($uri, $method) + { + $routes = container(DispatcherFactory::class) + ->getDispatcher('http') + ->dispatch($method, $uri); + if($routes[0] !== Dispatcher::FOUND) { + return false; + } + if($routes[1] instanceof Handler) { + if(is_string($routes[1]->callback)) { + [$controller, $action] = $this->prepareHandler($routes[1]->callback); + } else { + [$controller, $action] = [ + $routes[1]->callback[0], + $routes[1]->callback[1], + ]; + } + } else { + [$controller, $action] = $this->prepareHandler($routes[1]); + } + $controllerInstance = container($controller); + if (in_array($action, $controllerInstance->open_resources)) { + return true; + } + + // 获取开放的资源 + $dispatcher = $this->getResourceDispatcher(); + $route_info = $dispatcher->dispatch($method, $uri); + + return $route_info[0] === $dispatcher::FOUND; + } + + public function can($uri, $method) + { + if($this->isOpen($uri, $method)) { + return true; + } + if($this->hasPermission($uri, $method)) { + return true; + } + + return false; + } + + public function getPermissionCacheKey($user_id = 0, $force = false) + { + $cache_key = 'hyperf_admin_permission_cache:key_map'; + $new_key = "hyperf_admin_permission_cache:" . md5(time() . Str::random(6)); + $cache_value = !$force ? json_decode(Redis::get($cache_key), true) : []; + if(!isset($cache_value[$user_id])) { + $cache_value[$user_id] = $new_key; + Redis::set($cache_key, json_encode($cache_value)); + } + + return $cache_value[$user_id]; + } + + protected function prepareHandler($handler): array + { + if(is_string($handler)) { + if(strpos($handler, '@') !== false) { + return explode('@', $handler); + } + + return explode('::', $handler); + } + if(is_array($handler) && isset($handler[0], $handler[1])) { + return $handler; + } + throw new \RuntimeException('Handler not exist.'); + } +} diff --git a/src/admin/src/Service/UserService.php b/src/admin/src/Service/UserService.php new file mode 100644 index 0000000..e8ef1b2 --- /dev/null +++ b/src/admin/src/Service/UserService.php @@ -0,0 +1,79 @@ + (int)$filter]; + } else { + $where = $filter; + } + $user = make(User::class)->where2query($where)->select([ + 'id', + 'username', + 'realname', + 'mobile', + 'email', + 'status', + 'is_admin', + 'avatar', + 'roles', + ])->first(); + if (!$user) { + return false; + } + + return $user->toArray(); + } + + public function batchGetUser(array $ids, $status = YES) + { + $users = User::query()->whereIn('id', $ids)->where('status', $status)->select([ + 'id', + 'username', + 'realname', + 'mobile', + 'email', + 'status', + 'is_admin', + 'avatar', + 'roles', + ])->get(); + if (!$users) { + return false; + } + + return $users->toArray(); + } + + public function findUserOrCreate($sso_user_info) + { + $where = empty($sso_user_info['mobile']) ? [ + 'username' => $sso_user_info['name'], + ] : [ + 'username' => [$sso_user_info['name'], $sso_user_info['mobile']], + ]; + $user_info = $this->getUser($where); + if ($user_info) { + return $user_info; + } + $data = [ + 'username' => $sso_user_info['mobile'], + 'mobile' => $sso_user_info['mobile'], + 'avatar' => $sso_user_info['avatar'] ?? '', + 'password' => '', + 'status' => YES, + 'realname' => $sso_user_info['name'] ?? '', + 'login_time' => date("Y-m-d H:i:s"), + 'login_ip' => '', + ]; + $user = new User($data); + $user->save(); + + return $user->toArray(); + } +} diff --git a/src/admin/src/config/config.php b/src/admin/src/config/config.php new file mode 100644 index 0000000..6e5da18 --- /dev/null +++ b/src/admin/src/config/config.php @@ -0,0 +1,57 @@ + [ + 'hyperf_admin' => db_complete([ + 'host' => env('HYPERF_ADMIN_DB_HOST'), + 'database' => env('HYPERF_ADMIN_DB_NAME', 'hyperf_admin'), + 'username' => env('HYPERF_ADMIN_DB_USER'), + 'password' => env('HYPERF_ADMIN_DB_PWD'), + ]), + ], + 'init_routes' => [ + __DIR__ . '/routes.php' + ], + 'user_info_cache_prefix' => 'hyperf_admin_userinfo_', + 'admin_cookie_name' => 'hyperf_admin_token', + // 脚手架预置权限 + 'scaffold_permissions' => [ + 'list' => [ + 'label' => '列表', + 'type' => 2, + 'permission' => 'GET::/*/info,GET::/*/list,GET::/*/list.json,GET::/*/childs/{id:\d+},GET::/*/act,GET::/*/notice', + ], + 'create' =>[ + 'label' => '新建', + 'path' => '/form', + 'type' => 1, + 'permission' =>'GET::/*/form,GET::/*/form.json,POST::/*/form', + ], + 'edit' =>[ + 'label' => '编辑', + 'path' => '/:id', + 'type' => 1, + 'permission' => 'GET::/*/{id:\d+},GET::/*/{id:\d+}.json,POST::/*/{id:\d+},GET::/*/newversion/{id:\d+}/{last_ver_id:\d+}', + ], + 'rowchange' =>[ + 'label' => '行编辑', + 'type' => 2, + 'permission' => 'POST::/*/rowchange/{id:\d+}' + ], + 'delete' => [ + 'label' => '删除', + 'type' => 2, + 'permission' => 'POST::/*/delete', + ], + 'import' => [ + 'label' => '导入', + 'type' => 2, + 'permission' => 'POST::/*/import', + ], + 'export' => [ + 'label' => '导出', + 'type' => 2, + 'permission' => 'POST::/*/export', + ], + ], +]; diff --git a/src/admin/src/config/routes.php b/src/admin/src/config/routes.php new file mode 100644 index 0000000..c773d1c --- /dev/null +++ b/src/admin/src/config/routes.php @@ -0,0 +1,40 @@ +write($model, $action, $ids, $remark, $options, $user_id); + } +} + +if (! function_exists('auth')) { + function auth(?string $field = null) + { + $auth = container(AuthService::class); + if (is_null($field)) { + return $auth; + } + return $auth->get($field); + } +} + +if (! function_exists('permission')) { + function permission(?string $uri = null, string $method = 'POST') + { + $permission = make(PermissionService::class); + if (is_null($uri)) { + return $permission; + } + return $permission->can($uri, strtoupper($method)); + } +} diff --git a/src/alert-manager/composer.json b/src/alert-manager/composer.json new file mode 100644 index 0000000..e9434fa --- /dev/null +++ b/src/alert-manager/composer.json @@ -0,0 +1,30 @@ +{ + "name": "rock-admin/alert-manager", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "daodao97", + "email": "daodao97@foxmail.com" + } + ], + "require": { + "hyperf/async-queue": "^1.1", + "hyperf/process": "^1.1", + "rock-admin/rule-engine": "~0.0.1" + }, + "autoload": { + "psr-4": { + "Rock\\AlertManager\\": "./src" + }, + "files": [ + "./src/func.php" + ] + }, + "extra": { + "hyperf": { + "config": "Rock\\AlertManager\\ConfigProvider" + } + }, + "description": "" +} diff --git a/src/alert-manager/src/AlertJob.php b/src/alert-manager/src/AlertJob.php new file mode 100644 index 0000000..bd08842 --- /dev/null +++ b/src/alert-manager/src/AlertJob.php @@ -0,0 +1,77 @@ +params = $params; + } + + public function handle() + { + $params = $this->params; + $filter = make(AlertRules::class)->get(); + $logger = Log::get('alert_manager'); + if($filter) { + $context = (new Context())->register(new TimeContext()) + ->setCustomContext($params['extra'] ?? []); + $bo = new BooleanOperation($context); + try { + $ret = $bo->execute($filter); + } catch (\Exception $exception) { + $logger->error('rule compare filed', compact('exception', 'filter')); + } + } + + if(!isset($ret) || !$ret) { + $ret = [ + 'alert' => true, + ]; + } + + $params = array_overlay($ret, $params); + if(!$ret['alert']) { + return; + } + + $robots = make(AlertRobots::class)->get(); + if(!isset($robots[$params['robot_name']])) { + $logger->warning(sprintf('not support group [%s]', $params['robot_name'])); + + return; + } + + $key = sprintf('alert_manager:frequency:%s:%s', $params['robot_name'], date('YmdHi')); + + $params['webhook'] = $robots[$params['robot_name']]['webhook']; + $webhook = $params['webhook'] ?? ''; + $type = $params['type'] ?? 'text'; + $message = $params['message'] ?? ''; + $receivers = $params['receivers'] ?? ''; + $method = 'sendText'; + switch($type) { + case 'markdown': + case 'md': + $method = 'sendMarkdown'; + break; + } + + $send = make(DingTalkRobot::class, ['webhook' => $webhook])->$method($message, $receivers); + + if(!$send) { + $logger->error('alert_manager send fail', compact('params')); + } + } +} + diff --git a/src/alert-manager/src/AlertMessage.php b/src/alert-manager/src/AlertMessage.php new file mode 100644 index 0000000..5a32ccc --- /dev/null +++ b/src/alert-manager/src/AlertMessage.php @@ -0,0 +1,23 @@ + $val) { + if(property_exists($this, $key)) { + $this->{$key} = $val; + } + } + } +} diff --git a/src/alert-manager/src/AlertQueueConsumer.php b/src/alert-manager/src/AlertQueueConsumer.php new file mode 100644 index 0000000..fbcbb47 --- /dev/null +++ b/src/alert-manager/src/AlertQueueConsumer.php @@ -0,0 +1,15 @@ +key); + if($rules) { + return json_decode($rules, true); + } + + return []; + } + + public function set(array $value) + { + return Redis::set($this->key, json_encode($value)); + } +} diff --git a/src/alert-manager/src/AlertRules.php b/src/alert-manager/src/AlertRules.php new file mode 100644 index 0000000..b6d9ca0 --- /dev/null +++ b/src/alert-manager/src/AlertRules.php @@ -0,0 +1,24 @@ +key); + if($rules) { + return json_decode($rules, true); + } + + return []; + } + + public function set(array $rules) + { + return Redis::set($this->key, json_encode($rules)); + } +} diff --git a/src/alert-manager/src/AlertService.php b/src/alert-manager/src/AlertService.php new file mode 100644 index 0000000..f82deb1 --- /dev/null +++ b/src/alert-manager/src/AlertService.php @@ -0,0 +1,35 @@ +driver = $driverFactory->get('default'); + } + + /** + * 生产消息. + * + * @param $params 数据 + * @param int $delay 延时时间 单位秒 + * + * @return bool + */ + public function push($params, int $delay = 0): bool + { + // 这里的 `ExampleJob` 会被序列化存到 Redis 中,所以内部变量最好只传入普通数据 + // 同理,如果内部使用了注解 @Value 会把对应对象一起序列化,导致消息体变大。 + // 所以这里也不推荐使用 `make` 方法来创建 `Job` 对象。 + return $this->driver->push(new AlertJob($params), $delay); + } +} diff --git a/src/alert-manager/src/ConfigProvider.php b/src/alert-manager/src/ConfigProvider.php new file mode 100644 index 0000000..d3406b4 --- /dev/null +++ b/src/alert-manager/src/ConfigProvider.php @@ -0,0 +1,51 @@ + [ + 'alert_manager' => [ + 'host' => env('REDIS_ALERT_MANAGER_HOST', 'localhost'), + 'auth' => env('REDIS_ALERT_MANAGER_AUTH', null), + 'port' => (int)env('REDIS_ALERT_MANAGER_PORT', 6379), + 'db' => (int)env('REDIS_ALERT_MANAGER_DB', 0), + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => (float)env('REDIS_ALERT_MANAGER_MAX_IDLE_TIME', 60), + ], + ], + ], + 'async_queue' => [ + 'alert_manager' => [ + 'driver' => \Hyperf\AsyncQueue\Driver\RedisDriver::class, + 'redis' => [ + 'pool' => 'alert_manager', + ], + 'channel' => 'alert_manager', + 'timeout' => 2, + 'retry_seconds' => 5, + 'handle_timeout' => 10, + 'processes' => 1, + 'concurrent' => [ + 'limit' => 5, + ], + ], + ], + 'commands' => [], + 'dependencies' => [], + 'processes' => [ + AlertQueueConsumer::class, + ], + 'listeners' => [], + 'publish' => [], + ]; + } +} diff --git a/src/alert-manager/src/DingTalkRobot.php b/src/alert-manager/src/DingTalkRobot.php new file mode 100644 index 0000000..2c75199 --- /dev/null +++ b/src/alert-manager/src/DingTalkRobot.php @@ -0,0 +1,63 @@ +webhook = $webhook; + } + + public function sendText($message, $at = 'all') + { + $params = [ + 'msgtype' => 'text', + 'text' => [ + 'content' => $message, + ], + ]; + + return $this->send($params, $at); + } + + public function sendMarkdown($message, $at = 'all') + { + $params = [ + 'msgtype' => 'markdown', + 'markdown' => [ + 'text' => $message, + 'title' => '告警信息', + ], + ]; + + return $this->send($params, $at); + } + + public function send($params, $at = 'all') + { + if($at == 'all') { + $params['at'] = ['isAtAll' => true]; + } + if(is_array($at)) { + $params['at'] = ['atMobiles' => $at]; + } + $ret = Guzzle::post($this->webhook, $params); + if($ret['errcode'] ?? -1 !== 0) { + Log::get('alert_manager') + ->error(sprintf('alert_manager send message filed'), compact('params', 'ret')); + + return false; + } + + return true; + } +} diff --git a/src/alert-manager/src/SenderInterface.php b/src/alert-manager/src/SenderInterface.php new file mode 100644 index 0000000..a5fb3c5 --- /dev/null +++ b/src/alert-manager/src/SenderInterface.php @@ -0,0 +1,9 @@ +get('alert_manager')->push(new AlertJob($message)); +} diff --git a/src/base-utils/composer.json b/src/base-utils/composer.json new file mode 100644 index 0000000..50d0cb6 --- /dev/null +++ b/src/base-utils/composer.json @@ -0,0 +1,78 @@ +{ + "name": "rock-admin/base-utils", + "type": "project", + "license": "MIT", + "authors": [ + { + "name": "daodao97", + "email": "daodao97@foxmail.com" + } + ], + "require": { + "php": ">=7.2", + "ext-json": "*", + "ext-swoole": ">=4.4", + "ext-yaml": "*", + "aliyuncs/oss-sdk-php": "^2.3", + "box/spout": "^3.1", + "hyperf/amqp": "^1.1", + "hyperf/cache": "~1.1.0", + "hyperf/command": "~1.1.0", + "hyperf/config": "~1.1.0", + "hyperf/constants": "^1.1", + "hyperf/database": "^1.1", + "hyperf/db-connection": "~1.1.0", + "hyperf/filesystem": "^1.1", + "hyperf/framework": "~1.1.0", + "hyperf/guzzle": "~1.1.0", + "hyperf/http-server": "~1.1.0", + "hyperf/logger": "~1.1.0", + "hyperf/memory": "~1.1.0", + "hyperf/metric": "^1.1", + "hyperf/nsq": "^1.1", + "hyperf/process": "~1.1.0", + "hyperf/redis": "~1.1.0", + "hyperf/snowflake": "^1.1", + "yadakhov/insert-on-duplicate-key": "^1.2", + "ext-pdo": "*" + }, + "require-dev": { + "swoft/swoole-ide-helper": "^4.2", + "phpstan/phpstan": "^0.11.2", + "hyperf/devtool": "~1.1.0", + "hyperf/testing": "~1.1.0", + "daodao97/hyperf-watch": "dev-master", + "symfony/var-dumper": "^5.0" + }, + "suggest": { + "ext-openssl": "Required to use HTTPS.", + "ext-json": "Required to use JSON.", + "ext-pdo": "Required to use MySQL Client.", + "ext-pdo_mysql": "Required to use MySQL Client.", + "ext-redis": "Required to use Redis Client." + }, + "autoload": { + "psr-4": { + "Rock\\BaseUtils\\": "src/" + }, + "files": [ + "src/Helper/constants.php", + "src/Helper/common.php", + "src/Helper/array.php", + "src/Helper/system.php" + ] + }, + "autoload-dev": { + "psr-4": { + "HyperfTest\\": "./tests/" + } + }, + "config": { + "sort-packages": false + }, + "extra": { + "hyperf": { + "config": "Rock\\BaseUtils\\ConfigProvider" + } + } +} diff --git a/src/base-utils/src/AKSK.php b/src/base-utils/src/AKSK.php new file mode 100644 index 0000000..b4fbf51 --- /dev/null +++ b/src/base-utils/src/AKSK.php @@ -0,0 +1,50 @@ +access_key = $access_key; + $this->secret_key = $secret_key; + } + + public function token($method, $path, $host, $query, $content_type, $body) + { + $data = ''; + if(!empty($path)) { + $data = $method . ' ' . $path; + } + if(!empty($query)) { + $data .= '?' . $query; + } + $data .= "\nHost: " . $host; + if(!empty($content_type)) { + $data .= "\nContent-Type: " . $content_type; + } + $data .= "\n\n"; + if(!empty($body)) { + $data .= $body; + } + $sign = $this->sign($this->secret_key, $data); + + return 'ha ' . $this->access_key . ':' . $sign; + } + + private function digest($secret, $data) + { + return hash_hmac('sha1', $data, $secret, true); + } + + private function sign($secret, $data) + { + return urlsafe_b64encode($this->digest($secret, $data)); + } +} diff --git a/src/base-utils/src/AliyunOSS.php b/src/base-utils/src/AliyunOSS.php new file mode 100644 index 0000000..07597c3 --- /dev/null +++ b/src/base-utils/src/AliyunOSS.php @@ -0,0 +1,150 @@ +access_key = $config['access_key']; + $this->access_key_secret = $config['access_key_secret']; + $this->endpoint = $config['endpoint']; + $this->bucket = $config['bucket']; + $this->host = $config['host']; + $this->cdn = $config['cdn']; + $this->client = new OssClient($this->access_key, $this->access_key_secret, $this->endpoint); + } + + /** + * @param string $object + * @param string $file 本地文件名, 包含完整路径 + * + * @throws \OSS\Core\OssException + */ + public function uploadFile($object, $file, $options = []) + { + return $this->client->uploadFile($this->bucket, $object, $file, $options); + } + + public function uploadPrivateFile($object, $file_path, $options = []) + { + $ok = $this->uploadFile($object, $file_path, $options); + if(!$ok) { + return false; + } + + return $this->setAcl($object, self::ACL_PRIVATE); + } + + public function setAcl($object, $acl = 'default') + { + if($acl !== 'default') { + return $this->client->putObjectAcl($this->bucket, $object, $acl); + } + + return true; + } + + public function getSignUrl($object, $timeout = 60) + { + return $this->client->signUrl($this->bucket, $object, $timeout); + } + + /** + * 下载到本地文件 + * + * @link https://help.aliyun.com/document_detail/88494.html + * 直接获取文件内容 + * @link https://help.aliyun.com/document_detail/88495.html + * + * @param string $object + * @param string $file 本地文件名, 包含完整路径, 不传则直接获取文件内容 + * + * @throws \OSS\Core\OssException + */ + public function getFile($object, $file = null) + { + $options = []; + if($file) { + $options = [ + OssClient::OSS_FILE_DOWNLOAD => $file, + ]; + } + + return $this->client->getObject($this->bucket, $object, $options); + } + + private function gmtISO8601($time) + { + $dtStr = date("c", $time); + $mydatetime = new \DateTime($dtStr); + $expiration = $mydatetime->format(\DateTime::ISO8601); + $pos = strpos($expiration, '+'); + $expiration = substr($expiration, 0, $pos); + + return $expiration . "Z"; + } + + public function getPolicy($config = []) + { + $expire = $config['expire'] ?? $this->default_ttl; + $max_size = $config['max_size'] ?? $this->default_bytes; + $dir = $config['dir'] ?? ''; + $end = time() + $expire; + $expiration = $this->gmtISO8601($end); + //最大文件大小.用户可以自己设置 + $conditions[] = [ + 'content-length-range', + 0, + $max_size, + ]; + //表示用户上传的数据,必须是以$dir开始, 不然上传会失败,这一步不是必须项,只是为了安全起见,防止用户通过policy上传到别人的目录 + if($dir) { + $conditions[] = [ + 'starts-with', + '$key', + rtrim($dir, '/') . '/', + ]; + } + $base64_policy = base64_encode(json_encode([ + 'expiration' => $expiration, + 'conditions' => $conditions, + ])); + $signature = base64_encode(hash_hmac('sha1', $base64_policy, $this->access_key_secret, true)); + + return [ + 'OSSAccessKeyId' => $this->access_key, + 'host' => 'http://' . $this->host, + 'policy' => $base64_policy, + 'Signature' => $signature, + 'expire' => $end, + 'dir' => $dir, //这个参数是设置用户上传指定的前缀 + 'cdn' => $this->cdn ? rtrim($this->cdn, '/') : '', + ]; + } +} diff --git a/src/base-utils/src/ColorLineFormatter.php b/src/base-utils/src/ColorLineFormatter.php new file mode 100644 index 0000000..5fac6ac --- /dev/null +++ b/src/base-utils/src/ColorLineFormatter.php @@ -0,0 +1,27 @@ + "\e[0;31m{log_str}\e[0m", + 'INFO' => "\e[0;32m{log_str}\e[0m", + 'WARNING' => "\e[0;33m{log_str}\e[0m", + ]; + + public function format(array $record) + { + $log = parent::format($record); + + return $this->logColor($record['level_name'], $log); + } + + public function logColor($level_name, $log) + { + $color_format = $this->level_color_map[$level_name] ?? '{log_str}'; + + return str_replace('{log_str}', $log, $color_format); + } +} diff --git a/src/base-utils/src/ConfigProvider.php b/src/base-utils/src/ConfigProvider.php new file mode 100644 index 0000000..8c6633f --- /dev/null +++ b/src/base-utils/src/ConfigProvider.php @@ -0,0 +1,117 @@ + [ + 'class' => HAStreamHandler::class, + 'constructor' => [ + 'level' => Logger::INFO, + 'stream' => 'php://stdout', + ], + ], + 'formatter' => [ + 'class' => ColorLineFormatter::class, + 'constructor' => [ + 'format' => "%datetime%||%channel%||%level_name%||%message%||%context%||%extra%\n", + 'allowInlineLineBreaks' => true, + 'includeStacktraces' => true, + ], + ], + ]; + } else { + $logger_default = [ + 'handler' => [ + 'class' => RotatingFileHandler::class, + 'constructor' => [ + 'filename' => BASE_PATH . '/runtime/logs/app.log', + 'maxFiles' => 1, + 'level' => Logger::INFO, + ], + ], + 'formatter' => [ + 'class' => JsonFormatter::class, + 'constructor' => [], + ], + ]; + } + + return [ + 'commands' => [], + 'dependencies' => [ + // 终端彩色日志 + StdoutLoggerInterface::class => StdoutLoggerFactory::class, + // 处理 routes 分文件路由 + DispatcherFactory::class => RoutesDispatcher::class, + ], + 'listeners' => [ + HABootAppConfListener::class, + DbQueryExecutedListener::class, + FetchModeListener::class, + ], + 'annotations' => [ + 'scan' => [ + 'paths' => [ + __DIR__, + ], + ], + ], + 'logger' => [ + 'default' => $logger_default, + ], + 'middlewares' => [ + 'http' => [ + CorsMiddleware::class, + HttpLogMiddleware::class, + ], + ], + 'redis' => [ + 'metric' => [ + 'host' => env('REDIS_ALERT_MANAGER_HOST', 'localhost'), + 'auth' => env('REDIS_ALERT_MANAGER_AUTH', null), + 'port' => (int)env('REDIS_ALERT_MANAGER_PORT', 6379), + 'db' => (int)env('REDIS_ALERT_MANAGER_DB', 0), + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => (float)env('REDIS_ALERT_MANAGER_MAX_IDLE_TIME', 60), + ], + ], + ], + 'init_routes' => [ + __DIR__ . '/config/routes.php', + ], + 'exceptions' => [ + 'handler' => [ + 'http' => [ + HttpExceptionHandler::class + ], + ], + ], + 'publish' => [], + ]; + } +} diff --git a/src/base-utils/src/Constants/Consts.php b/src/base-utils/src/Constants/Consts.php new file mode 100644 index 0000000..6660f04 --- /dev/null +++ b/src/base-utils/src/Constants/Consts.php @@ -0,0 +1,42 @@ + 'wechat', + self::PLATFORM_WEB => 'web', + self::PLATFORM_WXA => 'mapp', + self::PLATFORM_IOS => 'ios', + self::PLATFORM_ANDROID => 'android', + self::PLATFORM_QTT => 'qtt', + ]; +} diff --git a/src/base-utils/src/Constants/ErrorCode.php b/src/base-utils/src/Constants/ErrorCode.php new file mode 100644 index 0000000..f770454 --- /dev/null +++ b/src/base-utils/src/Constants/ErrorCode.php @@ -0,0 +1,128 @@ +open($path); + $iterator = $reader->getSheetIterator(); + /** @var \Box\Spout\Reader\XLSX\Sheet $sheet */ + foreach($iterator as $sheet) { + $this->sheets[] = $sheet; + } + } + + public function readLine($sheet_index = 0) + { + $sheet = $this->sheets[$sheet_index] ?? null; + if(!$sheet) { + return false; + } + foreach($sheet->getRowIterator() as $row) { + $cells = $row->getCells(); + $row = []; + /** @var \Box\Spout\Common\Entity\Cell $cell */ + foreach($cells as $cell) { + $row[] = $cell->getValue(); + } + yield $row; + } + } +} diff --git a/src/base-utils/src/Excel/ExcelWriter.php b/src/base-utils/src/Excel/ExcelWriter.php new file mode 100644 index 0000000..eecd64f --- /dev/null +++ b/src/base-utils/src/Excel/ExcelWriter.php @@ -0,0 +1,28 @@ +writer = WriterFactory::createFromType($extension); + $this->writer = $this->writer->openToFile($path); + } + + public function addRows($rows) + { + $rows = WriterEntityFactory::createRowFromArray($rows); + $this->writer->addRow($rows); + } + + public function close() + { + $this->writer->close();; + } +} diff --git a/src/base-utils/src/Exception/HttpExceptionHandler.php b/src/base-utils/src/Exception/HttpExceptionHandler.php new file mode 100644 index 0000000..d0101d5 --- /dev/null +++ b/src/base-utils/src/Exception/HttpExceptionHandler.php @@ -0,0 +1,54 @@ +error($throwable->getCode(), [ + 'trace' => (string)$throwable, + ]); + if (is_production()) { + return $this->response(ErrorCode::CODE_ERR_SYSTEM, '服务器内部错误'); + } + + return $this->response($throwable->getCode() ?: ErrorCode::CODE_ERR_SYSTEM, (string)$throwable); + } + + /** + * 抽取函数方便子类重写response结构 + * + * @param int $code + * @param string $msg + * + * @return ResponseInterface + */ + protected function response($code, $msg) + { + return $this->response->json([ + 'code' => $code, + 'msg' => $msg, + ]); + } + + public function isValid(Throwable $throwable): bool + { + return true; + } +} + diff --git a/src/base-utils/src/Guzzle.php b/src/base-utils/src/Guzzle.php new file mode 100644 index 0000000..10c1184 --- /dev/null +++ b/src/base-utils/src/Guzzle.php @@ -0,0 +1,79 @@ +create($config); + } + + public static function get($url, $query = [], $header = []) + { + return self::request('get', $url, $query, $header); + } + + public static function post($url, $params = [], $header = []) + { + return self::request('post', $url, $params, $header); + } + + public static function request($method, $api, $params = [], $headers = []) + { + if(!$api) { + Log::get('api_request')->warning('api is empty'); + + return false; + } + $client = self::create([ + 'timeout' => $headers['timeout'] ?? 10.0, + ]); + $method = strtoupper($method); + $options = []; + $headers['charset'] = $headers['charset'] ?? 'UTF-8'; + $options['headers'] = $headers; + if($method == 'GET' && $params) { + $options['query'] = $params; + } + if($method == 'POST') { + $options['headers']['Content-Type'] = $headers['Content-Type'] ?? 'application/json'; + if($options['headers']['Content-Type'] == 'application/json' && $params) { + $options['body'] = \GuzzleHttp\json_encode($params ? $params : (object)[]); + } + if($options['headers']['Content-Type'] == 'application/x-www-form-urlencoded' && $params) { + $options['form_params'] = $params; + } + } + try { + $request = $client->request($method, $api, $options); + $code = $request->getStatusCode(); + $content = $request->getBody()->getContents(); + $content = my_json_decode($content); + Log::get('api_request')->info($api, [ + 'method' => $method, + 'code' => $code, + 'options' => $options, + 'content' => $content, + ]); + + return $content; + } catch (\GuzzleHttp\Exception\GuzzleException $e) { + Log::get('api_request')->error($api, [ + 'method' => $method, + 'options' => $options, + 'exception' => (string)$e, + ]); + + return false; + } + } +} diff --git a/src/base-utils/src/HAStreamHandler.php b/src/base-utils/src/HAStreamHandler.php new file mode 100644 index 0000000..092fb1a --- /dev/null +++ b/src/base-utils/src/HAStreamHandler.php @@ -0,0 +1,18 @@ += $this->level; + } +} diff --git a/src/base-utils/src/Helper/array.php b/src/base-utils/src/Helper/array.php new file mode 100644 index 0000000..30b77c1 --- /dev/null +++ b/src/base-utils/src/Helper/array.php @@ -0,0 +1,326 @@ + 2) { + $args = func_get_args(); + foreach($grouped as $key => $value) { + $parms = array_merge([$value], array_slice($args, 2, func_num_args())); + $grouped[$key] = call_user_func_array('array_group_by', $parms); + } + } + + return $grouped; + } +} + +if(!function_exists('array_node_append')) { + function array_node_append($list, $key, $append_key, $callable) + { + $kws = array_column($list, $key); + $ret = $callable($kws); + foreach($list as &$item) { + $item[$append_key] = $ret[$item[$key]] ?? ''; + unset($item); + } + + return $list; + } +} +if(!function_exists('array_map_recursive')) { + function array_map_recursive(callable $func, array $data) + { + $result = []; + foreach($data as $key => $val) { + $result[$key] = is_array($val) ? array_map_recursive($func, $val) : call($func, [$val]); + } + + return $result; + } +} + +if(!function_exists('array_copy')) { + function array_copy($arr, $keys = []) + { + if(!$keys) { + return $arr; + } + $new = []; + foreach($keys as $index => $key) { + $new_key = is_string($index) ? $index : $key; + isset($arr[$key]) && $new[$new_key] = $arr[$key]; + } + + return $new; + } +} + +if(!function_exists('array_sort_by_key_length')) { + function array_sort_by_key_length($arr, $sort_order = SORT_DESC) + { + $keys = array_map('strlen', array_keys($arr)); + array_multisort($keys, $sort_order, $arr); + + return $arr; + } +} + +if(!function_exists('array_sort_by_value_length')) { + function array_sort_by_value_length($arr, $sort_order = SORT_DESC) + { + $keys = array_map('strlen', $arr); + array_multisort($keys, $sort_order, $arr); + + return $arr; + } +} + +if(!function_exists('array_to_kv')) { + function array_to_kv($arr, $as_key_column, $as_value_column) + { + $new = []; + foreach($arr as $item) { + $new[$item[$as_key_column]] = $item[$as_value_column]; + } + + return $new; + } +} + +if(!function_exists('array_flat')) { + /** + * 将数组递归展开至深度为1的新数组 + * + * @param $arr + * @param bool $keep_key + * + * @return array|mixed + */ + function array_flat($arr, $keep_key = true) + { + $newArr = []; + if($keep_key) { + foreach($arr as $key => $item) { + if(is_array($item)) { + $newArr = $newArr + array_flat($item, true); + } else { + $newArr[$key] = $item; + } + } + } else { + foreach($arr as $item) { + if(is_array($item)) { + $newArr = array_merge($newArr, array_flat($item)); + } else { + $newArr[] = $item; + } + } + } + + return $newArr; + } +} + +if(!function_exists('array_depth')) { + function array_depth(array $array) + { + $max_depth = 1; + foreach($array as $value) { + if(is_array($value)) { + $depth = array_depth($value) + 1; + if($depth > $max_depth) { + $max_depth = $depth; + } + } + } + + return $max_depth; + } +} + +if(!function_exists('array_merge_node')) { + function array_merge_node($arr, $node, $key) + { + $box = []; + foreach($arr as $each) { + $box[$each[$key] . '-'] = $each; + } + if(isset($node[0])) { + foreach($node as $n) { + $box[$n[$key] . '-'] = $n; + } + } else { + $box[$node[$key] . '-'] = $node; + } + + return array_values($box); + } +} + +if(!function_exists('array_change_v2k')) { + /** + * 将二维数组二维某列的key值作为一维的key + * + * @param array $arr 原始数组 + * @param string $column key + */ + function array_change_v2k(&$arr, $column) + { + if(empty($arr)) { + return; + } + $new_arr = []; + foreach($arr as $val) { + $new_arr[$val[$column]] = $val; + } + $arr = $new_arr; + } +} + +if(!function_exists('array_group')) { + function array_group($arr, $key) + { + $tmp = []; + foreach($arr as $item) { + $tmp[$item[$key]][] = $item; + } + + return $tmp; + } +} + +if(!function_exists('array_last')) { + function array_last($arr) + { + return $arr[count($arr) - 1]; + } +} + +if(!function_exists('array_split')) { + /** + * 将数组切割成几份 + * + * @param array $arr 需要分割的数组 + * @param int $chunk_count 需要分割成几份 + * + * @return array + */ + function array_split(array $arr, int $chunk_count) + { + $total = count($arr); + if($chunk_count >= $total) { + return array_chunk($arr, 1); + } + $remainder = array_splice($arr, 0, $total % $chunk_count); + $chunks = array_chunk($arr, (int)($total / $chunk_count)); + $i = 0; + while($remainder) { + array_push($chunks[$i++], array_shift($remainder)); + } + + return $chunks; + } +} + +if(!function_exists('array_get_by_keys')) { + function array_get_by_keys($arr, $keys = []) + { + if(!$keys) { + return $arr; + } + $tmp = []; + foreach($keys as $key) { + $tmp[$key] = $arr[$key] ?? null; + } + + return $tmp; + } +} + +if(!function_exists('array_remove')) { + function array_remove($arr, $del) + { + if(($key = array_search($del, $arr)) !== false) { + unset($arr[$key]); + } + + return array_merge($arr); + } +} + +if(!function_exists('array_get_node')) { + function array_get_node($key, $arr = []) + { + $path = explode('.', $key); + foreach($path as $key) { + $key = trim($key); + if(empty($arr) || !isset($arr[$key])) { + return null; + } + $arr = $arr[$key]; + } + + return $arr; + } +} + +if(!function_exists('array_remove_keys_not_in')) { + function array_remove_keys_not_in($arr, $keys) + { + return array_remove_keys($arr, array_diff(array_keys($arr), $keys)); + } +} + +if(!function_exists('array_remove_keys')) { + function array_remove_keys($arr, $keys) + { + foreach($keys as $key) { + unset($arr[$key]); + } + + return $arr; + } +} + +if(!function_exists('array_overlay')) { + /** + * 数组合并: 使用 $source 中的值覆盖 $target 中的值 + * + * @param array $source + * @param array $target + * + * @return array + */ + function array_overlay($source, $target) + { + if(!is_array($source)) { + return $target; + } + foreach($source as $key => $val) { + if(!is_array($val) || !isset($target[$key]) || !is_array($target[$key])) { + $target[$key] = $val; + } else { + $target[$key] = array_overlay($val, $target[$key]); + } + } + + return $target; + } +} diff --git a/src/base-utils/src/Helper/common.php b/src/base-utils/src/Helper/common.php new file mode 100644 index 0000000..067cbc6 --- /dev/null +++ b/src/base-utils/src/Helper/common.php @@ -0,0 +1,810 @@ + $value) { + //如果pid这个节点存在 + if (isset($items[$value[$pid_key]])) { + $items[$value[$pid_key]][$children_key][] = &$items[$key]; + } else { + $tree[] = &$items[$key]; + } + } + + return $tree; + } +} + +if (!function_exists('generate_checkbox_tree')) { + function generate_checkbox_tree(array $array, array $checked_arr = [], $pid_key = 'pid', $id_key = 'id', $label_key = 'label') + { + $parents = []; + //第一步 构造数据 + $items = []; + foreach ($array as $value) { + $items[$value[$id_key]] = [ + 'label' => $value[$label_key], + 'value' => $value[$id_key], + ]; + if ($value[$pid_key] > 0) { + $parents[$value[$id_key]] = $value[$pid_key]; + } else { + $items[$value[$id_key]]['checkAll'] = false; + $items[$value[$id_key]]['isIndeterminate'] = false; + $items[$value[$id_key]]['checkList'] = in_array($value[$id_key], $checked_arr) ? [$value[$id_key]] : []; + } + } + //第二部 遍历数据 生成树状结构 + $tree = []; + foreach ($items as $key => $value) { + $pid = $parents[$value['value']] ?? 0; + //如果pid这个节点存在 + if (isset($items[$pid])) { + $items[$pid]['options'][] = &$items[$key]; + if (in_array($key, $checked_arr)) { + $items[$pid]['checkList'][] = $key; + $items[$pid]['isIndeterminate'] = true; + if (count($items[$pid]['checkList']) - 1 == count($items[$pid]['options'])) { + $items[$pid]['checkAll'] = true; + $items[$pid]['isIndeterminate'] = false; + } + } + } else { + $tree[] = &$items[$key]; + } + } + + return $tree; + } +} + +if (!function_exists('data_desensitization')) { + /** + * 数据脱敏 + * + * @param string $string 需要脱敏值 + * @param int $first_length 保留前n位 + * @param int $last_length 保留后n位 + * @param string $re 脱敏替代符号 + * + * @return bool|string + * 例子: + * data_desensitization('18811113683', 3, 4); //188****3683 + * data_desensitization('王富贵', 0, 1); //**贵 + */ + function data_desensitization($string, $first_length = 0, $last_length = 0, $re = '*') + { + if (empty($string) || $first_length < 0 || $last_length < 0) { + return $string; + } + $str_length = mb_strlen($string, 'utf-8'); + $first_str = mb_substr($string, 0, $first_length, 'utf-8'); + $last_str = mb_substr($string, -$last_length, $last_length, 'utf-8'); + if ($str_length <= 2 && $first_length > 0) { + $replace_length = $str_length - $first_length; + + return $first_str . str_repeat($re, $replace_length > 0 ? $replace_length : 0); + } elseif ($str_length <= 2 && $first_length == 0) { + $replace_length = $str_length - $last_length; + + return str_repeat($re, $replace_length > 0 ? $replace_length : 0) . $last_str; + } elseif ($str_length > 2) { + $replace_length = $str_length - $first_length - $last_length; + + return $first_str . str_repeat("*", $replace_length > 0 ? $replace_length : 0) . $last_str; + } + if (empty($string)) { + return $string; + } + } +} + +if (!function_exists('yuan2fen')) { + /** + * 转换价格到元, 保留 2 位小数 + * + * @param $yuan + * + * @return string + */ + function yuan2fen($yuan) + { + return (int)round((float)$yuan * 100); + } +} + +if (!function_exists('fen2yuan')) { + /** + * 转换价格到分 + * + * @param $fen + * + * @return int + */ + function fen2yuan($fen) + { + return sprintf('%.2f', (int)$fen / 100); + } +} + +// 请谨慎使用该函数,确保输入的目录的正确性 +if (!function_exists('rmdir_recursive')) { + function rmdir_recursive($dir) + { + if (!$dir || $dir === '/' || $dir === '.') { + return false; + } + $it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); + $it = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + rmdir($dir); + } +} + +if (!function_exists('get_img_ratio')) { + function get_img_ratio($img) + { + [$width, $height] = getimagesize($img); + + return number_format($width / $height, 2); + } +} + +if (!function_exists('encrypt')) { + function encrypt($txt, $key = 'mengtui') + { + $chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-=+"; + //$nh = rand(0,64); + $nh = strlen($txt) % 65; + $ch = $chars[$nh]; + $mdKey = md5($key . $ch); + $mdKey = substr($mdKey, $nh % 8, $nh % 8 + 7); + $txt = base64_encode($txt); + $tmp = ''; + $i = 0; + $j = 0; + $k = 0; + for ($i = 0; $i < strlen($txt); $i++) { + $k = $k == strlen($mdKey) ? 0 : $k; + $j = ($nh + strpos($chars, $txt[$i]) + ord($mdKey[$k++])) % 64; + $tmp .= $chars[$j]; + } + + return str_replace(['+', '='], ['_', '.'], $ch . $tmp); + } +} + +if (!function_exists('decrypt')) { + function decrypt($txt, $key = 'mengtui') + { + $txt = str_replace(['_', '.'], ['+', '='], $txt); + $chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-=+"; + $ch = $txt[0]; + $nh = strpos($chars, $ch); + $mdKey = md5($key . $ch); + $mdKey = substr($mdKey, $nh % 8, $nh % 8 + 7); + $txt = substr($txt, 1); + $tmp = ''; + $i = 0; + $j = 0; + $k = 0; + for ($i = 0; $i < strlen($txt); $i++) { + $k = $k == strlen($mdKey) ? 0 : $k; + $j = strpos($chars, $txt[$i]) - $nh - ord($mdKey[$k++]); + while ($j < 0) { + $j += 64; + } + $tmp .= $chars[$j]; + } + + return base64_decode($tmp); + } +} + +if (!function_exists('is_valid_date')) { + function is_valid_date($date_str) + { + return $date_str !== '0000-00-00 00:00:00'; + } +} + +if (!function_exists('str_var_replace')) { + function str_var_replace($str, $data) + { + preg_match_all('/{([\s\S]*?)}/', $str, $match); + $values = []; + $vars = []; + foreach (($match && $match[1] ? $match[1] : []) as $item) { + $vars[] = '{' . $item . '}'; + $values[$item] = Arr::get($data, $item); + } + + return str_replace($vars, $values, $str); + } +} + +if (!function_exists('convert_memory')) { + function convert_memory($size) + { + $unit = ['b', 'kb', 'mb', 'gb', 'tb', 'pb']; + + return @round($size / pow(1024, ($i = floor(log($size, 1024)))), 2) . ' ' . $unit[$i]; + } +} + +if (!function_exists('read_file')) { + function read_file($file) + { + $f = fopen($file, 'r'); + try { + while ($line = fgets($f)) { + yield $line; + } + } finally { + is_resource($f) && fclose($f); + } + } +} + +if (!function_exists('get_extension')) { + function get_extension($file) + { + return substr(strrchr($file, '.'), 1); + } +} + +if (!function_exists('get_class_method_params_name')) { + function get_class_method_params_name($object, $method) + { + $ref = new \ReflectionMethod($object, $method); + $params_name = []; + foreach ($ref->getParameters() as $item) { + $params_name[] = $item->getName(); + } + + return $params_name; + } +} + +if (!function_exists('tree_2_paths')) { + function tree_2_paths($tree, $pre_key = '', $id_key = 'value', $children_key = 'children') + { + $arr_paths = []; + foreach ($tree as $node) { + $now_key = $pre_key ? $pre_key . '-' . $node[$id_key] : $node[$id_key]; + if (!empty($node['children']) && is_array($node['children'])) { + $arr = tree_2_paths($node['children'], $now_key, $id_key, $children_key); + $arr_paths = array_merge($arr_paths, $arr); + } else { + $arr_paths[$now_key] = $node[$id_key]; + } + } + + return $arr_paths; + } +} + +if (!function_exists('getFilePath')) { + /** + * 生成csv文件的路径名,如果文件夹不存在,生成相应路径文件夹 + * + * @param int $id 如marketing_task_id,user_group_id + * @param string $relative_path 生成的文件相对路径 + * @param int $mode 生成的文件目录的权限 + * @param string $type_name 生成的文件的类型名,作更详细的区分 + * @param bool $is_file_name 是否需要返回文件名 + * @param string $filename 文件名 + * + * @return mixed 组装好的文件绝对路径 + */ + function getFilePath($id, $relative_path = '/runtime', $mode = 0755, $type_name = '', $is_file_name = false, $filename = '') + { + $env = config('app_env'); + $filename = $filename ?: sprintf('%s_%s_%s_%s.csv', date('YmdHis'), $env, $id, $type_name); + $path = BASE_PATH . $relative_path; + if ((!file_exists($path)) && (!mkdir($path, $mode, true))) { + return false; + } + if ($is_file_name) { + return ['filename' => $filename, 'path' => $path . '/' . $filename]; + } + + return $path . '/' . $filename; + } +} + +if (!function_exists('http_build_url')) { + function http_build_url($url_arr) + { + $new_url = $url_arr['scheme'] . "://" . $url_arr['host']; + if (!empty($url_arr['port'])) { + $new_url = $new_url . ":" . $url_arr['port']; + } + $new_url = $new_url . $url_arr['path']; + if (!empty($url_arr['query'])) { + $new_url = $new_url . "?" . $url_arr['query']; + } + if (!empty($url_arr['fragment'])) { + $new_url = $new_url . "#" . $url_arr['fragment']; + } + + return $new_url; + } +} + +if (!function_exists('replace_url_query')) { + function replace_url_query($url, array $query) + { + $parse = parse_url($url); + parse_str($parse['query'], $p); + $parse['query'] = urldecode(http_build_query(array_merge($p, $query))); + + return http_build_url($parse); + } +} + +if (!function_exists('container')) { + function container(string $id = '') + { + $container = ApplicationContext::getContainer(); + if (!$id) { + return $container; + } + + return $container->get($id); + } +} + +if (!function_exists('id_gen')) { + function id_gen() + { + return container(IdGeneratorInterface::class)->generate(); + } +} + +if (!function_exists('id_degen')) { + function id_degen(int $id) + { + return container(IdGeneratorInterface::class)->degenerate($id); + } +} + +if (!function_exists('format_time')) { + function format_time($time) + { + $output = ''; + foreach ( + [ + 86400 => '天', + 3600 => '小时', + 60 => '分', + 1 => '秒', + ] as $key => $value + ) { + if ($time >= $key) { + $output .= floor($time / $key) . $value; + } + $time %= $key; + } + + return $output; + } +} + +if (!function_exists('my_json_decode')) { + function my_json_decode($json, $default = []) + { + if (!$json) { + return $default; + } + $json = preg_replace('@//[^"]+?$@mui', '', $json); + $json = preg_replace('@^\s*//.*?$@mui', '', $json); + $json = $json ? @json_decode($json, true) : $default; + if (is_null($json)) { + $json = $default; + } + + return $json; + } +} + +if (!function_exists('is_real_array')) { + /** + * 检测是否是一个真实的类C的索引数组 + */ + function is_real_array($arr) + { + if (!is_array($arr)) { + return false; + } + $n = count($arr); + for ($i = 0; $i < $n; $i++) { + if (!isset($arr[$i])) { + return false; + } + } + + return true; + } +} + +if (!function_exists('is_map_array')) { + /** + * 关联数组 + */ + function is_map_array($arr) + { + if (!is_array($arr)) { + return false; + } + $keys = array_keys($arr); + foreach ($keys as $item) { + if (is_numeric($item)) { + return false; + } + } + + return true; + } +} + +if (!function_exists('get_millisecond')) { + function get_millisecond() + { + return round(microtime(true) * 1000); + } +} + +if (!function_exists('is_production')) { + function is_production() + { + $env = env('ENV', ''); + + return $env == 'production' || $env == 'prod'; + } +} + +if (!function_exists('is_staging')) { + /** + * @return bool + */ + function is_staging() + { + $env = env('ENV', ''); + + return $env == 'staging' || $env == 'pre'; + } +} + +if (!function_exists('is_test')) { + function is_test() + { + return env('ENV') == 'test'; + } +} + +if (!function_exists('is_dev')) { + function is_dev() + { + return env('ENV') == 'dev'; + } +} + +if (!function_exists('generate_random_str')) { + function generate_random_str($length = 12, $prefix = '') + { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $random_str = ''; + for ($i = 0; $i < $length; $i++) { + $random_str .= $characters[rand(0, strlen($characters) - 1)]; + } + + return trim($prefix) . $random_str; + } +} + +if (!function_exists('get_dir_filename')) { + function get_dir_filename($dir, $extension = '', $full_path = false) + { + $handler = opendir($dir); + $files = []; + while (($filename = readdir($handler)) !== false) { + $filter_extension = $extension === '' ? true : strpos($filename, $extension); + if (!($filename !== "." && $filename !== ".." && $filter_extension)) { + continue; + } + if ($full_path) { + $files[] = realpath($dir) . '/' . $filename; + } else { + $files[] = $filename; + } + } + closedir($handler); + + return $files; + } +} + +if (!function_exists('sp_encrypt')) { + function sp_encrypt($plaintext, $key) + { + $key = substr(sha1($key, true), 0, 16); + $iv = openssl_random_pseudo_bytes(16); + $ciphertext = openssl_encrypt($plaintext, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv); + $ciphertext_base64 = urlsafe_b64encode($iv . $ciphertext); + + return $ciphertext_base64; + } +} + +if (!function_exists('sp_decrypt')) { + function sp_decrypt($ciphertext_base64, $key) + { + $key = substr(sha1($key, true), 0, 16); + $ciphertext_dec = urlsafe_b64decode($ciphertext_base64); + $iv_size = 16; + $iv_dec = substr($ciphertext_dec, 0, $iv_size); + $ciphertext_dec = substr($ciphertext_dec, $iv_size); + $plaintext_dec = openssl_decrypt($ciphertext_dec, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv_dec); + + return $plaintext_dec; + } +} + +if (!function_exists('urlsafe_b64encode')) { + function urlsafe_b64encode($string) + { + $data = base64_encode($string); + $data = str_replace([ + '+', + '/', + // '=', + ], [ + '-', + '_', + // '', + ], $data); + + return $data; + } +} + +if (!function_exists('urlsafe_b64decode')) { + function urlsafe_b64decode($string) + { + $data = str_replace([ + '-', + '_', + ], [ + '+', + '/', + ], $string); + $mod4 = strlen($data) % 4; + if ($mod4) { + $data .= substr('====', $mod4); + } + + return base64_decode($data); + } +} + +if (!function_exists('csv_big_num')) { + function csv_big_num($num) + { + if (!is_numeric($num)) { + return $num; + } + + return "{$num}\t"; + } +} + +if (!function_exists('num_zone')) { + function num_zone($num1, $num2) + { + $min = min($num1, $num2); + $max = max($num1, $num2); + + return $max == $min ? $min : ($min . ' ~ ' . $max); + } +} + +if (!function_exists('logger')) { + function logger() + { + return container(LoggerFactory::class); + } +} + +if (!function_exists('request')) { + function request() + { + return container(RequestInterface::class); + } +} + +if (!function_exists('response')) { + function response() + { + return container(ResponseInterface::class); + } +} + +if (!function_exists('cookie')) { + /** + * 快捷方式,返回 request 相关 cookie + * + * @param string $key + * + * @return mixed + */ + function cookie(string $key = '') + { + if (!ApplicationContext::hasContainer()) { + return []; + } + $cookies = container(RequestInterface::class)->getCookieParams(); + if (empty($key)) { + return $cookies; + } + + return $cookies[$key] ?? ''; + } +} + +if (!function_exists('request_header')) { + /** + * 快捷方式,返回 request 相关 header + * + * @param string $key + * + * @return mixed + */ + function request_header(string $key = '') + { + if (!ApplicationContext::hasContainer()) { + return []; + } + $headers = container(RequestInterface::class)->getHeaders(); + if (empty($key)) { + return $headers; + } + + return $headers[$key] ?? ''; + } +} + +if (!function_exists('download')) { + /** + * 下载文件 + * + * @param string $url + * @param string $file_path + * @param array $http_options + * + * @return bool + */ + function download($url, $file_path, $http_options = []) + { + try { + $client = make(Client::class); + $client->get($url, array_merge([ + 'verify' => false, + 'decode_content' => false, + 'timeout' => 600, + 'sink' => $file_path, + ], $http_options)); + + return file_exists($file_path); + } catch (\Throwable $exception) { + logger()->get('download')->error((string)$exception); + + return false; + } + } +} + +if (!function_exists('runtime_path')) { + function runtime_path($path) + { + return BASE_PATH . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . $path; + } +} + +if (!function_exists('is_cli')) { + /** + * 判断是否是命令行环境 + * + * @return bool + */ + function is_cli() + { + return PHP_SAPI === 'cli'; + } +} + +if (!function_exists('xml2array')) { + function xml2array($xml_string, $key = '') + { + if (strpos($xml_string, '<') === false) { + return []; + } + $array = (array)@simplexml_load_string($xml_string, 'SimpleXMLElement', LIBXML_NOCDATA); + if (!$key) { + return $array; + } + + return array_get_node($key, $array); + } +} + +if (!function_exists('get_week_day_by_timestamp')) { + //获取星期几 + function get_week_day_by_timestamp($timestamp) + { + if (!$timestamp) { + return ''; + } + static $weeks = [ + '天', + '一', + '二', + '三', + '四', + '五', + '六', + ]; + + return '星期' . $weeks[date('w', $timestamp)]; + } +} + +if (!function_exists('now')) { + /** + * 获取当前时间 + * + * @param string $format + * + * @return false|string + */ + function now($format = 'Y-m-d H:i:s') + { + return date($format); + } +} + +if (!function_exists('is_json_str')) { + function is_json_str($string) + { + json_decode($string); + return (json_last_error() == JSON_ERROR_NONE); + } +} diff --git a/src/base-utils/src/Helper/constants.php b/src/base-utils/src/Helper/constants.php new file mode 100644 index 0000000..580fb85 --- /dev/null +++ b/src/base-utils/src/Helper/constants.php @@ -0,0 +1,6 @@ +load(); +} + +if(!function_exists('server')) { + function server() + { + return container(ServerFactory::class); + } +} + +if(!function_exists('swoole_server')) { + /** + * @return \Swoole\Server + */ + function swoole_server() + { + return server()->getServer()->getServer(); + } +} + +if(!function_exists('dispatcher')) { + /** + * @param $server_name + * + * @return \FastRoute\Dispatcher + */ + function dispatcher(string $server_name = 'http') + { + return container(DispatcherFactory::class)->getDispatcher($server_name); + } +} + +if(!function_exists('register_route')) { + function register_route($prefix, $controller, $callable = null) + { + Router::addGroup($prefix, function () use ($controller, $callable) { + Router::get('/list.json', [$controller, 'info']); + Router::get('/form.json', [$controller, 'form']); + Router::get('/{id:\d+}.json', [$controller, 'edit']); + Router::get('/info', [$controller, 'info']); + Router::get('/form', [$controller, 'form']); + Router::get('/{id:\d+}', [$controller, 'edit']); + Router::get('/list', [$controller, 'list']); + Router::post('/form', [$controller, 'save']); + Router::post('/delete', [$controller, 'delete']); + Router::post('/batchdel', [$controller, 'batchDelete']); + Router::post('/{id:\d+}', [$controller, 'save']); + Router::post('/rowchange/{id:\d+}', [$controller, 'rowChange']); + Router::get('/childs/{id:\d+}', [$controller, 'getTreeNodeChilds']); + Router::get('/newversion/{id:\d+}/{last_ver_id:\d+}', [ + $controller, + 'newVersion', + ]); + Router::post('/export', [$controller, 'export']); + Router::get('/act', [$controller, 'act']); + Router::post('/import', [$controller, 'import']); + is_callable($callable) && $callable($controller); + }); + } +} + +if(!function_exists('move_local_file_to_oss')) { + function move_local_file_to_oss($local_file_path, $oss_file_path, $private = false, $bucket = 'aliyuncs') + { + /** @var AliyunOSS $oss */ + $oss = make(AliyunOSS::class, ['bucket' => $bucket]); + try { + $method = $private ? 'uploadPrivateFile' : 'uploadFile'; + $oss->$method($oss_file_path, $local_file_path); + $file_path = config('storager.aliyuncs.cdn') . '/' . $oss_file_path; + if($private) { + $file_path = oss_private_url($oss_file_path, MINUTE * 5, $bucket); + } + + return [ + 'file_path' => $file_path, + 'path' => 'oss/' . $oss_file_path, + ]; + } catch (OssException $exception) { + Log::get('move_local_file_to_oss')->error($exception->getMessage()); + + return false; + } + } +} + +if(!function_exists('oss_private_url')) { + function oss_private_url($oss_file_path, $timeout = 60, $bucket = 'aliyuncs') + { + /** @var AliyunOSS $oss */ + $oss = make(AliyunOSS::class, ['bucket' => $bucket]); + $key = preg_replace('@^oss/@', '', $oss_file_path); + try { + return str_replace('-internal', '', $oss->getSignUrl($key, $timeout)); + } catch (OssException $exception) { + Log::get('oss_private_url')->error($exception->getMessage()); + + return false; + } + } +} + +if(!function_exists('call_self_api')) { + function call_self_api($api, $params = [], $method = 'GET') + { + $headers = [ + 'X-Real-IP' => '127.0.0.1', + ]; + $info = Guzzle::request($method, "http://127.0.0.1:" . config('server.servers.0.port') . $api, $params, $headers); + + return $info['payload'] ?? []; + } +} + +if(!function_exists('select_options')) { + function select_options($api, array $kws) + { + $ret = []; + $chunk = array_chunk($kws, 100); + foreach($chunk as $part) { + $ret = array_merge($ret, call_self_api($api, ['kw' => implode(',', $part)])); + } + + return $ret; + } +} + +if(!function_exists('process_list_filter')) { + function process_list_filter($processes, $rule) + { + if(!$rule) { + return $processes; + } + if($ignore = $rule['ignore'] ?? false) { + if(is_string($ignore) && $ignore === 'all') { + $processes = []; + } + if(is_array($ignore)) { + $processes = array_filter($processes, function ($item) use ($ignore) { + return !Str::startsWith($item, array_map(function ($each) { + return Str::replaceLast('*', '', $each); + }, $ignore)); + }); + } + } + if($active = $rule['active'] ?? []) { + $processes = array_merge($processes, $active); + } + + return $processes; + } +} + +if(!function_exists('get_sub_dir')) { + function get_sub_dir($dir, $exclude = []) + { + $paths = []; + $dirs = \Symfony\Component\Finder\Finder::create() + ->in($dir) + ->depth('<1') + ->exclude((array)$exclude) + ->directories(); + /** @var SplFileInfo $dir */ + foreach($dirs as $dir) { + $paths[] = $dir->getRealPath(); + } + + return $paths; + } +} + +if(!function_exists('db_complete')) { + function db_complete(array $conf) + { + return array_overlay($conf, [ + 'port' => 3306, + 'driver' => 'mysql', + 'options' => [ + PDO::ATTR_STRINGIFY_FETCHES => false, + ], + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 20, + 'connect_timeout' => 10.0, + 'wait_timeout' => 100, + 'heartbeat' => -1, + 'max_idle_time' => (float)env('DB_MAX_IDLE_TIME', 60), + ], + ]); + } +} + +if (!function_exists('format_exception')) { + function format_exception($throwable) + { + return make(FormatterInterface::class)->format($throwable); + } +} diff --git a/src/base-utils/src/JWT.php b/src/base-utils/src/JWT.php new file mode 100644 index 0000000..73bf7ab --- /dev/null +++ b/src/base-utils/src/JWT.php @@ -0,0 +1,132 @@ + 'HS256', //生成signature的算法 + 'typ' => 'JWT', //类型 + ]; + + //使用HMAC生成信息摘要时所使用的密钥 + private static $key = 'ha-jwt'; + + /** + * 获取jwt token + * + * @param array $payload jwt载荷 格式如下非必须 + * [ + * 'iss'=>'jwt_admin', //该JWT的签发者 + * 'iat'=>time(), //签发时间 + * 'exp'=>time()+7200, //过期时间 + * 'nbf'=>time()+60, //该时间之前不接收处理该Token + * 'sub'=>'www.xxx.com', //面向的用户 + * 'jti'=>md5(uniqid('JWT').time()) //该Token唯一标识 + * ] + * + * @return bool|string + */ + public static function token(array $payload) + { + if(!is_array($payload)) { + return false; + } + $base64_header = self::base64UrlEncode(json_encode(self::$header, JSON_UNESCAPED_UNICODE)); + $base64_payload = self::base64UrlEncode(json_encode($payload, JSON_UNESCAPED_UNICODE)); + $token = $base64_header . '.' . $base64_payload . '.' . self::signature($base64_header . '.' . $base64_payload, self::$key, self::$header['alg']); + + return $token; + } + + /** + * 验证token是否有效,默认验证exp,nbf,iat时间 + * + * @param string $token 需要验证的token + * + * @return bool|string + */ + public static function verifyToken(string $token) + { + $tokens = explode('.', $token); + if(count($tokens) != 3) { + return false; + } + [$base64_header, $base64_payload, $sign] = $tokens; + //获取jwt算法 + $base64_decode_header = json_decode(self::base64UrlDecode($base64_header), true); + if(empty($base64_decode_header['alg'])) { + return false; + } + //签名验证 + if(self::signature($base64_header . '.' . $base64_payload, self::$key, $base64_decode_header['alg']) !== $sign) { + return false; + } + $payload = json_decode(self::base64UrlDecode($base64_payload), true); + //签发时间大于当前服务器时间验证失败 + if(isset($payload['iat']) && $payload['iat'] > time()) { + return false; + } + //过期时间小于当前服务器时间验证失败 + if(isset($payload['exp']) && $payload['exp'] < time()) { + return false; + } + //该nbf时间之前不接收处理该Token + if(isset($payload['nbf']) && $payload['nbf'] > time()) { + return false; + } + + return $payload; + } + + /** + * base64UrlEncode https://jwt.io/ 中base64UrlEncode编码实现 + * + * @param string $input 需要编码的字符串 + * + * @return string + */ + private static function base64UrlEncode(string $input) + { + return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); + } + + /** + * base64UrlEncode https://jwt.io/ 中base64UrlEncode解码实现 + * + * @param string $input 需要解码的字符串 + * + * @return bool|string + */ + private static function base64UrlDecode(string $input) + { + $remainder = strlen($input) % 4; + if($remainder) { + $add_len = 4 - $remainder; + $input .= str_repeat('=', $add_len); + } + + return base64_decode(strtr($input, '-_', '+/')); + } + + /** + * HMACSHA256签名 https://jwt.io/ 中HMACSHA256签名实现 + * + * @param string $input 为base64UrlEncode(header).".".base64UrlEncode(payload) + * @param string $key + * @param string $alg 算法方式 + * + * @return mixed + */ + private static function signature(string $input, string $key, string $alg = 'HS256') + { + $alg_config = [ + 'HS256' => 'sha256', + ]; + + return self::base64UrlEncode(hash_hmac($alg_config[$alg], $input, $key, true)); + } +} diff --git a/src/base-utils/src/Listener/BootAppConfListener.php b/src/base-utils/src/Listener/BootAppConfListener.php new file mode 100644 index 0000000..5ef648e --- /dev/null +++ b/src/base-utils/src/Listener/BootAppConfListener.php @@ -0,0 +1,39 @@ +get(); + + return $ret ? $ret->toArray() : []; + }); + Builder::macro('firstAsArray', function () { + /** @var \Hyperf\Database\Query\Builder $this */ + $ret = $this->first(); + + return $ret ? $ret->toArray() : []; + }); + set_error_handler(function ($level, $message, $file = '', $line = 0, $context = []) { + if(error_reporting() & $level) { + $exception = new \ErrorException($message, 0, $level, $file, $line); + Log::get('php_output_error')->error((string)$exception); + } + }); + } +} diff --git a/src/base-utils/src/Listener/DbQueryExecutedListener.php b/src/base-utils/src/Listener/DbQueryExecutedListener.php new file mode 100644 index 0000000..1aa101c --- /dev/null +++ b/src/base-utils/src/Listener/DbQueryExecutedListener.php @@ -0,0 +1,103 @@ +sql; + } else { + $sql_with_placeholders = str_replace(['%', '?'], [ + '%%', + '%s', + ], $event->sql); + $bindings = $event->connection->prepareBindings($event->bindings); + $pdo = $event->connection->getPdo(); + $sql = vsprintf($sql_with_placeholders, array_map([ + $pdo, + 'quote', + ], $bindings)); + } + // 获取sql类型 + $sql_type = $this->getSqlType($sql); + $name_map = [ + 'select' => function ($sql_str) { + preg_match('/from\s+(\w+)\s?.*/', $sql_str, $match); + + return $match[1] ?? ''; + }, + 'update' => function ($sql_str) { + preg_match('/update\s+(\w+)\s.*/', $sql_str, $match); + + return $match[1] ?? ''; + }, + 'delete' => function ($sql_str) { + preg_match('/delete\s+(\w+)\s.*/', $sql_str, $match); + + return $match[1] ?? ''; + }, + 'insert' => function ($sql_str) { + preg_match('/insert into\s+(\w+)\s.*/', $sql_str, $match); + + return $match[1] ?? ''; + }, + ]; + $table_name = isset($name_map[$sql_type]) ? $name_map[$sql_type](strtolower(str_replace('`', '', $sql))) : ''; + Log::get('sql')->info($event->connectionName, [ + 'database' => $event->connection->getDatabaseName(), + 'type' => $sql_type, + 'table' => $table_name, + 'use_time' => $event->time, + 'sql' => $sql, + ]); + } + } + + /** + * 获取sql类型 + * + * @param string $sql + * + * @return string + */ + protected function getSqlType(string $sql): string + { + $type_map = [ + 'select' => 'select', + 'update' => 'update', + 'delete' => 'delete', + 'insert' => 'insert into', + // more... + ]; + $sql_type = strtolower(explode(' ', trim($sql))[0] ?? ''); + if(isset($type_map[$sql_type])) { + return (string)$sql_type; + } + // 下面原来逻辑不变 + foreach($type_map as $type => $ident) { + if(stripos($sql, $ident) !== false) { + $sql_type = $type; + break; + } + } + + return (string)$sql_type; + } +} diff --git a/src/base-utils/src/Listener/FetchModeListener.php b/src/base-utils/src/Listener/FetchModeListener.php new file mode 100644 index 0000000..f90a496 --- /dev/null +++ b/src/base-utils/src/Listener/FetchModeListener.php @@ -0,0 +1,24 @@ +statement->setFetchMode(PDO::FETCH_ASSOC); + } + } +} diff --git a/src/base-utils/src/Log.php b/src/base-utils/src/Log.php new file mode 100644 index 0000000..65e726f --- /dev/null +++ b/src/base-utils/src/Log.php @@ -0,0 +1,12 @@ +get($name, $group); + } +} diff --git a/src/base-utils/src/Middleware/CorsMiddleware.php b/src/base-utils/src/Middleware/CorsMiddleware.php new file mode 100644 index 0000000..36d3ac7 --- /dev/null +++ b/src/base-utils/src/Middleware/CorsMiddleware.php @@ -0,0 +1,25 @@ +withHeader('Access-Control-Allow-Origin', config('cors.origin', $request->getHeader('origin')[0] ?? '*')) + ->withHeader('Access-Control-Allow-Credentials', 'true') + ->withHeader('Access-Control-Allow-Headers', config('cors.allow_headers', 'Origin,X-Requested-With,Content-Type,Accept,X-HTTP-Method-Override,Cookie,X-Real-Ip')); + Context::set(ResponseInterface::class, $response); + if ($request->getMethod() == 'OPTIONS') { + return $response; + } + return $handler->handle($request); + } +} diff --git a/src/base-utils/src/Middleware/HttpLogMiddleware.php b/src/base-utils/src/Middleware/HttpLogMiddleware.php new file mode 100644 index 0000000..fd697de --- /dev/null +++ b/src/base-utils/src/Middleware/HttpLogMiddleware.php @@ -0,0 +1,69 @@ +getUri()->getPath(); + $uri = $request->getRequestTarget(); + $referer = $request->getServerParams()['remote_addr'] ?? ''; + $referer .= $request->getHeader('referer')[0] ?? ''; + $uriEx = [ + '/', + '/ping', + '/gw-heart', + '/consul-heart', + '/favicon.ico', + ]; + $request_msg = [ + 'uri' => $uri, + 'header' => json_encode($request->getHeaders()), + 'request' => mb_substr($request->getBody()->getContents(), 0, 125), + ]; + if(!in_array($path, $uriEx)) { + Log::get('http')->info('start', $request_msg); + } + $response = $handler->handle($request); + /** @var Dispatched $dispatched */ + $dispatched = $request->getAttribute(Dispatched::class); + //获取method + $method = $request->getMethod(); + switch($dispatched->status) { + case Dispatcher::NOT_FOUND: + Log::get('http')->warning(sprintf('%s not found', $path)); + break; + case Dispatcher::METHOD_NOT_ALLOWED: + Log::get('http') + ->warning(sprintf('%s method %s not allowed', $path, $request->getMethod())); + break; + case Dispatcher::FOUND: + if(!in_array($path, $uriEx)) { + $response_content = mb_substr($response->getBody()->getContents(), 0, 125); + $msg = [ + 'referer' => $referer, + 'uri' => $request->getRequestTarget(), + 'request' => $request->getBody()->getContents(), + 'use_time' => 1000 * (microtime(true) - $start_time), + 'response' => $response_content, + ]; + // 当路由存在时获取定义路由 + $route = $dispatched->handler->route; + Log::get('http')->info('resume', $msg); + } + break; + } + + return $response; + } +} diff --git a/src/base-utils/src/Model/BaseModel.php b/src/base-utils/src/Model/BaseModel.php new file mode 100644 index 0000000..046af65 --- /dev/null +++ b/src/base-utils/src/Model/BaseModel.php @@ -0,0 +1,151 @@ + '启用', + self::STATUS_NOT => '禁用', + ]; + + /** + * @param array $where + * @param null|\Hyperf\Database\Query\Builder $query + * + * @return \Hyperf\Database\Query\Builder + */ + public function where2query($where, $query = null) + { + $query = $query ?? $this->newQuery(); + if (!$where) { + return $query; + } + $boolean = strtolower($where['__logic'] ?? 'and'); + unset($where['__logic']); + foreach ($where as $key => $item) { + if (is_numeric($key) && is_array($item)) { + $query->where(function ($query) use ($item) { + return $this->where2query($item, $query); + }, null, null, $boolean); + continue; + } + if (!is_array($item)) { + $query->where($key, '=', $item, $boolean); + continue; + } + if (is_real_array($item)) { + $query->whereIn($key, $item, $boolean); + continue; + } + foreach ($item as $op => $val) { + if ($op == 'not in' || $op == 'not_in') { + $query->whereNotIn($key, $val, $boolean); + continue; + } + if ($op == 'like') { + $query->where($key, 'like', $val, $boolean); + continue; + } + if ($op == 'between') { + $query->whereBetween($key, $val, $boolean); + continue; + } + if ($op == 'find_in_set') { // and or + $query->where(function ($q) use ($val, $key) { + if (!is_array($val)) { + $val = ['values' => $val, 'operator' => 'and']; + } + $operator = $val['operator']; + $method = ($operator === 'or' ? 'or' : '') . "whereRaw"; + foreach ($val['values'] as $set_val) { + $q->{$method}("find_in_set({$set_val}, {$key})"); + } + }); + continue; + } + $query->where($key, $op, $val, $boolean); + } + } + return $query; + } + + /** + * select options 通用搜索底层方法 + * + * @param array $attr + * @param array $extra_where + * @param string $name_key + * @param string|integer $id_key + * @param string $logic + * @param bool $default_query + * + * @return array + */ + public function search($attr, $extra_where = [], $name_key = 'name', $id_key = 'id', $logic = 'and', $default_query = false) + { + $where = []; + $kw = request()->input('kw'); + if ($kw) { + if (preg_match_all('/^\d+$/', $kw)) { + $where[$id_key] = $kw; + } elseif (preg_match_all('/^\d+?,/', $kw)) { + $where[$id_key] = explode(',', $kw); + } else { + $where[$name_key] = ['like' => "%{$kw}%"]; + } + } + $id = request()->input('id'); + if ($id) { + if (preg_match_all('/^\d+$/', $id)) { + $where[$id_key] = $id; + } elseif (preg_match_all('/^\d+?,/', $id)) { + $where[$id_key] = explode(',', $id); + } + } + if (!$default_query && !$where) { + return []; + } + $where['__logic'] = $logic; + $where = array_merge($where, $extra_where); + $attr['limit'] = $attr['limit'] ?? 100; + return $this->list($where, $attr)->toArray(); + } + + public function list($where, array $attr) + { + $query = $this->where2query($where); + if (isset($attr['select'])) { + $query = $query->select($attr['select']); + } + if (isset($attr['select_raw'])) { + $query = $query->selectRaw($attr['select_raw']); + } + $order_by = $attr['order_by'] ?? ''; + if ($order_by) { + $query = $query->orderByRaw($order_by); + } + if (isset($attr['limit'])) { + $query = $query->limit($attr['limit']); + } + return $query->get(); + } +} diff --git a/src/base-utils/src/Model/EsBaseModel.php b/src/base-utils/src/Model/EsBaseModel.php new file mode 100644 index 0000000..22229ad --- /dev/null +++ b/src/base-utils/src/Model/EsBaseModel.php @@ -0,0 +1,609 @@ +create(); + $server_info = config('es.' . $this->connection); + if (!$server_info) { + throw new \Exception(sprintf('elastic connection [%s] not found', $this->connection)); + } + if (!$this->index) { + throw new \Exception(sprintf('elastic index is required')); + } + $this->client = $builder->setHosts([$server_info])->build(); + $this->logger = Log::get('elastic'); + $this->all_query = [ + 'bool' => [ + 'must' => [ + ['match_all' => (object)[]], + ], + 'must_not' => [], + 'should' => [], + ], + ]; + } + + protected $operator_map = [ + '>=' => 'gte', + '>' => 'gt', + '=' => 'eq', + '<=' => 'lte', + '<' => 'lt', + ]; + + public function select($where = [], $attrs = [], $origin_meta = false) + { + $params = [ + 'index' => $this->index, + 'body' => [ + 'query' => $this->where2query($where) ?: $this->all_query, + ], + ]; + $is_scroll = false; + if (isset($attrs['scroll'])) { + $is_scroll = true; + $params['scroll'] = $attrs['scroll']; + } + if (isset($attrs['select']) && $attrs['select'] != '*') { + $params['_source'] = str_replace([' ', '`'], '', $attrs['select']); + } + if (isset($attrs['offset'])) { + $params['body']['from'] = $attrs['offset']; + } + if (isset($attrs['limit'])) { + $params['body']['size'] = $attrs['limit']; + } + if (isset($attrs['order_by'])) { + $order_by = str_replace(['`'], '', $attrs['order_by']); + $order_by = preg_replace('/ +/', ' ', $order_by); + $explode = explode(',', $order_by); + $sorts = []; + foreach ($explode as $item) { + if (Str::contains($item, ['+', '-', '*', '/'])) { + preg_match('/(\w+) ([+\-*\/]) (\w+) (\w+)/', $item, $m); + $sorts[] = [ + '_script' => [ + 'type' => 'number', + 'script' => [ + 'lang' => 'painless', + 'source' => "doc['{$m[1]}'].value {$m[2]} doc['{$m[3]}'].value", + ], + 'order' => $m[4], + ], + ]; + } else { + [ + $order_by_field, + $order_by_type, + ] = explode(' ', trim($item)); + $sorts[] = [ + $order_by_field => ['order' => $order_by_type], + ]; + } + } + $params['body']['sort'] = $sorts; + } + try { + $scroll_id = $attrs['scroll_id'] ?? null; + if (!$scroll_id) { + $res = $this->client->search($params); + } else { + $res = $this->client->scroll([ + 'scroll_id' => $scroll_id, + 'scroll' => $attrs['scroll'], + ]); + } + $nex_scroll_id = $res['_scroll_id'] ?? null; + $list = $res['hits']['hits'] ?? []; + if ($origin_meta) { + return $list; + } + $final = []; + foreach ($list as $item) { + $final[] = $item['_source']; + } + $this->logger->info('select success', ['params' => json_encode($params, JSON_UNESCAPED_UNICODE)]); + if ($is_scroll) { + return [$final, $nex_scroll_id]; + } + return $final; + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s select error', $this->index), [ + 'exception' => $e, + 'params' => $params, + ]); + return []; + } + } + + public function indices() + { + return $this->client->indices(); + } + + public function insert($body) + { + $params = [ + 'index' => $this->index, + 'type' => $this->type ?: $this->index, + 'body' => $body, + ]; + if (isset($body[$this->primaryKey])) { + $params['id'] = $body[$this->primaryKey]; + } + try { + return $this->client->index($params); + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s insert error', $this->index), [ + 'exception' => $e, + 'params' => $params, + ]); + return false; + } + } + + public function batchInsert($docs) + { + foreach ($docs as $doc) { + $index = [ + '_index' => $this->index, + '_type' => $this->type ?: $this->index, + ]; + if (isset($doc[$this->primaryKey])) { + $index['_id'] = $doc[$this->primaryKey]; + } + $params['body'][] = [ + 'index' => $index, + ]; + $params['body'][] = $doc; + } + if (!isset($params)) { + return false; + } + try { + return $this->client->bulk($params); + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s batchInsert error', $this->index), [ + 'exception' => $e, + 'params' => $params, + ]); + return false; + } + } + + public function batchUpdate($docs) + { + foreach ($docs as $doc) { + $index = [ + '_index' => $this->index, + '_type' => $this->type ?: $this->index, + '_retry_on_conflict' => 3, + ]; + if (isset($doc[$this->primaryKey])) { + $index['_id'] = $doc[$this->primaryKey]; + } else { + continue; + } + $params['body'][] = [ + 'update' => $index, + ]; + $params['body'][] = ['doc' => $doc]; + } + if (!isset($params)) { + return false; + } + try { + return $this->client->bulk($params); + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s batchInsert error', $this->index), [ + 'exception' => $e, + 'params' => $params, + ]); + return false; + } + } + + public function batchCreateOrUpdate($docs) + { + $ids = array_column($docs, $this->primaryKey); + if (!$ids) { + return false; + } + $exists = $this->select(['_id' => $ids], [ + 'select' => '_none_', + 'limit' => count($ids), + ], true); + $exist_ids = array_column($exists, '_id'); + $insert_docs = []; + $update_docs = []; + foreach ($docs as $doc) { + if (in_array($doc[$this->primaryKey], $exist_ids)) { + $update_docs[] = $doc; + } else { + $insert_docs[] = $doc; + } + } + $insert = $this->batchInsert($insert_docs); + $update = $this->batchUpdate($update_docs); + return [$insert, $update]; + } + + public function find($id) + { + $params = [ + 'index' => $this->index, + 'type' => $this->type ?: $this->index, + 'id' => $id, + ]; + try { + return $this->client->get($params); + } catch (\Exception $e) { + return false; + } + } + + public function updateById($id, $doc) + { + $update = [ + 'index' => $this->index, + 'type' => $this->type ?: $this->index, + 'id' => $id, + 'body' => [ + 'doc' => $doc, + ], + ]; + try { + return $this->client->update($update); + } catch (Conflict409Exception $e) { + $this->logger->warning(sprintf('elastic index:%s updateById error', $this->index), [ + 'exception' => $e, + 'params' => $update, + ]); + return false; + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s updateById error', $this->index), [ + 'exception' => $e, + 'params' => $update, + ]); + return false; + } + } + + public function update($where, $doc) + { + $docs = $this->select($where, ['select' => 'id,status'], true); + $ret = []; + try { + foreach ($docs as $item) { + $update = [ + 'index' => $item['_index'], + 'type' => $item['_type'], + 'id' => $item['_id'], + 'body' => [ + 'doc' => $doc, + ], + ]; + $ret[$item['_id']] = $this->client->update($update); + } + return $ret; + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s update error', $this->index), [ + 'exception' => $e, + 'where' => $where, + ]); + return false; + } + } + + public function createIndex() + { + $params = [ + 'index' => $this->index, + ]; + try { + if (!$this->client->indices()->exists($params)) { + $ret = $this->client->indices()->create($params); + if ($this->mapping) { + $this->modifyIndex(); + } + return $ret; + } else { + $this->logger->warning(sprintf('elastic index:%s exists', $this->index)); + return true; + } + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s createIndex error', $this->index), [ + 'exception' => $e, + 'params' => $params, + ]); + return false; + } + } + + public function modifyIndex() + { + if (!$this->mapping) { + return false; + } + $params = [ + 'index' => $this->index, + 'type' => $this->type ?: $this->index, + 'body' => [ + $this->index => $this->getIndexDefine(), + ], + ]; + try { + return $this->client->indices()->putMapping($params); + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s modifyIndex error', $this->index), [ + 'exception' => $e, + 'params' => $params, + ]); + return false; + } + } + + public function getIndexDefine() + { + return $this->mapping; + } + + public function deleteIndex() + { + $deleteParams = [ + 'index' => $this->index, + ]; + try { + return $this->client->indices()->delete($deleteParams); + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s deleteIndex error', $this->index), [ + 'exception' => $e, + 'params' => $deleteParams, + ]); + return false; + } + } + + public function getMapping() + { + $params = [ + 'index' => $this->index, + ]; + try { + return $this->client->indices()->getMapping($params); + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s getMapping error', $this->index), [ + 'exception' => $e, + 'params' => $params, + ]); + return false; + } + } + + /** + * 此函数将通用的model where条件平铺, 当前仅支持 and 结合 + * + * @param $where array + * + * @return array + */ + public function makeTile($where) + { + $tmp = []; + foreach ($where as $k => $v) { + if (is_numeric($k)) { + $tmp = array_merge($this->makeTile($v), $tmp); + continue; + } + if (is_string($k)) { + $k = str_replace(['&/!', '&/'], '', $k); + if (is_array($v)) { + foreach ($v as &$vv) { + $vv = str_replace(['&/!', '&/'], '', $vv); + } + unset($v['__logic'], $vv); + } else { + $v = str_replace(['&/!', '&/'], '', $v); + } + $tmp[$k] = $v; + continue; + } + } + unset($tmp['__logic']); + return $tmp; + } + + /** + * 将 通用where条件 转换为 es 查询query + * + * @param $where array + * + * @return array + */ + public function where2query($where) + { + //todo 转换场景尚未完全支持 + $query = []; + $where = $this->makeTile($where); + if ($this->fakeDeleteKey) { + $where[$this->fakeDeleteKey] = 0; + } + foreach ($where as $key => $val) { + if (in_array($key, $this->fuzzy_fields)) { + $kw = $val; + if (is_array($val) && isset($val['like'])) { + $kw = str_replace('%', '', $val['like']); + } + $query['bool']['must'][] = [ + 'match_phrase' => [ + $key => [ + 'query' => $kw, + 'slop' => 1, + ], + ], + ]; + continue; + } + if (in_array($key, $this->range_fields)) { + $is_time_field = in_array($key, $this->datetime_fields); + if (is_array($val)) { + $val_tile = $this->makeTile($val); + $index = count($query['bool']['filter']['bool']['must'] ?? []); + foreach ($val_tile as $operator => $each) { + if (isset($this->operator_map[$operator])) { + $mapping_date_format = $this->mapping['properties'][$key]['format'] ?? null; + $query['bool']['filter']['bool']['must'][$index]['range'][$key][$this->operator_map[$operator]] = $is_time_field ? date($mapping_date_format ? $this->transDateFormat($mapping_date_format) : "Y-m-d\TH:i:s", strtotime($each)) : $each; + } + } + continue; + } + } + if (is_array($val)) { + $query['bool']['filter']['bool']['must'][] = [ + 'terms' => [ + $key => array_map(function ($each) { + return is_numeric($each) ? $each * 1 : $each; + }, $val), + ], + ]; + continue; + } + $query['bool']['filter']['bool']['must'][] = [ + 'term' => [ + $key => is_numeric($val) ? (int)$val : $val, + ], + ]; + } + $this->logger->info(sprintf('elastic %s where2query', $this->index), [ + 'where' => $where, + 'query' => $query, + ]); + return $query; + } + + public function selectCount($where) + { + try { + $query = $this->where2query($where); + $re = $this->_query($query, 0, 1); + return $re['hits']['total'] ?? 0; + } catch (\Exception $e) { + $this->logger->error(sprintf('elastic index:%s selectCount error', $this->index), [ + 'exception' => $e, + 'where' => json_encode($where), + 'params' => json_encode($query), + ]); + return false; + } + } + + public function query($query, $from = null, $size = null) + { + $res = $this->_query($query, $from, $size); + if (empty($res) || empty($res['hits'])) { + return []; + } + $rows = []; + foreach ($res['hits']['hits'] as $row) { + $new_row = []; + foreach ($row['_source'] as $i => $v) { + $new_row[$i] = $v; + } + $rows[] = $new_row; + } + return $rows; + } + + public function count($query) + { + $res = $this->_query($query); + if (empty($res) || empty($res['hits'])) { + return 0; + } + return $res['hits']['total']; + } + + public function _query($query, $from = null, $size = null) + { + $params = [ + 'index' => $this->index, + 'body' => [ + 'query' => $query ?: $this->all_query, + ], + ]; + if (!is_null($from) && !is_null($size)) { + $params['body']['from'] = $from; + $params['body']['size'] = $size; + } + return $this->client->search($params); + } + + public function getLastSql() + { + return $this->lastSql; + } + + public function getPrimaryKey() + { + return $this->primaryKey; + } + + public function transDateFormat($es_format_str) + { + // yyyy-MM-dd HH:mm:ss + return str_replace([ + 'yyyy', + 'MM', + 'dd', + 'HH', + 'mm', + 'ss', + ], [ + 'Y', + 'm', + 'd', + 'H', + 'i', + 's', + ], $es_format_str); + } +} diff --git a/src/base-utils/src/Redis/Redis.php b/src/base-utils/src/Redis/Redis.php new file mode 100644 index 0000000..69b5c92 --- /dev/null +++ b/src/base-utils/src/Redis/Redis.php @@ -0,0 +1,215 @@ +get($name); + } + + public static function set($name, $value, $cluster = 'default') + { + $redis = self::connection($cluster); + $start_time = microtime(true); + $re = $redis->set($name, $value); + $end_time = microtime(true); + Log::get('redis')->debug('set', [ + 'cluster' => $cluster, + 'key' => $name, + 'use_time' => $end_time - $start_time, + 'result' => $re, + ]); + + return $re; + } + + public static function setex($name, $value, $expire, $cluster = 'default') + { + $redis = self::connection($cluster); + $start_time = microtime(true); + $re = $redis->setex($name, $expire * 1, $value); + $end_time = microtime(true); + Log::get('redis')->debug('setex', [ + 'cluster' => $cluster, + 'key' => $name, + 'ttl' => $expire, + 'use_time' => $end_time - $start_time, + 'result' => $re, + ]); + + return $re; + } + + public static function get($name, $cluster = 'default') + { + $redis = self::connection($cluster); + $start_time = microtime(true); + $re = $redis->get($name); + $end_time = microtime(true); + Log::get('redis')->debug('get', [ + 'cluster' => $cluster, + 'key' => $name, + 'use_time' => $end_time - $start_time, + 'result' => $re, + ]); + + return $re; + } + + public static function exists($name, $cluster = 'default') + { + $redis = self::connection($cluster); + $start_time = microtime(true); + $re = $redis->exists($name); + $end_time = microtime(true); + Log::get('redis')->debug('exist', [ + 'cluster' => $cluster, + 'key' => $name, + 'use_time' => $end_time - $start_time, + 'result' => $re, + ]); + + return $re; + } + + public static function incr($key, $cluster = 'default') + { + $redis = self::connection($cluster); + $start_time = microtime(true); + $ret = $redis->incr($key); + $end_time = microtime(true); + Log::get('redis')->debug('incr', [ + 'cluster' => $cluster, + 'key' => $key, + 'use_time' => $end_time - $start_time, + 'result' => $ret, + ]); + + return $ret; + } + + public static function incrBy($key, $value, $cluster = 'default') + { + $redis = self::connection($cluster); + $start_time = microtime(true); + $ret = $redis->incrBy($key, $value); + $end_time = microtime(true); + Log::get('redis')->debug('incrBy', [ + 'cluster' => $cluster, + 'key' => $key, + 'use_time' => $end_time - $start_time, + 'result' => $ret, + ]); + + return $ret; + } + + public static function expire($key, $ttl, $cluster = 'default') + { + $redis = self::connection($cluster); + $start_time = microtime(true); + $ret = $redis->expire($key, $ttl); + $end_time = microtime(true); + Log::get('redis')->debug('expire', [ + 'cluster' => $cluster, + 'key' => $key, + 'use_time' => $end_time - $start_time, + 'result' => $ret, + ]); + + return $ret; + } + + public static function ttl($key, $cluster = 'default') + { + $redis = self::connection($cluster); + $start_time = microtime(true); + $ret = $redis->ttl($key); + $end_time = microtime(true); + Log::get('redis')->debug('ttl', [ + 'cluster' => $cluster, + 'key' => $key, + 'use_time' => $end_time - $start_time, + 'result' => $ret, + ]); + + return $ret; + } + + public static function beyondFrequency($key, $duration, $limit, $cluster = 'default') + { + $num = self::incr($key, $cluster); + if($num == 1) { + self::expire($key, $duration, $cluster); + } + $ttl = self::ttl($key, $cluster); + if($ttl == -1) { + self::expire($key, $duration, $cluster); + } + if($num > $limit) { + return true; + } + + return false; + } + + public static function hGet($key, $hashKey, $cluster = 'default') + { + $redis = self::connection($cluster); + + return $redis->hGet($key, $hashKey); + } + + public static function hSet($key, $hashKey, $value, $cluster = 'default') + { + $redis = self::connection($cluster); + + return $redis->hSet($key, $hashKey, $value); + } + + public static function hGetAll($key, $cluster = 'default') + { + $redis = self::connection($cluster); + + return $redis->hGetAll($key); + } + + public static function hMset($key, $value, $cluster = 'default') + { + $redis = self::connection($cluster); + + return $redis->hMset($key, $value); + } + + /** + * @param string $name + * @param int $expired + * @param mixed $callable + * @param string $cluster + * + * @return array|null + */ + public static function getOrSet(string $name, int $expired, callable $callable, $cluster = 'default') + { + if(self::exists($name)) { + Log::get('redis')->info(sprintf('get %s from cache', $name)); + + return json_decode(self::get($name, $cluster), true); + } + $data = call($callable); + if($data) { + self::setex($name, json_encode($data), $expired, $cluster); + } + + return $data; + } +} diff --git a/src/base-utils/src/Redis/RedisArray.php b/src/base-utils/src/Redis/RedisArray.php new file mode 100644 index 0000000..466d664 --- /dev/null +++ b/src/base-utils/src/Redis/RedisArray.php @@ -0,0 +1,53 @@ +name = 'redis_array_access_' . $name; + $this->redis = container(RedisFactory::class)->get($cluster); + } + + public function offsetExists($offset) + { + return $this->redis->hExists($this->name, (string)$offset); + } + + public function offsetGet($offset) + { + $val = $this->redis->hGet($this->name, (string)$offset); + if(is_json_str($val)) { + return json_decode($val, true); + } + + return $val; + } + + public function offsetSet($offset, $value) + { + return $this->redis->hSet($this->name, (string)$offset, is_array($value) ? json_encode($value) : $value); + } + + public function offsetUnset($offset) + { + return $this->redis->hDel($this->name, (string)$offset); + } + + public function __destruct() + { + //$this->redis->del($this->name); + } + + public function count() + { + return $this->redis->hLen($this->name); + } +} diff --git a/src/base-utils/src/Redis/RedisQueue.php b/src/base-utils/src/Redis/RedisQueue.php new file mode 100644 index 0000000..d7ee8b4 --- /dev/null +++ b/src/base-utils/src/Redis/RedisQueue.php @@ -0,0 +1,146 @@ +queue_name = $queue_name; + $this->cluster = $cluster; + $this->redis = container(RedisFactory::class)->get($cluster); + } + + public function length($name = '') + { + return $this->redis->lLen($name ?: $this->queue_name); + } + + public function push(array $msg) + { + if($this->length() < $this->max) { + return $this->redis->rPush($this->queue_name, json_encode($msg)); + } else { + $that = $this; + + return retry(3, function () use ($that, $msg) { + $that->push($msg); + }, 3); + } + } + + public function pop($filter = []) + { + $un_ack = $this->unAck(); + foreach($un_ack as $index => $item) { + if(($item['_time'] + $this->wait_timeout) < time()) { + unset($item['_time']); + $msg = $item; + $item['_time'] = time(); + $this->redis->lSet($this->getAckQueueName(), $index, json_encode($item)); + if($filter) { + if(array_intersect($item, $filter)) { + return $item; + } + } else { + return $msg; + } + } + } + $msg = $this->getOne($this->queue_name, $filter); + if($msg) { + $msg_id = md5($msg); + $data = json_decode($msg, true); + $ret = array_merge($data, ['_queue_msg_id' => $msg_id]); + $this->redis->rPush($this->getAckQueueName(), json_encode(array_merge($ret, ['_time' => time()]))); + + return $ret; + } + + return null; + } + + public function getOne($queue_name, $filter = []) + { + if(!$filter) { + return $this->redis->lPop($queue_name); + } + $len = $this->redis->lLen($queue_name); + if(!$len) { + return null; + } + foreach(range(0, $len - 1) as $index) { + $ele = $this->redis->lIndex($queue_name, $index); + $ele_array = json_decode($ele, true); + if(array_intersect($ele_array, $filter)) { + $this->redis->lRem($queue_name, $ele, 1); + + return $ele; + } + } + + return null; + } + + protected function clearQueue($queue_name) + { + $length = $this->length($queue_name); + + return $this->redis->ltrim($queue_name, $length + 1, $length + 2); + } + + public function clear() + { + return $this->clearQueue($this->queue_name) && $this->clearQueue($this->getAckQueueName()); + } + + public function getAckQueueName() + { + return $this->queue_name . '_' . $this->queue_wait_ack_suffix; + } + + public function ack($msg_id) + { + $un_ack = $this->unAck(); + $ret = false; + foreach($un_ack as $item) { + if($item['_queue_msg_id'] == $msg_id) { + $ret = $this->redis->lRem($this->getAckQueueName(), json_encode($item), 0); + } + } + + return $ret; + } + + public function unAck() + { + $list = $this->redis->lRange($this->getAckQueueName(), 0, -1); + foreach($list as &$item) { + $item = json_decode($item, true); + } + unset($item); + + return $list; + } + + public function unAckCount() + { + return $this->length($this->getAckQueueName()); + } +} diff --git a/src/base-utils/src/RotatingFileHandler.php b/src/base-utils/src/RotatingFileHandler.php new file mode 100644 index 0000000..e7b47b0 --- /dev/null +++ b/src/base-utils/src/RotatingFileHandler.php @@ -0,0 +1,74 @@ +filename = $filename; + $this->maxFiles = (int)$maxFiles; + $this->nextRotation = new \DateTime(date('YmdH0000', strtotime('+1 hour'))); + $this->dateFormat = 'YmdH'; + $this->filenameFormat = '{filename}-{date}'; + parent::__construct($this->getTimedFilename(), $level, $bubble, $filePermission, $useLocking); + } + + /** + * 修复handler写日志判断级别问题bug + */ + public function isHandling(array $record): bool + { + $level_code = Logger::toMonologLevel($record['level']); + + return $level_code >= $this->level; + } + + protected function rotate() + { + // update filename + $this->url = $this->getTimedFilename(); + $this->nextRotation = new \DateTime(date('YmdH0000', strtotime('+1 hour'))); + // skip GC of old logs if files are unlimited + if (0 === $this->maxFiles) { + return; + } + $logFiles = glob($this->getGlobPattern()); + if ($this->maxFiles >= count($logFiles)) { + // no files to remove + return; + } + // Sorting the files by name to remove the older ones + usort($logFiles, function ($a, $b) { + return strcmp($b, $a); + }); + foreach (array_slice($logFiles, $this->maxFiles) as $file) { + if (is_writable($file)) { + // suppress errors here as unlink() might fail if two processes + // are cleaning up/rotating at the same time + set_error_handler(function ($errno, $errstr, $errfile, $errline) { + }); + unlink($file); + restore_error_handler(); + } + } + $this->mustRotate = false; + } + + protected function getTimedFilename() + { + $fileInfo = pathinfo($this->filename); + $timedFilename = str_replace(['{filename}', '{date}'], [ + $fileInfo['filename'], + date($this->dateFormat), + ], $fileInfo['dirname'] . '/' . $this->filenameFormat); + + if (!empty($fileInfo['extension'])) { + $timedFilename .= '.' . $fileInfo['extension']; + } + + return $timedFilename; + } +} diff --git a/src/base-utils/src/RoutesDispatcher.php b/src/base-utils/src/RoutesDispatcher.php new file mode 100644 index 0000000..ed603a1 --- /dev/null +++ b/src/base-utils/src/RoutesDispatcher.php @@ -0,0 +1,25 @@ +routes = array_unique(array_filter(array_merge($this->routes, $router_files))); + parent::initConfigRoute(); + } +} diff --git a/src/base-utils/src/Scaffold/Controller/AbstractController.php b/src/base-utils/src/Scaffold/Controller/AbstractController.php new file mode 100644 index 0000000..35812f6 --- /dev/null +++ b/src/base-utils/src/Scaffold/Controller/AbstractController.php @@ -0,0 +1,1304 @@ +options = $this->scaffoldOptions(); + } + + /** + * 脚手架配置 + * + * @return array|callable + */ + public function scaffoldOptions() + { + return []; + } + + /** + * 列表页拉取配置接口 + */ + public function info() + { + $tableHeader = array_values(array_filter($this->getListHeader(), function ($item) { + return !($item['hidden'] ?? false); + })); + $filter = $this->getListFilters(); + $actions = $this->options['table']['rowActions'] ?? []; + $actions = $this->buttonConfigConvert($actions); + $topActions = $this->options['table']['topActions'] ?? []; + $topActions = $this->buttonConfigConvert($topActions); + $batchButtons = $this->options['table']['batchButtons'] ?? []; + $batchButtons = $this->buttonConfigConvert($batchButtons); + $enum = $this->options['table']['enum'] ?? []; + $resource = $this->getCalledSource(true); + $tabs = $this->options['table']['tabs'] ?? []; + $info = [ + 'filterRule' => $filter, + 'tableHeader' => $tableHeader, + 'rowActions' => $actions, + 'tableTabs' => is_callable($tabs) ? $tabs() : $tabs, + 'options' => [ + 'form_path' => $this->options['form_path'] ?? '', + 'rowChangeApi' => "/{$resource[0]}/rowchange/{id}", + 'batchButtons' => $batchButtons, + 'enum' => $enum, + 'createAble' => $this->options['createAble'] ?? true, + 'exportAble' => $this->options['exportAble'] ?? true, + 'defaultList' => $this->options['defaultList'] ?? true, + 'importAble' => $this->options['importAble'] ?? false, + 'topActions' => $topActions, + 'tableOptions' => [ + 'style' => $this->options['table']['style'] ?? 'list', + 'group' => $this->options['table']['group'] ?? [], + ], + 'noticeAble' => !empty($this->options['notices'] ?? []), + ], + ]; + if (method_exists($this, 'beforeInfo')) { + $info = $this->beforeInfo($info); + } + return $this->success($info); + } + + public function buttonConfigConvert($config) + { + $buttons = []; + foreach ($config as $key => $item) { + $buttons[$key]['text'] = $item['text'] ?? ''; + $buttons[$key]['type'] = isset($item['target']) ? $item['type'] ?? 'jump' : (isset($item['rules']) ? 'form' : (isset($item['api']) ? 'api' : 'jump')); + $buttons[$key]['target'] = isset($item['target']) ? $item['target'] : (isset($item['api']) ? $item['api'] : ($item['action'] ?? '')); + $buttons[$key]['props'] = isset($item['target']) ? $item['props'] ?? [] : [ + 'icon' => $item['icon'] ?? '', + 'circle' => $item['circle'] ?? false, + 'size' => $item['size'] ?? 'small', + 'type' => $item['type'] ?? '', + ]; + if (isset($item['rules'])) { + $form = $this->formOptionsConvert($item['rules']); + $buttons[$key]['rules'] = $this->formResponse(0, $form); + unset($form); + } + $buttons[$key]['rules']['form_ui'] = $item['formUi'] ?? []; + if (isset($item['when'])) { + $buttons[$key]['when'] = $item['when']; + } + // 批量操作的数据过滤 + if (isset($item['selectFilter'])) { + $buttons[$key]['selectFilter'] = $item['selectFilter']; + } + if (isset($item[0])) { + $buttons[$key] = $this->buttonConfigConvert($item); + } + } + return $buttons; + } + + public function makeWhere() + { + $page = $this->request->input('_page', 1); + $size = $this->request->input('_size', 20); + $table_options = $this->getListHeader(); + $columns = array_unique(array_values(array_map(function ($item) { + return explode('.', $item)[0]; + }, array_column(array_filter($table_options, function ($each) { + return isset($each['virtual_field']) ? !$each['virtual_field'] : true; + }), 'field')))); + $filter_options = $this->getListFilters(); + $filters = []; + if (!empty($filter_options)) { + array_change_v2k($filter_options, 'field'); + foreach ($filter_options as $field => $each) { + $input = $this->request->input($field); + if (in_array($input, [null, ''])) { + continue; + } + // todo field 相同时覆盖的问题 + if (isset($each['filterConvert'])) { + $filters[$each['filterConvert']['field']] = $each['filterConvert']['handel']($input); + } else { + $filters[$field] = $input; + } + } + } + $conditions = $this->options['where'] ?? []; + foreach ($filters as $field => $value) { + switch ($filter_options[$field]['search_type'] ?? '') { + case 'between': + $conditions[$field] = ['between' => $value]; + break; + case 'full_like': + $conditions[$field] = ['like' => "%{$value}%"]; + break; + case 'suffix_like': + $conditions[$field] = ['like' => "{$value}%"]; + break; + case 'prefix_like': + $conditions[$field] = ['like' => "%{$value}"]; + break; + default: + $conditions[$field] = $value; + break; + } + } + $order_by = $this->options['order_by'] ?? ''; + if (empty($conditions) && !($this->options['defaultList'] ?? true)) { + return compact('page', 'size', 'conditions', 'order_by', 'columns', 'table_options'); + } + if (method_exists($this, 'beforeListQuery')) { + $hook_params = get_class_method_params_name($this, 'beforeListQuery'); + if (count($hook_params) == 2) { + $this->beforeListQuery($conditions, $order_by); + } elseif (count($hook_params) === 1) { + $this->beforeListQuery($conditions); + } + } + return compact('page', 'size', 'conditions', 'order_by', 'columns', 'table_options'); + } + + /** + * 列表拉取接口 + */ + public function list() + { + [ + $page, + $size, + $conditions, + $order_by, + $columns, + $tableOptions, + ] = array_values($this->makeWhere()); + $entity = $this->getEntity(); + $count = $entity->count($conditions); + $list = []; + if ($count) { + $attr['select'] = $columns; + $order_by && $attr['order_by'] = $order_by; + $list = $entity->list($conditions, $attr, $page, $size); + } + $list = $this->listFilter($list, $tableOptions); + return $this->success([ + 'list' => $list, + 'total' => $count, + ]); + } + + public function listFilter($list, $table_options) + { + foreach ($this->options['hasOne'] ?? [] as $item) { + $execute = $this->hasOne($list, $item); + $execute && $list = $execute; + } + foreach ($this->options['hasMany'] ?? [] as $item) { + $execute = $this->hasMany($list, $item); + $execute && $list = $execute; + } + if (method_exists($this, 'beforeListResponse')) { + $this->beforeListResponse($list); + } + $is_tree = $this->options['table']['is_tree'] ?? false; + $is_tree && $this->hasChildren($list); + foreach ($table_options as $item) { + if (!isset($item['render'])) { + continue; + } + foreach ($list as &$each) { + if (is_callable($item['render'])) { + $each[$item['field']] = $item['render']($each[$item['field']] ?? null, $each); + } elseif (is_string($item['render']) && method_exists($this, $item['render'])) { + $each[$item['field']] = $this->$item['render']($each[$item['field']] ?? null, $each); + } + unset($each); + } + } + return $list; + } + + /** + * @param array $list + * @param string $has_str [pool.]db.table:[local_key->]foreign_key,other_key + * + * @return mixed + */ + public function hasOne($list, $has_str) + { + $explode = $this->explodeHasStr($has_str); + if (!$explode) { + return false; + } + [ + $pool, + $db, + $table, + $local_key, + $foreign_key, + $columns, + $default, + ] = $explode; + $where = array_filter(array_column($list, $local_key)); + if (!$where) { + return false; + } + $ret = Db::connection($pool)->table("{$db}.{$table}")->whereIn($foreign_key, $where)->get($columns)->toArray(); + array_change_v2k($ret, $foreign_key); + foreach ($list as &$item) { + $append = isset($ret[$item[$local_key]]) ? $ret[$item[$local_key]] : $default; + unset($append[$foreign_key]); + $item = array_merge($item, $append); + } + unset($item); + return $list; + } + + public function hasMany($list, $has_str) + { + $explode = $this->explodeHasStr($has_str); + if (!$explode) { + return false; + } + [ + $pool, + $db, + $table, + $local_key, + $foreign_key, + $columns, + $default, + ] = $explode; + $where = array_column($list, $local_key); + if (!$where) { + return false; + } + $ret = Db::connection($pool)->table("{$db}.{$table}")->whereIn($foreign_key, $where)->get($columns)->toArray(); + $ret = array_group_by($ret, $foreign_key); + foreach ($list as &$item) { + $group = isset($ret[$item[$local_key]]) ? $ret[$item[$local_key]] : $default; + $append = []; + foreach (array_keys($default) as $field) { + $append[$field] = array_values(array_unique(array_filter(array_column($group, $field)))); + } + unset($append[$foreign_key]); + $item = array_merge($item, $append); + } + unset($item); + return $list; + } + + public function explodeHasStr($has_str) + { + $check = preg_match('/([a-zA-Z_]+\.)?([a-zA-Z_]+)\.([a-zA-Z_]+):([a-zA-Z_0-9]+->)?([a-zA-Z_,0-9 ]+)/', $has_str, $match); + if ($check === 0) { + return false; + } + [ + $str, + $pool, + $db, + $table, + $local_key, + $foreign_key, + ] = array_map(function ($item) { + return str_replace(['.', '->'], '', $item); + }, $match); + $pool = $pool ? $pool : 'default'; + $local_key = $local_key ? $local_key : 'id'; + $columns = explode(',', $foreign_key); + if (!$columns) { + return false; + } + $foreign_key = $columns[0]; + $default = []; + foreach ($columns as $each) { + $default[trim(preg_replace('/[\w ]+as +/i', '', trim($each)))] = null; + } + return array_values(compact('pool', 'db', 'table', 'local_key', 'foreign_key', 'columns', 'default')); + } + + public function getTreeNodeChilds($id) + { + $tableOptions = $this->getListHeader(); + $columns = array_unique(array_values(array_map(function ($item) { + return explode('.', $item)[0]; + }, array_column(array_filter($tableOptions, function ($each) { + return isset($each['virtual_field']) ? !$each['virtual_field'] : true; + }), 'field')))); + $order_by = $this->options['order_by'] ?? ''; + $attr['select'] = $columns; + $order_by && $attr['order_by'] = $order_by; + $childs = $this->getEntity()->list(['pid' => $id], $attr); + foreach ($tableOptions as $item) { + if (!isset($item['render'])) { + continue; + } + foreach ($childs as &$each) { + if (is_callable($item['render'])) { + $each[$item['field']] = $item['render']($each[$item['field']] ?? null, $each); + } elseif (is_string($item['render']) && method_exists($this, $item['render'])) { + $each[$item['field']] = $this->$item['render']($each[$item['field']] ?? null, $each); + } + unset($each); + } + } + $is_tree = $this->options['table']['is_tree'] ?? false; + $is_tree && $this->hasChildren($childs); + if (method_exists($this, 'beforeListResponse')) { + $this->beforeListResponse($childs); + } + return $this->success([ + 'childs' => $childs, + ]); + } + + public function hasChildren(&$list) + { + if (!$list) { + return; + } + $pk = $this->getPk(); + $ids = array_column($list, $pk); + $parent_key = $this->options['table']['tree']['pid'] ?? 'pid'; + $childs = $this->getEntity()->list([ + $parent_key => $ids, + 'status' => 1, + ], [ + 'select' => [$parent_key, "count(*) as hasChildren"], + 'group_by' => $parent_key, + ]); + array_change_v2k($childs, $parent_key); + foreach ($list as &$item) { + $item['hasChildren'] = (bool)($childs[$item[$pk]] ?? false); + unset($item); + } + unset($list); + } + + /** + * 获取当前操作model对象 + * + * @return \HyperfAdmin\Util\Scaffold\BaseModel + */ + public function getModel() + { + if (!$this->model_class) { + return null; + } + return make($this->model_class); + } + + /** + * 获取当前操作entity的主键 + * + * @return string + */ + public function getPk() + { + $entity = $this->getEntity(); + return $entity ? $this->getEntity()->getPk() : null; + } + + public function formComputeConfig($form) + { + $compute_map = []; + $options_map = []; + foreach ($form as $item) { + if (isset($item['depend'])) { + $compute_map[$item['depend']['field']][$item['field']][] = [ + 'when' => [ + [ + $item['depend']['field'], + is_array($item['depend']['value']) ? 'not_in' : '!=', + $item['depend']['value'], + ], + ], + 'set' => [ + 'type' => 'hidden', + ], + ]; + } + if (isset($item['hidden'])) { + $compute_map[$item['field']][$item['hidden']['field']][] = [ + 'when' => [[$item['field'], '=', $item['hidden']['value']]], + 'set' => [ + 'type' => 'hidden', + ], + ]; + } + if (isset($item['compute'])) { + if (isset($item['compute']['when'])) { + foreach ($item['compute']['set'] as &$set) { + $set = $this->formComputeSetConvert($set); + unset($set); + } + foreach ($item['compute']['set'] as $key => $detail) { + $compute_map[$item['field']][$key][] = [ + 'when' => [array_merge([$item['field']], $item['compute']['when'])], + 'set' => $detail, + ]; + } + } + if (isset($item['compute'][0])) { + foreach ($item['compute'] as $each) { + foreach ($each['set'] as &$set) { + $set = $this->formComputeSetConvert($set); + unset($set); + } + foreach ($each['set'] as $key => $detail) { + $compute_map[$item['field']][$key][] = [ + 'when' => [array_merge([$item['field']], $each['when'])], + 'set' => $detail, + ]; + } + } + } + } + if (isset($item['options'])) { + foreach ($item['options'] as $each) { + $options_map[$item['field']][] = [ + 'value' => $each['value'], + 'label' => $each['label'], + ]; + if (!isset($each['disabled_when'])) { + continue; + } + if (is_string($each['disabled_when'][0])) { + $compute_map[$each['disabled_when'][0]][$item['field']][] = [ + 'when' => [$each['disabled_when']], + 'set' => [ + 'options' => [ + [ + 'value' => $each['value'], + 'label' => $each['label'], + 'disabled' => true, + ], + ], + ], + ]; + continue; + } + foreach ($each['disabled_when'] as $line) { + $compute_map[$line[0]][$item['field']][] = [ + 'when' => $each['disabled_when'], + 'set' => [ + 'options' => [ + [ + 'value' => $each['value'], + 'label' => $each['label'], + 'disabled' => true, + ], + ], + ], + ]; + } + } + } + } + $real_map = []; + foreach ($compute_map as $change_field => $item) { + foreach ($item as $affect_field => $parts) { + $group = []; + foreach ($parts as $part) { + $key = json_encode($part['when']); + if (!isset($group[$key])) { + $group[$key] = $part['set']; + } else { + $group[$key] = array_merge_recursive($group[$key], $part['set']); + } + } + $cell = []; + foreach ($group as $when => $set) { + if (isset($set['options'])) { + $set['options'] = array_merge_node($options_map[$affect_field] ?? [], $set['options'], 'value'); + } + $cell[] = [ + 'when' => json_decode($when, true), + 'set' => $set, + ]; + } + $real_map[$change_field][$affect_field] = $cell; + } + } + return $real_map; + } + + public function formComputeSetConvert($cell) + { + if (isset($cell['rule'])) { + $validate = $this->validateOptions('input', '', explode('|', $cell['rule'])); + $cell['validate'] = $validate; + unset($cell['rule']); + } + foreach ($cell as $key => $value) { + if (is_callable($value)) { + $cell[$key] = call($value, [$cell]); + } + } + return $cell; + } + + /** + * 表单配置拉取接口 + * + * @return array + */ + public function form() + { + $form = $this->formOptionsConvert([], false, false); + return $this->success($this->formResponse(0, $form)); + } + + public function formResponse($id, $form) + { + if (method_exists($this, 'meddleFormRule')) { + $this->meddleFormRule($id, $form); + } + $compute_map = $this->formComputeConfig($form); + return [ + 'form' => $form, + 'compute_map' => (object)$compute_map, + 'form_ui' => (object)($this->options['formUI'] ?? []), + ]; + } + + /** + * 表单拉取接口 + * + * @param int $id 主键值 + * + * @return array + */ + public function edit(int $id) + { + $record = $this->getEntity()->get($id); + $history_versions = []; + $version_enable = $this->getEntity()->isVersionEnable(); + if ($version_enable && $record && method_exists($this, 'getRecordHistory')) { + $history_versions = $this->getRecordHistory(); + } + $ver_id = $this->request->input('_ver'); + if ($ver_id && $history_versions) { + $versions = $history_versions; + array_change_v2k($versions, 'id'); + $record = isset($versions[$ver_id]) ? $versions[$ver_id]['content'] : $record; + } + if (method_exists($this, 'beforeFormResponse')) { + $this->beforeFormResponse($id, $record); + } + $form = $this->formOptionsConvert([], false, true, false, $record); + return $this->success(array_merge($this->formResponse($id, $form), [ + 'version_enable' => $version_enable, + 'version_list' => $history_versions, + ])); + } + + /** + * 获取所有字段, 共列表/表单使用 + */ + public function getFields() + { + $form = $this->formOptionsConvert(); + $form = array_filter($form, function ($item) { + return !($item['virtual_field'] ?? false); + }); + $fields = array_column($form, 'field'); + $fields = array_map(function ($item) { + return explode('.', $item)[0]; + }, $fields); + return array_unique($fields); + } + + /** + * 获取列表的搜索项 + */ + public function getListFilters() + { + if (empty($this->options['filter'])) { + return []; + } + $form_fields = $this->getFormFieldMap(); + $form_options = $this->options['form'] ?? []; + $filter_options = []; + foreach ($this->options['filter'] as $key => $item) { + $filter_option_key = is_array($item) ? $key : str_replace('%', '', $item); + $field_extra = explode('|', $filter_option_key); + $field = $field_extra[0]; + $form_option = []; + if (isset($form_fields[$field]) && isset($form_options[$form_fields[$field]])) { + $filter_option_key = $form_fields[$field]; + $form_option = is_array($form_options[$form_fields[$field]]) ? $form_options[$form_fields[$field]] : []; + if (isset($form_option['rule'])) { + unset($form_option['rule']); + } + } + $filter_option = is_array($item) ? $item : []; + if (!empty($field_extra[1])) { + $filter_option_key = "{$field}|{$field_extra[1]}"; + } + $filter_options[$filter_option_key] = array_merge($form_option, $filter_option); + if (!isset($filter_options[$filter_option_key]['search_type']) && is_string($item)) { + $search_type = 'eq'; + if (Str::startsWith($item, '%') !== false) { + $search_type = 'prefix_like'; + } + if (Str::endsWith($item, '%') !== false) { + $search_type = 'suffix_like'; + } + if (Str::startsWith($item, '%') !== false && Str::endsWith($item, '%') !== false) { + $search_type = 'full_like'; + } + if (strpos(($filter_options[$filter_option_key]['type'] ?? ''), 'range') !== false) { + $search_type = 'between'; + } + $filter_options[$filter_option_key]['search_type'] = $search_type; + } + } + unset($form_options); + return $this->formOptionsConvert($filter_options, true, false, true); + } + + /** + * 获取列表的表头 + */ + public function getListHeader() + { + $form = $this->formOptionsConvert(); + array_change_v2k($form, 'field'); + $table_options = $this->options['table']['columns'] ?? []; + $headers = []; + foreach ($table_options as $item) { + if (is_string($item)) { + $header = [ + 'title' => $form[$item]['title'] ?? $item, + 'field' => $form[$item]['field'] ?? $item, + 'type' => $form[$item]['type'] ?? '', + 'virtual_field' => $form[$item]['virtual_field'] ?? false, + 'sortable' => false, + ]; + } else { + $header = array_merge(!empty($form[$item['field']]) ? [ + 'type' => $item['type'] ?? $form[$item['field']]['type'], + 'title' => $form[$item['field']]['title'], + 'sortable' => $item['sortable'] ?? false, + 'virtual_field' => $item['virtual_field'] ?? false, + ] : [], $item); + } + if ($form[$header['field']]['options'] ?? false) { + $options = []; + foreach ($form[$header['field']]['options'] as $each) { + $options[$each['value']] = $each['label']; + } + $header['options'] = $options; + } + $headers[] = $header; + } + if (!$table_options) { + foreach ($form as $item) { + $headers[] = [ + 'title' => $item['title'], + 'field' => $item['field'], + 'sortable' => $item['sortable'] ?? false, + 'virtual_field' => $item['virtual_field'] ?? false, + ]; + } + } + return $headers; + } + + /** + * 表单配置转换 + * + * @param array $formOption 表单配置 + * @param bool $full 是否为全部, false 则会过滤虚拟字段 + * @param bool $edit 是否为编辑模式, 用于处理 新增或编辑 时, 字段的只读问题 + * @param bool $filter 是否为filter模式,用于处理id字段的type + * @param array $default + * @param int $depth 深度 + * + * @return array + */ + public function formOptionsConvert($formOption = [], $full = false, $edit = true, $filter = false, $default = [], $depth = 0) + { + if (!$formOption) { + $formOption = $this->options['form'] ?? []; + } + $form = []; + foreach ($formOption as $key => $val) { + $field_extra = explode('|', $key); + $field = $field_extra[0]; + $title = $field_extra[1] ?? $field_extra[0]; + $biz = []; + if (is_string($val)) { + $biz['rule'] = $val; + $biz['type'] = 'input'; + } else { + $biz = $val; + } + if ($full === false && ($biz['form'] ?? true) === false) { + continue; + } + $rule = $biz['rule'] ?? ''; + $rules = is_array($rule) ? $rule : explode('|', $rule); + $_form = [ + 'title' => $title, + 'field' => $field, + 'type' => $biz['type'] ?? 'input', + 'value' => Arr::get(array_merge($this->request->all(), $default ?: []), $field, $biz['default'] ?? ''), + ]; + switch ($_form['type']) { + case 'checkbox': + case 'cascader': + $_form['value'] = array_map('intval', is_array($_form['value']) ? $_form['value'] : (array)$_form['value']); + break; + case 'image': + $biz['props']['limit'] = $biz['props']['limit'] ?? 1; + break; + case 'select': + if (isset($biz['props']['selectApi']) && $_form['value']) { + $biz['options'] = select_options($biz['props']['selectApi'], is_array($_form['value']) ? $_form['value'] : explode(',', $_form['value'])); + } + // fixme sub-form value 不好取, 先默认查一次 + if (isset($biz['props']['selectApi']) && $depth) { + $biz['options'] = select_options($biz['props']['selectApi'], is_array($_form['value']) ? $_form['value'] : explode(',', $_form['value'])); + } + break; + default: + break; + } + $validate = $this->validateOptions($_form['type'], $_form['title'], $rules); + if (isset($biz['children'])) { + $biz['props']['rules'] = $this->formOptionsConvert($biz['children'], $full, $edit, $filter, Arr::get($field, $default, []), $depth + 1); + $biz['props']['computeMap'] = (object)$this->formComputeConfig($biz['props']['rules']); + $biz['props']['repeat'] = $biz['repeat'] ?? false; + $_form['value'] = is_array($_form['value']) ? $_form['value'] : []; + } + if ($validate) { + $_form['validate'] = $validate; + } + if (!$filter && $field == $this->getPk()) { + $_form['type'] = 'hidden'; + } + if ($biz['props'] ?? false) { + $_form['props'] = $biz['props']; + } + if ($biz['col'] ?? false) { + $_form['col'] = $biz['col']; + } + if ($biz['info'] ?? false) { + $_form['info'] = $biz['info']; + } + if (isset($biz['depend'])) { + $_form['depend'] = $biz['depend']; + } + if (isset($biz['hidden'])) { + $_form['hidden'] = $biz['hidden']; + } + if (isset($biz['custom'])) { + $_form['custom'] = (bool)$biz['custom']; + } + if (isset($biz['virtual_field'])) { + $_form['virtual_field'] = (bool)$biz['virtual_field']; + } + if (isset($biz['readonly']) && $edit) { + $_form['props']['disabled'] = (bool)$biz['readonly']; + } + if (isset($biz['section'])) { + $_form['section'] = $biz['section']; + } + if (isset($biz['compute'])) { + $_form['compute'] = $biz['compute']; + } + if (isset($biz['search_type'])) { + $_form['search_type'] = $biz['search_type']; + } + if (isset($biz['options']) && is_callable($biz['options'])) { + $_form['options'] = $biz['options']($field, $default); + } elseif (($biz['options'] ?? false) && ($biz['type'] != 'cascader')) { + $value_label = []; + $first = current($biz['options']); + if (!isset($first['value'])) { + foreach ($biz['options'] as $value => $label) { + $value_label[] = is_array($label) ? $label : [ + 'value' => $value, + 'label' => $label, + ]; + } + } else { + $value_label = $biz['options']; + } + $_form['options'] = $value_label; + } + if ($filter + && in_array($_form['type'], [ + 'radio', + 'select', + 'checkbox', + ]) + && isset($_form['options'])) { + $_form['type'] = 'select'; + unset($_form['value']); + $options_lables = array_column($_form['options'], 'label'); + if (!isset($_form['props']['selectApi']) && !in_array('全部', $options_lables)) { + array_unshift($_form['options'], [ + 'value' => '', + 'label' => '全部', + ]); + } + } + if (isset($biz['copy_show'])) { + $_form['copy_show'] = $biz['copy_show']; + } + if (isset($biz['render']) && is_callable($biz['render'])) { + $biz['render']($field, $_form); + } + if (isset($biz['filterConvert'])) { + $_form['filterConvert'] = $biz['filterConvert']; + } + $form[] = $_form; + } + return $form; + } + + /** + * 获取表单的约束 + */ + public function getFormRules($options = null) + { + $formOptions = $options ? $options : ($this->options['form'] ?? []); + $rules = []; + foreach ($formOptions as $key => $val) { + if (is_array($val) && ($val['form'] ?? true) === false) { + continue; + } + if (is_string($val)) { + $rules[$key] = $val; + continue; + } + if (is_array($val) && ($val['rule'] ?? false)) { + $rules[$key] = $val['rule']; + } else { + $rules[$key] = ''; + } + if (isset($val['children']) && is_array($val['children'])) { + $rules[$key] = [ + 'children' => [ + 'rules' => $this->getFormRules($val['children']), + 'repeat' => $val['repeat'] ?? false, + ], + ]; + } + } + return $rules; + } + + public function save() + { + $rules = $this->getFormRules(); + $entity = $this->getEntity(); + $pk = $entity->getPk(); + $data_source = $this->request->all(); + $pk_val = $data_source[$pk] ?? null; + foreach ($rules as &$val) { + $rule_parts = is_array($val) ? $val : explode('|', $val); + foreach ($rule_parts as &$rule) { + if ($pk_val && is_string($rule) && Str::startsWith($rule, 'unique')) { + // unique rule without itself in update + $rule .= ',' . $pk_val . '_to_ignore'; + } + unset($rule); + } + $val = array_filter($rule_parts); + unset($val); + } + [ + $data, + $errors, + ] = $this->validation->check($rules, $data_source, $this); + if ($errors) { + return $this->fail(ErrorCode::CODE_ERR_PARAM, implode(PHP_EOL, $errors)); + } + if ($pk_val === null) { + return $this->fail(ErrorCode::CODE_ERR_SYSTEM, '表单配置错误, 请联系管理员'); + } + if (method_exists($this, 'beforeSave')) { + try { + $this->beforeSave($pk_val, $data); + } catch (\Exception $e) { + return $this->fail($e->getCode() ?? ErrorCode::FAIL, $e->getMessage()); + } + } + try { + if ($pk_val) { + $saved = $entity->set($pk_val, $data); + } else { + unset($data[$pk]); + $saved = $entity->create($data); + $pk_val = $saved; + } + if ($saved) { + if (method_exists($this, 'afterSave')) { + $this->afterSave($pk_val, $data, $entity); + } + return $this->success(); + } else { + return $this->fail(ErrorCode::CODE_ERR_SERVER); + } + } catch (\Exception $e) { + return $this->fail(is_int($e->getCode()) ? $e->getCode() : ErrorCode::CODE_ERR_SERVER, $e->getMessage()); + } + } + + /** + * 删除接口 + */ + public function delete() + { + $entity = $this->getEntity(); + $pk = $entity->getPk(); + $pk_val = $this->request->input($pk); + if (!$pk_val) { + return $this->fail(ErrorCode::CODE_ERR_DENY); + } + try { + if (method_exists($this, 'beforeDelete')) { + $this->beforeDelete($pk_val); + } + $deleted = $entity->delete($pk_val); + if (method_exists($this, 'afterDelete')) { + $this->afterDelete($pk_val, $deleted); + } + return $deleted ? $this->success() : $this->fail(ErrorCode::CODE_ERR_SERVER, '删除失败'); + } catch (\Exception $e) { + return $this->fail(is_int($e->getCode()) ? $e->getCode() : ErrorCode::CODE_ERR_SERVER, $e->getMessage()); + } + } + + public function batchDelete() + { + $entity = $this->getEntity(); + $pk = $entity->getPk(); + $selected = $this->request->input('selected'); + $pks = array_filter(array_column($selected, $pk)); + if (!$pks) { + return $this->fail(ErrorCode::CODE_ERR_PARAM); + } + try { + if (method_exists($this, 'beforeDelete')) { + $this->beforeDelete($pks); + } + $deleted = $entity->delete($pks); + if (method_exists($this, 'afterDelete')) { + $this->afterDelete($pks, $deleted); + } + return $deleted ? $this->success() : $this->fail(ErrorCode::CODE_ERR_SERVER, '删除失败'); + } catch (\Exception $e) { + return $this->fail(is_int($e->getCode()) ? $e->getCode() : ErrorCode::CODE_ERR_SERVER, $e->getMessage()); + } + } + + /** + * 表单中编辑的保存接口 + * + * @param int $id + * + * @return array + */ + public function rowChange($id) + { + $rules = $this->getFormRules(); + $up = $this->request->all(); + $up_fields = array_keys($up); + foreach ($rules as $key => $val) { + $field_extra = explode('|', $key); + $field = $field_extra[0]; + if (!in_array($field, $up_fields)) { + unset($rules[$key]); + } + } + [ + $data, + $errors, + ] = $this->validation->check($rules, $this->request->all()); + if ($errors) { + return $this->fail(ErrorCode::CODE_ERR_PARAM, implode(PHP_EOL, $errors)); + } + if (method_exists($this, 'beforeRowChangeSave')) { + try { + $this->beforeRowChangeSave($id, $data); + } catch (\Exception $e) { + return $this->fail($e->getCode() ?? ErrorCode::FAIL, $e->getMessage()); + } + } + $saved = $this->getEntity()->set($id, $data); + return $saved ? $this->success() : $this->fail(ErrorCode::CODE_ERR_SERVER); + } + + /** + * 脚手架通用抛出异常 + * + * @param string $message + * @param int $code + * @param \Throwable $previous + * + * @return void + * @throws \Exception + */ + public function exception($message = "", $code = ErrorCode::CODE_ERR_SYSTEM, \Throwable $previous = null) + { + throw new \Exception($message, $code, $previous); + } + + /** + * laravel-validation -> async-validator + * 脚手架字段约束转换 + * + * @param string $type 字段类型 + * @param string $title 字段中文名 + * @param array $rules 字段约束 + * + * @return array + */ + public function validateOptions($type, $title, $rules) + { + $validates = []; + foreach ($rules as $item) { + $parts = explode(':', $item); + $rule = array_shift($parts); + switch ($rule) { + case 'required': + $validates[] = [ + 'required' => true, + 'message' => '请输入' . $title, + //'type' => $type, + 'trigger' => 'blur', + ]; + break; + } + } + return $validates; + } + + /** + * 从form options中提取field对应的form options的key + * + * @return array + */ + public function getFormFieldMap() + { + $form_options = $this->options['form'] ?? []; + if (empty($form_options)) { + return []; + } + return collect(array_keys($form_options))->mapWithKeys(function ($item) { + $field_extra = explode('|', $item); + return [$field_extra[0] => $item]; + })->toArray(); + } + + /** + * form表单中二次确认方法 + * + * @param string $confirm_msg 二次确认的提示文案 + * @param callable $need_confirm_callable 当何种情况下需要二次确认 + * + * @return mixed + */ + public function confirm($confirm_msg, $need_confirm_callable) + { + if ($need_confirm_callable() && !$this->request->input('_repeat')) { + $this->exception($confirm_msg, 40012); + } + } + + /** + * select options 接口 + */ + public function act() + { + $attr = ['select' => ['id as value', 'name as label']]; + $model = $this->getEntity(); + $options = $model->search($attr); + return $this->success($options); + } + + //public function riskCheck() + //{ + // if (!empty($this->options['risk']) && !empty($this->options['riskAction'])) { + // $check_param = [ + // 'riskAction' => $this->options['riskAction'], + // 'fields' => $this->request->all(), + // ]; + // $confirm_message = (new RiskCheck)->check($check_param); + // if ($confirm_message) { + // throw new \Exception($confirm_message, 40012); + // } + // } + //} + + /** + * 拉取模块提示接口 + * notices: "测试一下", + * notices: [ + * { + * "message": "测试二下" + * } + * ] + */ + public function notice() + { + $notices = $this->options['notices'] ?? []; + if (empty($notices)) { + return $this->success(); + } + $filters = []; + if ($filter_options = $this->getListFilters()) { + array_change_v2k($filter_options, 'field'); + $filters = array_filter($this->request->inputs(array_keys($filter_options)), function ($item) { + return !in_array($item, [null, '']); + }); + } + $list = []; + if (is_array($notices)) { + foreach ($notices as $notice) { + if (isset($notice['when'])) { + if (!is_callable($notice['when'])) { + continue; + } + $ok = $notice['when']($filters); + if (!$ok) { + continue; + } + unset($notice['when']); + } + $list[] = $notice; + } + } else { + $list[] = [ + 'message' => $notices, + ]; + } + return $this->success(compact('list')); + } + + /** + * 获取当前操作entity对象 + * + * @return \HyperfAdmin\BaseUtils\Scaffold\Entity\EntityInterface + */ + public function getEntity() + { + if ($this->entity_class) { + return make($this->entity_class); + } + if ($this->model_class && make($this->model_class) instanceof BaseModel) { + return new class ($this->model_class) extends MysqlEntityAbstract{ + }; + } + if ($this->model_class && make($this->model_class) instanceof EsBaseModel) { + return new class ($this->model_class) extends EsEntityAbstract{ + }; + } + return null; + } + + /** + * @param $field + * + * @return array + */ + public function options($field) + { + $const = []; + if ($model = $this->model_class ?? '') { + $const = constant($model . '::' . strtoupper($field)) ?? []; + } + $options = []; + foreach ($const as $k => $v) { + $options[] = [ + 'id' => $k, + 'name' => $v, + ]; + } + return $this->success($options); + } + + + /** + * 新版本检查 + * + * @param int $id + * @param int $last_ver_id + * + * @return array + */ + public function newVersion(int $id, int $last_ver_id) + { + $last = $this->getEntity()->lastVersion(); + if (!$last || $last->id == $last_ver_id) { + return $this->success(['has_new_version' => false]); + } + if ($last->user_id == $this->user()['id']) { + return $this->success(['has_new_version' => false]); + } + return $this->success([ + 'has_new_version' => true, + 'message' => sprintf("%s有新的数据产生, 请刷新页面获取最新数据", $last->create_at), + ]); + } +} diff --git a/src/base-utils/src/Scaffold/Controller/Controller.php b/src/base-utils/src/Scaffold/Controller/Controller.php new file mode 100644 index 0000000..305a7bb --- /dev/null +++ b/src/base-utils/src/Scaffold/Controller/Controller.php @@ -0,0 +1,133 @@ +container = $container; + $this->request = $request; + $this->response = $response; + $this->resource = $this->getCalledSource(); + $this->idGen = $container->get(IdGeneratorInterface::class); + $this->validation = make(Validation::class); + + if (method_exists($this, 'init')) { + $this->init(); + } + } + + protected function getCalledSource($get_arr = false) + { + $uri = $this->getRequestUri(); + $parts = array_filter(explode('/', $uri)); + if ($get_arr) { + return array_values($parts); + } + return implode('.', $parts); + } + + protected function getRequestUri() + { + $http_request = $this->container->get(RequestInterface::class); + return $http_request->getServerParams()['request_uri'] ?? ''; + } + + /** + * 返回成功的请求 + * + * @param array $data + * @param string $message + * + * @return array + */ + public function success(array $data = [], $message = '操作成功') + { + $response = [ + 'code' => 0, + 'message' => $message, + 'payload' => $data ?: (object)[], + ]; + Log::get('http.' . $this->getCalledSource())->info(0, $response); + return $response; + } + + /** + * @param int $code + * @param string|null $message + * + * @return array + */ + public function fail(int $code = -1, ?string $message = null) + { + $response = [ + 'code' => $code, + 'message' => $message ?: ErrorCode::getMessage($code), + 'payload' => (object)[], + ]; + Log::get('http.' . $this->getCalledSource())->info($code, $response); + return $response; + } +} diff --git a/src/base-utils/src/Scaffold/Entity/ApiEntityAbstract.php b/src/base-utils/src/Scaffold/Entity/ApiEntityAbstract.php new file mode 100644 index 0000000..3e8e69c --- /dev/null +++ b/src/base-utils/src/Scaffold/Entity/ApiEntityAbstract.php @@ -0,0 +1,92 @@ +model = make($model_class); + } + } + + public function getModel() + { + if ($this->model) { + return $this->model; + } + if ($this->model_class) { + $this->model = make($this->model_class); + } + return $this->model; + } + + public function getPk() + { + return $this->getModel()::getPrimaryKey(); + } + + public function create($data) + { + return $this->getModel()->insertGetId($data); + } + + public function get($id) + { + return $this->getModel()->where($this->getPk(), $id)->firstAsArray(); + } + + public function set($id, array $data) + { + $record = $this->getModel()->where($this->getPk(), $id)->first(); + if (!$record) { + return false; + } + return $record->fill($data)->save(); + } + + public function delete($id) + { + return $this->getModel()->destroy($id); + } + + public function count($where) + { + return $this->getModel()->where2query($where)->count(); + } + + public function list($where, $attr = [], $page = 1, $size = 20) + { + $query = $this->getModel()->where2query($where); + if ($attr['select'] ?? false) { + $query->select($attr['select']); + } + if ($attr['order_by']) { + $query->orderByRaw($attr['order_by']); + } + $query->limit($size)->offset(($page - 1) * $size); + $ret = $query->get(); + return $ret ? $ret->toArray() : []; + } + + public function isVersionEnable() + { + $version_enable = false; + if (method_exists($this->getModel(), 'isVersionEnable')) { + $version_enable = $this->getModel()->isVersionEnable(); + } + return $version_enable; + } + + public function lastVersion($version_id = null) + { + return []; + } +} diff --git a/src/base-utils/src/Scaffold/Entity/EntityInterface.php b/src/base-utils/src/Scaffold/Entity/EntityInterface.php new file mode 100644 index 0000000..6958d5e --- /dev/null +++ b/src/base-utils/src/Scaffold/Entity/EntityInterface.php @@ -0,0 +1,21 @@ +model = make($model_class); + } + } + + public function getModel() + { + if ($this->model) { + return $this->model; + } + if ($this->model_class) { + $this->model = make($this->model_class); + } + return $this->model; + } + + public function getPk() + { + return $this->getModel()->getPrimaryKey(); + } + + public function create($data) + { + return $this->getModel()->insert($data); + } + + public function get($id) + { + return $this->getModel()->select(['id' => $id]); + } + + public function set($id, array $data) + { + return false; + } + + public function delete($id) + { + return false; + } + + public function count($where) + { + return $this->getModel()->selectCount($where); + } + + public function list($where, $attr = [], $page = 1, $size = 20) + { + $attr['limit'] = $size; + $attr['offset'] = ($page - 1) * $size; + return $this->getModel()->select($where, $attr); + } + + public function isVersionEnable() + { + return false; + } + + public function lastVersion($version_id = null) + { + return []; + } +} diff --git a/src/base-utils/src/Scaffold/Entity/MysqlEntityAbstract.php b/src/base-utils/src/Scaffold/Entity/MysqlEntityAbstract.php new file mode 100644 index 0000000..cd7e5ba --- /dev/null +++ b/src/base-utils/src/Scaffold/Entity/MysqlEntityAbstract.php @@ -0,0 +1,109 @@ +model = make($model_class); + } + } + + public function getModel() + { + if ($this->model) { + return $this->model; + } + if ($this->model_class) { + $this->model = make($this->model_class); + } + return $this->model; + } + + public function getPk() + { + return $this->getModel()::getPrimaryKey(); + } + + public function create($data) + { + $entity = $this->getModel()->fill($data); + $entity->save(); + return $entity->{$this->getPk()}; + } + + public function get($id) + { + return $this->getModel()->where($this->getPk(), $id)->firstAsArray(); + } + + public function set($id, array $data) + { + $record = $this->getModel()->where($this->getPk(), $id)->first(); + if (!$record) { + return false; + } + return $record->fill($data)->save(); + } + + public function delete($id) + { + return $this->getModel()->destroy($id); + } + + public function count($where) + { + return $this->getModel()->where2query($where)->count(); + } + + public function list($where, $attr = [], $page = 1, $size = 20) + { + $query = $this->getModel()->where2query($where); + if ($attr['select'] ?? false) { + $selects = array_map(function ($select) { + $select = trim($select); + if (Str::contains($select, ' ')) { + return Db::connection($this->getModel()->getConnectionName())->raw($select); + } else { + return $select; + } + }, $attr['select']); + $query->select($selects); + } + if (isset($attr['order_by'])) { + $query->orderByRaw($attr['order_by']); + } + if (isset($attr['group_by'])) { + // todo groupByRaw + $query->groupBy($attr['group_by']); + } + $query->limit($size)->offset(($page - 1) * $size); + $ret = $query->get(); + return $ret ? $ret->toArray() : []; + } + + public function isVersionEnable() + { + $version_enable = false; + if (method_exists($this->getModel(), 'isVersionEnable')) { + $version_enable = $this->getModel()->isVersionEnable(); + } + return $version_enable; + } + + public function lastVersion($version_id = null) + { + return []; + } +} diff --git a/src/base-utils/src/StdoutLoggerFactory.php b/src/base-utils/src/StdoutLoggerFactory.php new file mode 100644 index 0000000..dede272 --- /dev/null +++ b/src/base-utils/src/StdoutLoggerFactory.php @@ -0,0 +1,12 @@ + $explode[0], + 'weight' => $explode[1] ?? 0, + ]; + }, $arr); + }; + $package = $handel($providers); + array_change_v2k($package, 'provider'); + + $local = json_decode(file_get_contents(BASE_PATH . '/composer.json'), true)['extra']['hyperf']['config'] ?? []; + $local = $handel($local); + array_change_v2k($local, 'provider'); + + foreach ($local as $key => $val) { + $package[$key] = $val; + } + + $providers = array_values($package); + usort($providers, function ($a, $b) { + return $a['weight'] > $b['weight']; + }); + $providers = array_column($providers, 'provider'); + static::$providerConfigs = static::loadProviders($providers); + } + return static::$providerConfigs; + } + + public static function clear(): void + { + static::$providerConfigs = []; + } + + protected static function loadProviders(array $providers): array + { + $providerConfigs = []; + foreach ($providers as $provider) { + if (is_string($provider) && class_exists($provider) && method_exists($provider, '__invoke')) { + $providerConfigs[] = (new $provider())(); + } + } + + return static::merge(...$providerConfigs); + } + + protected static function merge(...$arrays): array + { + $result = array_merge_recursive(...$arrays); + if (isset($result['dependencies'])) { + $dependencies = array_column($arrays, 'dependencies'); + $result['dependencies'] = array_merge(...$dependencies); + } + + return $result; + } +} diff --git a/src/base-utils/src/config/routes.php b/src/base-utils/src/config/routes.php new file mode 100644 index 0000000..89e0b1e --- /dev/null +++ b/src/base-utils/src/config/routes.php @@ -0,0 +1,11 @@ + 200, + 'message' => 'ok', + ]; +}); diff --git a/src/cron-center/.gitignore b/src/cron-center/.gitignore new file mode 100644 index 0000000..82cfc4e --- /dev/null +++ b/src/cron-center/.gitignore @@ -0,0 +1,3 @@ +.idea +composer.lock +vendor diff --git a/src/cron-center/composer.json b/src/cron-center/composer.json new file mode 100644 index 0000000..0223b17 --- /dev/null +++ b/src/cron-center/composer.json @@ -0,0 +1,31 @@ +{ + "name": "rock-admin/cron-center", + "description": "hyperf crontab manager", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "daodao97", + "email": "daodao97@foxmail.com" + } + ], + "require": { + "hyperf/crontab": "^1.1", + "hyperf/process": "^1.1", + "hyperf/command": "^1.1", + "hyperf/event": "^1.1", + "rock-admin/base-utils": "~0.0.1" + }, + "autoload": { + "psr-4": { + "Rock\\CronCenter\\": "./src" + }, + "files": [ + ] + }, + "extra": { + "hyperf": { + "config": "Rock\\CronCenter\\ConfigProvider" + } + } +} diff --git a/src/cron-center/src/AlertManagerProcess.php b/src/cron-center/src/AlertManagerProcess.php new file mode 100644 index 0000000..ecc24c7 --- /dev/null +++ b/src/cron-center/src/AlertManagerProcess.php @@ -0,0 +1,25 @@ +jobManager = make(CronManager::class); + parent::__construct($container); + } + + public function handle(): void + { + // todo alert + while (true) { + $jobs = $this->jobManager->getJobs(); + \Swoole\Coroutine::sleep(1); + } + } +} diff --git a/src/cron-center/src/ClassJobAbstract.php b/src/cron-center/src/ClassJobAbstract.php new file mode 100644 index 0000000..c28845f --- /dev/null +++ b/src/cron-center/src/ClassJobAbstract.php @@ -0,0 +1,89 @@ +job = $crontab; + $this->jobManager = make(CronManager::class); + $this->state = $this->jobManager->getJobState($crontab->getId()); + $this->logger = logger(); + } + + public function run($params = []) + { + $func = function () use ($params) { + try { + $this->beforeAction($params); + $this->handle($params); + $this->afterAction($params); + } catch (\Throwable $throwable) { + $this->onError($throwable); + } finally { + $this->onComplete(); + + return $this->evaluate(); + } + }; + + return $func(); + } + + public function beforeAction($params = []) + { + $this->state['start_time'] = ($this->state['start_time'] ?? false) ?: Carbon::now()->toDateTimeString(); + $this->logger->info(sprintf('script job [%s] start at %s', $this->getJobName(), Carbon::now())); + } + + public function afterAction($params = []) + { + $this->logger->info(sprintf('script job [%s] end at %s', $this->getJobName(), Carbon::now())); + } + + public function onError(\Throwable $throwable) + { + $this->logger->error(sprintf('script job [%s] fail: %s', $this->getJobName(), $throwable)); + } + + public function onComplete() + { + $this->state['last_time'] = Carbon::now()->toDateTimeString(); + $this->state['counter'] = ($this->state['counter'] ?? 0) + 1; + $this->state['memory_usage'] = memory_get_usage(); + $this->jobManager->setJobState($this->getJobId(), $this->state); + } + + public function getJobKey(): string + { + return 'cron-center.' . $this->job->getName(); + } + + public function getJobName() + { + return $this->job->getName(); + } + + public function getJobId() + { + return $this->job->getId(); + } + + public function handle($params) + { + } + + protected function evaluate() + { + } +} diff --git a/src/cron-center/src/CommandJobAbstract.php b/src/cron-center/src/CommandJobAbstract.php new file mode 100644 index 0000000..2b91b89 --- /dev/null +++ b/src/cron-center/src/CommandJobAbstract.php @@ -0,0 +1,111 @@ +addOption('job_id', '', InputOption::VALUE_REQUIRED, 'job id'); + $this->addOption('job_name', '', InputOption::VALUE_REQUIRED, 'job name'); + } + + public function __construct() + { + $container = ApplicationContext::getContainer(); + $this->jobManager = make(CronManager::class); + parent::__construct(); + $this->logger = $container->get(LoggerFactory::class)->get('cron_center.' . $this->getJobName()); + $this->state = $this->jobManager->getJobState($this->getJobId()); + } + + public function handle() + { + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $callback = function () { + try { + $this->beforeRun(); + call([$this, 'handle']); + $this->afterRun(); + } catch (\Throwable $throwable) { + $this->onError($throwable); + + return $throwable->getCode(); + } finally { + $this->onFinally(); + } + + return 0; + }; + if ($this->coroutine && !Coroutine::inCoroutine()) { + run($callback, $this->hookFlags); + + return 0; + } + + return $callback(); + } + + public function beforeRun() + { + $this->state['start_time'] = ($this->state['start_time'] ?? false) ?: Carbon::now()->toDateTimeString(); + $this->logger->info(sprintf('script job [%s] start at %s', $this->getJobName(), Carbon::now())); + } + + public function afterRun() + { + $this->logger->info(sprintf('script job [%s] end at %s', $this->getJobName(), Carbon::now())); + } + + public function onError(\Throwable $throwable) + { + $this->logger->error(sprintf('script job [%s] fail: %s', $this->getJobName(), $throwable)); + } + + public function onFinally() + { + $this->state['last_time'] = Carbon::now()->toDateTimeString(); + $this->state['counter'] = ($this->state['counter'] ?? 0) + 1; + $this->jobManager->setJobState($this->getJobId(), $this->state); + } + + public function getJobName() + { + return $this->input->getOption('job_name'); + } + + public function getJobId() + { + return $this->input->getOption('job_id'); + } +} diff --git a/src/cron-center/src/ConfigProvider.php b/src/cron-center/src/ConfigProvider.php new file mode 100644 index 0000000..7c890d9 --- /dev/null +++ b/src/cron-center/src/ConfigProvider.php @@ -0,0 +1,40 @@ + [ + 'cron_center' => db_complete([ + 'host' => env('CRON_CENTER_DB_HOST', env('HYPERF_ADMIN_DB_HOST')), + 'database' => env('CRON_CENTER_DB_NAME', env('HYPERF_ADMIN_DB_NAME')), + 'username' => env('CRON_CENTER_DB_USER', env('HYPERF_ADMIN_DB_USER')), + 'password' => env('CRON_CENTER_DB_PWD', env('HYPERF_ADMIN_DB_PWD')), + ]), + ], + 'commands' => [ + InstallCommand::class, + ], + 'dependencies' => [ + HyperfCrontabRegisterListener::class => CronCenterCrontabRegisterListener::class, + HyperfCrontabExecutor::class => CronCenterExecutor::class, + ], + 'processes' => [ + CrontabDispatcherProcess::class, + ], + 'listeners' => [], + 'init_routes' => [ + __DIR__ . '/config/routes.php', + ], + ]; + } +} diff --git a/src/cron-center/src/Controller/CronNodeController.php b/src/cron-center/src/Controller/CronNodeController.php new file mode 100644 index 0000000..f07167a --- /dev/null +++ b/src/cron-center/src/Controller/CronNodeController.php @@ -0,0 +1,90 @@ +cron_manager = make(CronManager::class); + parent::init(); + } + + public function scaffoldOptions() + { + return [ + 'createAble' => false, + 'deleteAble' => false, + 'form' => [ + 'id|#' => '', + 'name|节点名' => '', + 'status|状态' => [ + 'type' => 'select', + 'options' => CronNodes::$status, + 'enum' => [ + YES => 'success', + ], + ], + 'update_at|最后活跃时间' => '', + ], + 'table' => [ + 'columns' => [ + 'id', + 'name', + [ + 'field' => 'status', + 'enum' => [ + YES => 'success', + NO => 'info', + CronNodes::STATUS_BLOCK => 'warning', + CronNodes::STATUS_LOSS => 'danger', + ], + 'render' => function ($status, $row) { + if ((time() - strtotime($row['update_at'])) > 60) { + return CronNodes::STATUS_LOSS; + } + + return $status; + }, + 'info' => '下线表示节点已经丢失, 不可用, 锁定表示节点存活, 但人为锁定, 将不执行任何任务', + ], + 'update_at', + ], + 'rowActions' => [ + [ + 'action' => 'api', + 'api' => '/cronnodes/block/{id}', + 'text' => '锁定', + 'when' => ['status', '=', 1], + ], + [ + 'type' => 'api', + 'target' => '/cronnodes/delete', + 'text' => '删除', + 'props' => [ + 'type' => 'danger', + ], + ], + ], + ], + ]; + } + + public function block($id) + { + $re = $this->cron_manager->blockNode($id); + + return $re ? $this->success() : $this->fail(ErrorCode::CODE_ERR_SYSTEM, '锁定失败'); + } +} diff --git a/src/cron-center/src/Controller/CrontabController.php b/src/cron-center/src/Controller/CrontabController.php new file mode 100644 index 0000000..e5c1abc --- /dev/null +++ b/src/cron-center/src/Controller/CrontabController.php @@ -0,0 +1,219 @@ +cron_manager = make(CronManager::class); + parent::init(); + } + + public function scaffoldOptions() + { + return [ + 'form' => [ + 'id|#' => 'int', + 'title|任务可读名' => 'required|max:100', + 'name|任务名' => [ + 'rule' => 'required|alpha_dash|max:100', + 'info' => '可以包含字母和数字,以及破折号和下划线', + ], + 'type|类型' => [ + 'type' => 'select', + 'rule' => 'required', + 'options' => CronJobs::$types, + 'values' => [ + 'execute' => [ + CronJobs::TYPE_CLASS, + CronJobs::TYPE_CMD, + CronJobs::TYPE_EVAL, + ], + 'gateway' => [ + CronJobs::TYPE_GATEWAY, + ], + ], + ], + 'config.execute|执行入口' => [ + 'rule' => 'required_if:type,execute|max:100|call_class_exist', + 'depend' => [ + 'field' => 'type', + 'value' => [ + CronJobs::TYPE_CLASS, + CronJobs::TYPE_CMD, + CronJobs::TYPE_EVAL, + ], + ], + ], + 'config.api|API路由' => [ + 'rule' => 'required_if:type,gateway|max:100', + 'depend' => [ + 'field' => 'type', + 'value' => CronJobs::TYPE_GATEWAY, + ], + ], + 'config.method|API请求方式' => [ + 'type' => 'select', + 'rule' => 'required_if:type,gateway', + 'options' => ['GET', 'POST'], + 'default' => 'GET', + 'depend' => [ + 'field' => 'type', + 'value' => CronJobs::TYPE_GATEWAY, + ], + ], + 'config.params|执行参数' => [ + 'type' => 'json', + ], + 'config.headers|请求Headers' => [ + 'type' => 'json', + 'depend' => [ + 'field' => 'type', + 'value' => CronJobs::TYPE_GATEWAY, + ], + ], + 'rule|运行规则' => [ + 'rule' => 'required|call_crontab', + 'info' => '* * * * * *', + ], + 'status|状态' => [ + 'type' => 'select', + 'rule' => 'required', + 'options' => CronJobs::$status, + 'default' => CronJobs::STATUS_NOT, + ], + 'config.singleton|是否单例' => [ + 'type' => 'select', + 'options' => [ + YES => '是', + NO => '否', + ], + 'default' => YES, + 'info' => '解决任务的并发执行问题,任务永远只会同时运行 1 个。但是这个没法保障任务在集群时重复执行的问题。', + ], + 'config.on_one_server|是否单节点' => [ + 'type' => 'select', + 'options' => [ + YES => '是', + NO => '否', + ], + 'default' => YES, + 'info' => '多实例部署项目时,则只有一个实例会被触发。', + ], + 'bind_nodes|执行节点' => [ + 'type' => 'select', + 'rule' => 'required', + 'props' => [ + 'multiple' => true, + 'limit' => 5, + ], + 'options' => function () { + $nodes = $this->cron_manager->getAvailableNodes(); + $options = []; + foreach ($nodes as $item) { + $options[] = [ + 'value' => $item['id'], + 'label' => $item['name'], + ]; + } + + return $options; + }, + ], + 'alert_rule|报警规则' => [ + "type" => 'json', + ], + 'alert_receiver|报警接收人' => [ + 'rule' => 'number_concat_ws_comma', + ], + ], + 'table' => [ + 'columns' => [ + 'id', + 'title', + 'name', + 'type', + 'rule', + [ + 'field' => 'status', + 'enum' => [ + YES => 'success', + NO => 'info', + ], + ], + [ + 'field' => 'state', + 'hidden' => true, + ], + [ + 'field' => 'state_info', + 'title' => '运行状态', + 'virtual_field' => true, + 'popover' => [ + 'messages' => [ + '上线时间: {state.start_time}', + '最后活跃时间: {state.last_time}', + '运行次数: {state.counter}', + ], + ], + 'render' => function () { + return '悬浮查看'; + }, + ], + ], + 'rowActions' => [ + ['action' => '/crontab/{id}', 'text' => '编辑',], + [ + 'action' => 'api', + 'api' => '/crontab/trigger/{id}', + 'text' => '触发', + ], + //[ + // 'type' => 'drawer', + // 'target' => '', + // 'text' => '查看日志', + // 'props' => [ + // 'component' => 'SocketList', + // 'componentProps' => [ + // 'url' => env('OMS_WEBSOCKET_URL') . '/cronlog?name={name}', + // ], + // 'drawerWithHeader' => false, + // 'drawerSize' => '80%', + // 'drawerTitle' => '{title}日志', + // 'drawerDirection' => 'ttb', + // ], + //], + ], + ], + ]; + } + + public function beforeSave($id, &$data) + { + $data['bind_nodes'] = implode(',', $data['bind_nodes']); + } + + public function beforeFormResponse($id, &$record) + { + $record['bind_nodes'] = array_map('intval', explode(',', $record['bind_nodes'])); + } + + public function trigger($id) + { + $ret = $this->cron_manager->dispatch($id); + + return $ret === false ? $this->fail(ErrorCode::CODE_ERR_SYSTEM, "执行失败") : $this->success(); + } +} diff --git a/src/cron-center/src/CronManager.php b/src/cron-center/src/CronManager.php new file mode 100644 index 0000000..72cfa91 --- /dev/null +++ b/src/cron-center/src/CronManager.php @@ -0,0 +1,238 @@ +config = $container->get(ConfigInterface::class); + $this->db = $this->getConn(); + } + + public function addConfig() + { + if ($this->config->has('databases.cron_center')) { + return; + } + $this->config->set('databases.cron_center', [ + 'driver' => 'mysql', + 'host' => env('DB_CRON_CENTER_HOST', 'localhost'), + 'database' => env('DB_CRON_CENTER_DATABASE', 'cron_center'), + 'port' => env('DB_CRON_CENTER_PORT', 3306), + 'username' => env('DB_CRON_CENTER_USERNAME', 'root'), + 'password' => env('DB_CRON_CENTER_PASSWORD', 'root'), + 'charset' => env('DB_CRON_CENTER_CHARSET', 'utf8'), + 'collation' => env('DB_CRON_CENTER_COLLATION', 'utf8_unicode_ci'), + 'prefix' => env('DB_CRON_CENTER_PREFIX', ''), + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => (float)env('DB_MAX_IDLE_TIME', 60), + ], + 'options' => [ + \PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, + ], + ]); + } + + public function getConn() + { + $this->addConfig(); + + return DbConnectionDb::connection('cron_center'); + } + + public function getJobResource($just_current_node = true) + { + $conn = $this->getConn()->table('cron_jobs'); + $query = $conn->select('*'); + if ($just_current_node) { + $node_id = $this->getNodeId(); + if (!$node_id) { + return []; + } + $query = $conn->whereRaw("find_in_set({$node_id}, bind_nodes)"); + } + $list = $query->where('status', 1)->where('is_deleted', 0)->get()->toArray(); + foreach ($list as &$item) { + $item['config'] = json_decode($item['config'], true); + $item['state'] = json_decode($item['state'], true); + $item['alert_rule'] = json_decode($item['alert_rule'], true); + unset($item); + } + + return $list; + } + + public function getAllJobs() + { + return $this->getJobResource(false); + } + + public function getJobs() + { + $crons = []; + $jobs = $this->getJobResource(true); + foreach ($jobs as $item) { + $crontab = $this->convertCrontab($item); + if ($crontab) { + $crons[] = $crontab; + } + } + + return $crons; + } + + public function getJobById($id) + { + $conn = $this->getConn()->table('cron_jobs'); + $job = $conn->select('*')->find($id); + if (!$job) { + return false; + } + $job['config'] = json_decode($job['config'], true); + + return $this->convertCrontab($job); + } + + public function convertCrontab($item) + { + $config = $item['config']; + switch ($item['type']) { + case 'command': + $callback = array_merge([ + 'command' => $config['execute'], + ], $config['params']); + + return (new Crontab)->setId($item['id']) + ->setType($item['type']) + ->setName($item['name']) + ->setRule($item['rule']) + ->setSingleton($item['singleton'] ?? true) + ->setOnOneServer($item['on_one_server'] ?? true) + ->setCallback($callback); + break; + case 'class': + $class = explode('::', $config['execute']); + $callback = [$class[0], 'run', $config['params']]; + + return (new Crontab)->setId($item['id']) + ->setName($item['name']) + ->setRule($item['rule']) + ->setSingleton($item['singleton'] ?? true) + ->setOnOneServer($item['on_one_server'] ?? false) + ->setCallback($callback); + break; + case 'gateway': + $callback = [ + 'api' => $config['api'], + 'method' => $config['method'] ?? 'GET', + 'params' => $config['params'] ?? [], + 'headers' => $config['headers'] ?? [], + ]; + + return (new Crontab)->setId($item['id']) + ->setType($item['type']) + ->setName($item['name']) + ->setRule($item['rule']) + ->setSingleton($item['singleton'] ?? true) + ->setOnOneServer($item['on_one_server'] ?? false) + ->setCallback($callback); + break; + default: + return false; + } + } + + public function createOrUpdateNode() + { + $node_name = $this->getNodeName(); + $info = $this->config->get('server.settings'); + $nodes = $this->getConn()->table('cron_nodes'); + $data = [ + 'name' => $node_name, + 'info' => json_encode($info), + ]; + $has = $nodes->where(['name' => $node_name])->first(); + if ($has) { + $data = [ + 'status' => $has['status'] === 2 ? 2 : 1, + 'update_at' => date('Y-m-d H:i:s'), + ]; + $nodes->where(['name' => $node_name])->update($data); + } else { + $data['status'] = 1; + $nodes->insertGetId($data); + } + } + + public function blockNode($id) + { + $node = $this->getConn()->table('cron_nodes'); + + return $node->where('id', $id)->update(['status' => 3]); + } + + public function getNodeName() + { + $port = $this->config->get('server.servers.0.port'); + $host = env("HOST_IP") ?? gethostbyname(gethostname()); + $port = env("HOST_PORT") ?? $port; + + return "{$host}:{$port}"; + } + + public function getNodeId() + { + return $this->getConn()->table('cron_nodes')->where('name', $this->getNodeName())->first()['id']; + } + + public function getAvailableNodes() + { + $node = $this->getConn()->table('cron_nodes'); + + return $node->where('status', 1)->select(['id', 'name'])->get(); + } + + public function getJobState($id) + { + $state = $this->getConn()->table('cron_jobs')->find($id)['state']; + + return $state ? json_decode($state, true) : []; + } + + public function setJobState($id, $data) + { + return $this->getConn()->table('cron_jobs')->where('id', $id)->update(['state' => json_encode($data)]); + } + + public function dispatch($id) + { + $crontab = $this->getJobById($id); + if (!$crontab) { + return false; + } + $time = Carbon::createFromTimestamp(time() + 10); + $crontab->setExecuteTime($time); + $executor = container(Executor::class); + $ret = $executor->execute($crontab); + + return $ret === null; + } +} diff --git a/src/cron-center/src/Crontab.php b/src/cron-center/src/Crontab.php new file mode 100644 index 0000000..a9c9e90 --- /dev/null +++ b/src/cron-center/src/Crontab.php @@ -0,0 +1,29 @@ +id = $id; + + return $this; + } + + public function getId() + { + return $this->id; + } +} diff --git a/src/cron-center/src/CrontabDispatcherProcess.php b/src/cron-center/src/CrontabDispatcherProcess.php new file mode 100644 index 0000000..ba7d7a8 --- /dev/null +++ b/src/cron-center/src/CrontabDispatcherProcess.php @@ -0,0 +1,85 @@ +logger = $container->get(LoggerFactory::class)->get('cron_center'); + $this->scheduler = $container->get(Scheduler::class); + $this->strategy = $container->get(StrategyInterface::class); + $this->parser = make(Parser::class); + $this->cron_manager = make(CronManager::class); + } + + public function handle(): void + { + $this->event->dispatch(new CrontabDispatcherStarted()); + while (true) { + $this->cron_manager->createOrUpdateNode(); + $this->counter++; + $result = []; + $crontabs = $this->getCrontabs(); + $this->logger->info(sprintf('Crontab dispatcher the %s time, jobs total %s', $this->counter, count($crontabs))); + $last = time(); + foreach ($crontabs ?? [] as $key => $crontab) { + $time = $this->parser->parse($crontab->getRule(), $last); + if ($time) { + foreach ($time as $t) { + $result[] = clone $crontab->setExecuteTime($t); + } + } + } + $this->sleep(); + foreach ($result as $crontab) { + $this->strategy->dispatch($crontab); + } + } + } + + private function getCrontabs(): array + { + $jobs = $this->cron_manager->getJobs(); + $crontabs = []; + foreach ($jobs as $crontab) { + if ($crontab instanceof Crontab) { + $crontabs[$crontab->getName()] = $crontab; + } + } + + return array_values($crontabs); + } + + private function sleep() + { + $current = date('s', time()); + $sleep = 60 - $current; + $this->logger->info('Crontab dispatcher sleep ' . $sleep . 's.'); + $sleep > 0 && \Swoole\Coroutine::sleep($sleep); + } +} diff --git a/src/cron-center/src/CrontabRegisterListener.php b/src/cron-center/src/CrontabRegisterListener.php new file mode 100644 index 0000000..b8d0391 --- /dev/null +++ b/src/cron-center/src/CrontabRegisterListener.php @@ -0,0 +1,20 @@ +manager = new CronManager(); + } + + public function execute(Crontab $crontab) + { + if (!($crontab instanceof Crontab) || !$crontab->getCallback()) { + return false; + } + $handle = $crontab->getType() . 'Handle'; + if (!method_exists($this, $handle)) { + $this->logger->warning(sprintf('Crontab task [%s] type [%s] not support.', $crontab->getName(), $crontab->getType())); + + return false; + } + $diff = $crontab->getExecuteTime()->diffInRealSeconds(new Carbon()); + $callback = $this->$handle($crontab); + $runner = $callback ? function () use ($callback, $crontab) { + try { + $this->startJob($crontab); + $result = true; + $runnable = $this->decorateRunnable($crontab, $callback); + $result = call($runnable); + } catch (\Throwable $throwable) { + $this->logger->error(sprintf('Crontab task [%s] error: %s.', $crontab->getName(), $throwable)); + $result = false; + } finally { + $this->logResult($crontab, (bool)$result); + } + } : null; + $runner && Timer::after($diff > 0 ? $diff * 1000 : 1, $runner); + } + + public function callbackHandle(Crontab $crontab) + { + [$class, $method] = $crontab->getCallback(); + $parameters = $crontab->getCallback()[2] ?? null; + if ($class && $method && class_exists($class) && method_exists($class, $method)) { + return function () use ($class, $method, $parameters, $crontab) { + $instance = make($class, ['crontab' => $crontab]); + if ($parameters && is_array($parameters)) { + return $instance->{$method}($parameters); + } else { + return $instance->{$method}(); + } + }; + } + + return null; + } + + public function commandHandle(Crontab $crontab) + { + $input = make(ArrayInput::class, [ + array_merge($crontab->getCallback(), [ + '--job_id' => $crontab->getId(), + '--job_name' => $crontab->getName(), + ]), + ]); + $output = make(NullOutput::class); + $application = $this->container->get(ApplicationInterface::class); + $application->setAutoExit(false); + + return function () use ($application, $input, $output, $crontab) { + $result = $application->run($input, $output); + + return $result === 0; + }; + } + + public function evalHandle(Crontab $crontab) + { + return function () use ($crontab) { + return eval($crontab->getCallback()); + }; + } + + protected function runInSingleton(Crontab $crontab, Closure $runnable): Closure + { + return function () use ($crontab, $runnable) { + $taskMutex = $this->getTaskMutex(); + if ($taskMutex->exists($crontab) || !$taskMutex->create($crontab)) { + $this->logger->info(sprintf('Crontab task [%s] skipped (singleton) execution at %s.', $crontab->getName(), date('Y-m-d H:i:s'))); + + return false; + } + try { + $ret = $runnable(); + } finally { + $taskMutex->remove($crontab); + } + + return $ret; + }; + } + + protected function runOnOneServer(Crontab $crontab, Closure $runnable): Closure + { + return function () use ($crontab, $runnable) { + $taskMutex = $this->getServerMutex(); + if (!$taskMutex->attempt($crontab)) { + $this->logger->info(sprintf('Crontab task [%s] skipped (on server) execution at %s.', $crontab->getName(), date('Y-m-d H:i:s'))); + + return false; + } + + return $runnable(); + }; + } + + public function startJob(Crontab &$crontab) + { + $this->logger->info(sprintf('Crontab task [%s] start at %s.', $crontab->getName(), date('Y-m-d H:i:s'))); + } +} diff --git a/src/cron-center/src/Install/InstallCommand.php b/src/cron-center/src/Install/InstallCommand.php new file mode 100644 index 0000000..b9a2ef9 --- /dev/null +++ b/src/cron-center/src/Install/InstallCommand.php @@ -0,0 +1,29 @@ +setDescription('install db from cron-center.'); + } + + public function handle() + { + $db_conf = config('databases.cron_center'); + if (!$db_conf || !$db_conf['host']) { + $this->output->error('place set cron_center db config in env'); + } + + $sql = file_get_contents(__DIR__ . '/install.sql'); + + $re = Db::connection('cron_center')->getPdo()->exec($sql); + + $this->output->success('cron_center db install success'); + } +} diff --git a/src/cron-center/src/Install/install.sql b/src/cron-center/src/Install/install.sql new file mode 100644 index 0000000..b963702 --- /dev/null +++ b/src/cron-center/src/Install/install.sql @@ -0,0 +1,67 @@ +-- data-focus db + +CREATE TABLE `dsn` ( + `id` INT(12) UNSIGNED NOT NULL AUTO_INCREMENT, + `type` TINYINT(4) UNSIGNED NOT NULL DEFAULT '0' COMMENT '类型, 1 mysql', + `name` VARCHAR(50) NOT NULL COMMENT '名称', + `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注', + `config` TINYTEXT NOT NULL COMMENT '配置', + `create_uid` INT(12) UNSIGNED NOT NULL DEFAULT '0' COMMENT '创建者id', + `status` TINYINT(4) UNSIGNED NOT NULL DEFAULT '0' COMMENT '0启用, 1禁用', + `create_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) + ENGINE = InnoDB + DEFAULT CHARSET = `utf8mb4`; + +CREATE TABLE `plugin_function` ( + `id` INT(12) UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(50) NOT NULL COMMENT '中文名称', + `type` TINYINT(1) UNSIGNED NOT NULL DEFAULT '1' COMMENT '方法类型 1列插件, 2表插件', + `func_name` VARCHAR(50) NOT NULL COMMENT '方法名', + `context` TEXT COMMENT '方法体定义', + `create_uid` INT(120) UNSIGNED NOT NULL DEFAULT '0' COMMENT '创建者id', + `status` TINYINT(4) UNSIGNED NOT NULL DEFAULT '0' COMMENT '状态', + `create_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `func_name` (`func_name`) +) + ENGINE = InnoDB + DEFAULT CHARSET = `utf8mb4`; + +CREATE TABLE `report_change_log` ( + `id` INT(12) UNSIGNED NOT NULL AUTO_INCREMENT, + `report_id` INT(12) UNSIGNED NOT NULL COMMENT '报表id', + `dev_uid` INT(12) UNSIGNED NOT NULL COMMENT '开发者id', + `dev_content` TEXT COMMENT '内容', + `published` TINYINT(4) UNSIGNED NOT NULL DEFAULT '0' COMMENT '0未发布, 1已发布', + `create_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `report_id` (`report_id`) +) + ENGINE = InnoDB + DEFAULT CHARSET = `utf8mb4`; + +CREATE TABLE `reports` ( + `id` INT(12) UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '名称', + `pid` INT(12) UNSIGNED NOT NULL COMMENT '父id', + `publish_content` TEXT COMMENT '发布的报表内容', + `dev_content` TEXT COMMENT '开发中的报表内容', + `bind_rold_ids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '授权的角色id', + `bind_uids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '绑定的用户id', + `create_uid` INT(12) UNSIGNED NOT NULL COMMENT '创建者id', + `dev_uid` INT(12) UNSIGNED NOT NULL COMMENT '开发者id', + `crontab` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '定时任务', + `config` TEXT COMMENT '配置', + `publish_at` DATETIME DEFAULT NULL COMMENT '最后一次发布时间', + `create_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', + PRIMARY KEY (`id`) +) + ENGINE = InnoDB + DEFAULT CHARSET = `utf8mb4`; + diff --git a/src/cron-center/src/Model/CronJobs.php b/src/cron-center/src/Model/CronJobs.php new file mode 100644 index 0000000..9cbefcd --- /dev/null +++ b/src/cron-center/src/Model/CronJobs.php @@ -0,0 +1,61 @@ + 'array', + 'config' => 'array', + 'state' => 'array', + 'status' => 'integer', + 'is_deleted' => 'integer', + ]; + + const TYPE_CMD = 'command'; + + const TYPE_CLASS = 'class'; + + const TYPE_EVAL = 'eval'; + + const TYPE_GATEWAY = 'gateway'; + + public static $types = [ + self::TYPE_CLASS => '内部类', + self::TYPE_CMD => '内部命令', + self::TYPE_EVAL => '外部命令', + self::TYPE_GATEWAY => '网关API', + ]; +} diff --git a/src/cron-center/src/Model/CronNodes.php b/src/cron-center/src/Model/CronNodes.php new file mode 100644 index 0000000..3a7b0ac --- /dev/null +++ b/src/cron-center/src/Model/CronNodes.php @@ -0,0 +1,32 @@ + 'integer', 'info' => 'array']; + + const STATUS_BLOCK = 3; + + const STATUS_LOSS = 4; + + public static $status = [ + self::STATUS_NOT => '已下线', + self::STATUS_YES => '运行中', + self::STATUS_BLOCK => '锁定', + self::STATUS_LOSS => '已失联', + ]; +} diff --git a/src/cron-center/src/Model/JobRunLog.php b/src/cron-center/src/Model/JobRunLog.php new file mode 100644 index 0000000..c0847bd --- /dev/null +++ b/src/cron-center/src/Model/JobRunLog.php @@ -0,0 +1,22 @@ + 'integer']; +} diff --git a/src/cron-center/src/config/routes.php b/src/cron-center/src/config/routes.php new file mode 100644 index 0000000..7bfb5e7 --- /dev/null +++ b/src/cron-center/src/config/routes.php @@ -0,0 +1,13 @@ + [ + 'data_focus' => db_complete([ + 'host' => env('DATA_FOCUS_DB_HOST', env('HYPERF_ADMIN_DB_HOST')), + 'database' => env('DATA_FOCUS_DB_NAME', env('HYPERF_ADMIN_DB_NAME')), + 'username' => env('DATA_FOCUS_DB_USER', env('HYPERF_ADMIN_DB_USER')), + 'password' => env('DATA_FOCUS_DB_PWD', env('HYPERF_ADMIN_DB_PWD')), + ]), + ], + 'commands' => [ + InstallCommand::class, + ], + 'dependencies' => [], + 'listeners' => [ + DataFocusBootAppConfListener::class => -1, + ], + 'init_routes' => [ + __DIR__ . '/config/routes.php', + ], + ]; + } +} diff --git a/src/data-focus/src/Controller/DsnController.php b/src/data-focus/src/Controller/DsnController.php new file mode 100644 index 0000000..0706509 --- /dev/null +++ b/src/data-focus/src/Controller/DsnController.php @@ -0,0 +1,86 @@ + [ + 'id|#' => '', + 'type|类型' => [ + 'type' => 'select', + 'options' => Dsn::$types, + 'default' => Dsn::TYPE_MYSQL, + ], + 'name|名称' => '', + 'remark|备注' => '', + 'config|配置' => [ + 'type' => 'json', + ], + 'status|状态' => [ + 'type' => 'select', + 'options' => Dsn::$status, + 'default' => Dsn::STATUS_YES, + ], + ], + 'hasOne' => [ + 'pt_oms.pt_oms.user:create_uid->id,realname', + ], + 'table' => [ + 'columns' => [ + 'id', + 'name', + 'type', + [ + 'field' => 'status', + 'enum' => [ + 'info', + 'success', + ], + ], + [ + 'field' => 'create_uid', + 'title' => '创建者', + 'render' => function ($val, $row) { + return $row['realname']; + }, + ], + ], + 'rowActions' => [ + [ + 'type' => 'jump', + 'target' => '/dsn/{id}', + 'text' => '编辑', + ], + [ + 'type' => 'api', + 'target' => '/dsn/{id}', + 'text' => '删除', + 'props' => [ + 'type' => 'danger', + ], + ], + ], + ], + ]; + } + + public function beforeSave($id, &$data) + { + if (!$id) { + $data['create_uid'] = $this->userId(); + } + } + + public function afterSave($id, $data, $entity) + { + make(\HyperfAdmin\DataFocus\Service\Dsn::class)->addToConfig($id); + } +} diff --git a/src/data-focus/src/Controller/PluginFunctionController.php b/src/data-focus/src/Controller/PluginFunctionController.php new file mode 100644 index 0000000..cbf73cb --- /dev/null +++ b/src/data-focus/src/Controller/PluginFunctionController.php @@ -0,0 +1,85 @@ + [ + 'id|#' => '', + 'name|中文名称' => [ + 'rule' => 'required|max:50', + ], + 'type|类型' => [ + 'type' => 'select', + 'rule' => 'required', + 'options' => PluginFunction::$type, + 'default' => PluginFunction::TYPE_COLUMN, + ], + 'context|方法体' => [ + 'type' => 'code', + 'rule' => 'required', + ], + 'status|状态' => [ + 'type' => 'select', + 'rule' => 'required', + 'options' => PluginFunction::$status, + 'default' => PluginFunction::STATUS_YES, + ], + ], + 'table' => [ + 'columns' => [ + 'id', + 'name', + [ + 'field' => 'func_name', + 'title' => '方法名', + ], + [ + 'field' => 'status', + 'enum' => [ + 0 => 'info', + 1 => 'success', + ], + ], + ], + 'rowActions' => [ + [ + 'type' => 'jump', + 'target' => '/plugin_function/{id}', + 'text' => '编辑', + ], + ], + ], + ]; + } + + public function beforeSave($id, &$data) + { + $sandbox = make(PHPSandbox::class)->setFunctionValidator(function ($name, $sandbox) { + if(preg_match('/^(df|array|str|url)/', $name)) { + return true; + } + + return false; + }); + $parse = $sandbox->validateCode(sprintf("exception('只允许定义一个方法'); + } + /** @var \PhpParser\Node\Stmt\Function_ $func */ + $func = $parse[0]; + $data['func_name'] = $func->name->toString(); + if(count($func->params) < 1) { + $this->exception('方法至少需要一个参数'); + } + } +} diff --git a/src/data-focus/src/Controller/ReportChangeLogController.php b/src/data-focus/src/Controller/ReportChangeLogController.php new file mode 100644 index 0000000..3f9f5bc --- /dev/null +++ b/src/data-focus/src/Controller/ReportChangeLogController.php @@ -0,0 +1,48 @@ + [ + 'id|#' => '', + 'report_id|报表id' => '', + 'dev_uid|开发者id' => '', + 'dev_content|内容' => '', + 'published|0未发布, 1已发布' => '', + 'bind_uids|绑定的用户id' => '', + 'create_uid|创建者id' => '', + 'crontab|定时任务' => '', + 'config|配置' => '', + 'publish_at|最后一次发布时间' => [ + 'type' => 'datetime', + ], + ], + 'table' => [ + 'rowActions' => [ + [ + 'type' => 'jump', + 'target' => '/reportchangelog/{id}', + 'text' => '编辑', + ], + [ + 'type' => 'api', + 'target' => '/reportchangelog/{id}', + 'text' => '删除', + 'props' => [ + 'type' => 'danger', + ], + ], + ], + ], + ]; + } +} diff --git a/src/data-focus/src/Controller/ReportsController.php b/src/data-focus/src/Controller/ReportsController.php new file mode 100644 index 0000000..71fb101 --- /dev/null +++ b/src/data-focus/src/Controller/ReportsController.php @@ -0,0 +1,149 @@ + [ + 'id|#' => '', + 'name|名称' => [ + 'rule' => 'required|max:50', + ], + 'pid|父id' => '', + 'dev_content|开发中的报表内容' => [ + 'type' => 'code', + 'props' => [ + 'options' => [ + 'language' => 'php', + ], + ], + ], + 'bind_rold_ids|授权的角色id' => '', + 'bind_uids|绑定的用户id' => '', + 'create_uid|创建者id' => [ + 'form' => false, + ], + 'dev_uid|开发者id' => [ + 'form' => false, + ], + 'crontab|定时任务' => '', + 'config|配置' => [ + 'type' => 'json', + ], + 'publish_at|最后一次发布时间' => [ + 'type' => 'datetime', + 'form' => false, + ], + ], + 'table' => [ + 'columns' => [ + 'id', + 'name', + 'crontab', + [ + 'field' => 'publish_at', + 'title' => '最后发布时间', + ], + ], + 'rowActions' => [ + [ + 'type' => 'jump', + 'target' => '/reports/panel/{id}?dev=true', + 'text' => '查看', + ], + [ + [ + 'type' => 'jump', + 'target' => '/reports/{id}', + 'text' => '编辑', + ], + [ + 'type' => 'api', + 'target' => '/reports/publish/{id}', + 'text' => '发布', + ], + ], + //[ + // 'type' => 'api', + // 'target' => '/reports/{id}', + // 'text' => '删除', + // 'props' => [ + // 'type' => 'danger', + // ], + //], + ], + ], + ]; + } + + public function beforeSave($id, &$data) + { + $codeRunner = make(CodeRunner::class); + $check = $codeRunner->run($data['dev_content']); + if ($check['errors']) { + $this->exception(implode("\n", $check['errors'])); + } + if (!$id) { + $data['create_uid'] = $this->userId(); + } + $data['pid'] = (int)$data['pid']; + $data['dev_uid'] = $this->userId(); + } + + public function afterSave($id, $data) + { + make(ReportChangeLog::class)->fill([ + 'report_id' => $id, + 'dev_uid' => $this->userId(), + 'dev_content' => $data['dev_content'], + 'published' => ReportChangeLog::STATUS_NOT, + ])->save(); + } + + public function execute($id) + { + $part_id = $this->request->input('id'); + $dev_mode = (bool)$this->request->input('dev'); + $record = $this->getModel()->find($id)->toArray(); + $code = $dev_mode ? $record['dev_content'] : $record['publish_content']; + if (!$code) { + return $this->fail(ErrorCode::CODE_ERR_PARAM, '尚未发布'); + } + $codeRunner = make(CodeRunner::class); + $ret = $codeRunner->run($code, $part_id); + + return $this->success($ret); + } + + public function publish($id) + { + $entity = $this->getModel()->find($id); + $record = $entity->toArray(); + if (!$record) { + return $this->fail(ErrorCode::CODE_ERR_PARAM, 'Not Found!'); + } + $code = $record['dev_content']; + $codeRunner = make(CodeRunner::class); + $ret = $codeRunner->run($code); + if ($ret['errors']) { + return $this->fail(ErrorCode::CODE_ERR_PARAM, implode("\n", $ret['errors'])); + } + $entity->update([ + 'publish_content' => $record['dev_content'], + 'publish_at' => date('Y-m-d H:i:s'), + ]); + + return $this->success(); + } +} diff --git a/src/data-focus/src/Install/InstallCommand.php b/src/data-focus/src/Install/InstallCommand.php new file mode 100644 index 0000000..4780087 --- /dev/null +++ b/src/data-focus/src/Install/InstallCommand.php @@ -0,0 +1,29 @@ +setDescription('install db from data-focus.'); + } + + public function handle() + { + $db_conf = config('databases.data_focus'); + if (!$db_conf || !$db_conf['host']) { + $this->output->error('place set data_focus db config in env'); + } + + $sql = file_get_contents(__DIR__ . '/install.sql'); + + $re = Db::connection('cron_center')->getPdo()->exec($sql); + + $this->output->success('data_focus db install success'); + } +} diff --git a/src/data-focus/src/Install/install.sql b/src/data-focus/src/Install/install.sql new file mode 100644 index 0000000..cae362f --- /dev/null +++ b/src/data-focus/src/Install/install.sql @@ -0,0 +1,59 @@ +-- cron-center db + +CREATE TABLE `dsn` ( + `id` int(12) unsigned NOT NULL AUTO_INCREMENT, + `type` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '类型, 1 mysql', + `name` varchar(50) NOT NULL COMMENT '名称', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `config` tinytext NOT NULL COMMENT '配置', + `create_uid` int(12) unsigned NOT NULL DEFAULT '0' COMMENT '创建者id', + `status` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '0启用, 1禁用', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `plugin_function` ( + `id` int(12) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL COMMENT '中文名称', + `type` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '方法类型 1列插件, 2表插件', + `func_name` varchar(50) NOT NULL COMMENT '方法名', + `context` text COMMENT '方法体定义', + `create_uid` int(120) unsigned NOT NULL DEFAULT '0' COMMENT '创建者id', + `status` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '状态', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `func_name` (`func_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `report_change_log` ( + `id` int(12) unsigned NOT NULL AUTO_INCREMENT, + `report_id` int(12) unsigned NOT NULL COMMENT '报表id', + `dev_uid` int(12) unsigned NOT NULL COMMENT '开发者id', + `dev_content` text COMMENT '内容', + `published` tinyint(4) unsigned NOT NULL DEFAULT '0' COMMENT '0未发布, 1已发布', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `report_id` (`report_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `reports` ( + `id` int(12) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称', + `pid` int(12) unsigned NOT NULL COMMENT '父id', + `publish_content` text COMMENT '发布的报表内容', + `dev_content` text COMMENT '开发中的报表内容', + `bind_rold_ids` varchar(255) NOT NULL DEFAULT '' COMMENT '授权的角色id', + `bind_uids` varchar(255) NOT NULL DEFAULT '' COMMENT '绑定的用户id', + `create_uid` int(12) unsigned NOT NULL COMMENT '创建者id', + `dev_uid` int(12) unsigned NOT NULL COMMENT '开发者id', + `crontab` varchar(255) NOT NULL DEFAULT '' COMMENT '定时任务', + `config` text COMMENT '配置', + `publish_at` datetime DEFAULT NULL COMMENT '最后一次发布时间', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + diff --git a/src/data-focus/src/Model/Dsn.php b/src/data-focus/src/Model/Dsn.php new file mode 100644 index 0000000..54bde32 --- /dev/null +++ b/src/data-focus/src/Model/Dsn.php @@ -0,0 +1,52 @@ + 'string', + 'remark' => 'string', + 'config' => 'array', + 'create_uid' => 'int', + 'status' => 'int', + ]; + + const TYPE_MYSQL = 1; + + const TYPE_REDIS = 2; + + public static $types = [ + self::TYPE_MYSQL => "MySql", + self::TYPE_REDIS => 'Redis', + ]; +} diff --git a/src/data-focus/src/Model/PluginFunction.php b/src/data-focus/src/Model/PluginFunction.php new file mode 100644 index 0000000..8469d7e --- /dev/null +++ b/src/data-focus/src/Model/PluginFunction.php @@ -0,0 +1,52 @@ + 'string', + 'func_name' => 'string', + 'context' => 'string', + 'create_uid' => 'int', + 'type' => 'int', + 'status' => 'int', + ]; + + const TYPE_COLUMN = 1; + + const TYPE_TABLE = 2; + + public static $type = [ + self::TYPE_COLUMN => '行插件', + self::TYPE_TABLE => '表插件', + ]; +} diff --git a/src/data-focus/src/Model/ReportChangeLog.php b/src/data-focus/src/Model/ReportChangeLog.php new file mode 100644 index 0000000..3d7e8c8 --- /dev/null +++ b/src/data-focus/src/Model/ReportChangeLog.php @@ -0,0 +1,37 @@ + 'int', + 'dev_uid' => 'int', + 'dev_content' => 'string', + 'published' => 'int', + ]; +} diff --git a/src/data-focus/src/Model/Reports.php b/src/data-focus/src/Model/Reports.php new file mode 100644 index 0000000..ec4bc1b --- /dev/null +++ b/src/data-focus/src/Model/Reports.php @@ -0,0 +1,59 @@ + 'int', + 'name' => 'string', + 'publish_content' => 'string', + 'dev_content' => 'string', + 'bind_role_ids' => 'string', + 'bind_uids' => 'string', + 'create_uid' => 'int', + 'dev_uid' => 'int', + 'crontab' => 'string', + 'config' => 'string', + 'publish_at' => 'string', + ]; +} diff --git a/src/data-focus/src/Service/Dsn.php b/src/data-focus/src/Service/Dsn.php new file mode 100644 index 0000000..8d6660d --- /dev/null +++ b/src/data-focus/src/Service/Dsn.php @@ -0,0 +1,102 @@ +config = $config; + } + + public function initAll() + { + $dsn_list = DsnModel::query()->where('status', '=', DsnModel::STATUS_YES)->select([ + 'name', + 'type', + 'config', + ])->get()->toArray(); + // todo decrypt + //$dsn_list = $this->config->decryptOne($dsn_list); + foreach($dsn_list as $dsn) { + $method = 'add' . strtolower(DsnModel::$types[$dsn['type']]); + if(method_exists($this, $method)) { + $this->{$method}($dsn['name'], $dsn['config']); + } + } + } + + public function addToConfig($id) + { + $this->changeConfig($id, 'add'); + } + + public function removeFromConfig($id) + { + $this->changeConfig($id, 'remove'); + } + + public function changeConfig($id, $type) + { + $dsn = DsnModel::query() + ->select(['name', 'type', 'config']) + ->where('id', $id) + ->firstAsArray(); + $dsn = $this->config->decryptOne($dsn); + if(!$dsn) { + return false; + } + $method = $type . strtolower(DsnModel::$types[$dsn['type']]); + $this->{$method}($dsn['name'], $dsn['config']); + } + + public function addmysql($name, $conf) + { + $this->config->set('databases.data_focus_' . $name, db_complete($conf)); + } + + public function removemysql($name) + { + // TODO Rewrite core/Util/Config.php + //$this->config->unset('database.data_focus_' . $name); + } + + public function addredis($name, $conf) + { + $this->config->set('redis.data_focus_' . $name, $conf); + } + + public function removeredis($name) + { + } + + public static function getChanged() + { + $key = 'data_focus:' . php_uname('n') . ':dsn_worker_sync_last_time'; + $last_check_time = Redis::get($key) ?: date('Y-m-d H:i:s'); + $changed = DsnModel::query() + ->select(['id']) + ->where('update_at', '>=', $last_check_time) + ->where('status', DsnModel::STATUS_YES) + ->getAsArray(); + if(!$changed) { + return null; + } + Redis::set($key, date('Y-m-d H:i:s')); + + return array_column($changed, 'id'); + } + + public static function changedSetToConfig($ids) + { + $self = make(Dsn::class); + foreach($ids as $id) { + $self->addToConfig($id); + } + } +} diff --git a/src/data-focus/src/Util/BootAppConfListener.php b/src/data-focus/src/Util/BootAppConfListener.php new file mode 100644 index 0000000..13c0033 --- /dev/null +++ b/src/data-focus/src/Util/BootAppConfListener.php @@ -0,0 +1,21 @@ +initAll(); + } +} diff --git a/src/data-focus/src/Util/CodeRunner.php b/src/data-focus/src/Util/CodeRunner.php new file mode 100644 index 0000000..e0748c1 --- /dev/null +++ b/src/data-focus/src/Util/CodeRunner.php @@ -0,0 +1,345 @@ +runner = (new PHPSandbox())->setFunctionValidator(function ($name, $sandbox) { + if(preg_match('/^(df|array|str|url)/', $name)) { + return true; + } + + return false; + }); + } + + public function run($code, $ids = []) + { + $ids = (array)$ids; + $result = []; + $errors = []; + $startTime = microtime(true); + $namespace = sprintf("PHPSandbox_%s_%s", md5($code), (int)(microtime(true) * 1000)); + $this->namespace = $namespace; + $this->runner->setNamespace($namespace); + if(preg_match_all('/df_\w+/', $code, $m)) { + $plugins = PluginFunction::query() + ->where('status', PluginFunction::STATUS_YES) + ->whereIn('func_name', array_unique($m[0])) + ->get() + ->toArray(); + $plugin_str = []; + foreach($plugins as $item) { + $plugin_str[] = sprintf("", $item['context']); + } + $code = implode("\n", $plugin_str) . $code; + } + try { + if(preg_match_all('/<\?(?:php|=).*?\?>/msui', $code, $match)) { + foreach($match[0] ?? [] as $part) { + $ret = $this->executePHPCode($part); + $wrap = null; + $replace = is_array($ret) ? json_encode($ret) : $ret; + if(Str::contains($code, "\"$part")) { + $wrap = '"'; + } + if(Str::contains($code, "'$part")) { + $wrap = "'"; + } + if($wrap) { + $replace = $wrap === '"' ? str_replace('"', '\"', $replace) : str_replace("'", "\'", $replace); + } + $code = str_replace($part, $replace, $code); + } + } + if(preg_match_all('/\{{([^}]+)\}}/i', $code, $m)) { + foreach($m[1] as $each) { + $ret = $this->pipFilter($each); + $code = Str::replaceArray('{{' . $each . '}}', [$ret], $code); + } + } + $code = removeComment($code); + $parse = str_get_html($code, true, true, DEFAULT_TARGET_CHARSET, false); + $children = $parse ? $parse->root->children() : []; + $node_ids = []; + foreach($children as $index => $node) { + $id = $node->getAttribute('id') ?: $index; + if(in_array((string)$id, $node_ids)) { + throw new \Exception('标签id重复: ' . $id); + } + $node_ids[] = (string)$id; + if($ids && !in_array((string)$id, $ids)) { + continue; + } + $type = $node->tag; + $text = trim($node->innertext()); + if($node->hasChildNodes()) { + throw new \Exception('一级标签不允许嵌套'); + } + $data = []; + $part_start_time = microtime(true); + $part_type = 'table'; // table/info/filter ... + $table_plugin = $this->explodeTablePlugin($node->getAttribute('table_plugin') ?: ''); + if($type == 'sql') { + if(!Str::startsWith($text, ['select', 'SELECT'])) { + throw new \Exception('只允许 SELECT 语句'); + } + $count = count(array_filter(explode(';', $text))); + if($count > 1) { + throw new \Exception('一个 节点只允许一个sql语句'); + } + //$explain = $conn->select('explain ' . $text); + $data = df_db_query($text, $node->getAttribute('dsn') ?: 'default'); + if(preg_match_all('/[\w\'\"]+,?\s+--\s+@.*/mui', $text, $m)) { + $self = $this; + $field_plugin = array_map(function ($item) use ($self) { + [ + $field, + $plugin_str, + ] = preg_split('/\s+,?--\s+/', $item); + + return [ + preg_replace('/[\'\",\s]+/', '', $field), + $self->explodeTablePlugin(preg_replace('/@/mui', '', $plugin_str)), + ]; + }, $m[0]); + $data = $this->filterByFieldPlugin($data, $field_plugin); + } + } + if($type == 'json') { + $data = df_json_decode($text); + } + if($type == 'info') { + $part_type = 'info'; + $data = $text; + } + if($type == 'filter') { + $part_type = 'filter'; + $data = $text; + } + $data = $this->filterByTablePlugin($data, $table_plugin); + $dump = df_dump_get(); + if($dump) { + $result[] = [ + 'id' => 'debug_dump', + 'type' => 'debug_dump', + 'data' => $dump, + ]; + } + $result[] = [ + 'id' => $id, + 'type' => $part_type, + 'chart' => (object)$this->getChartOptions($node->getAttribute('chart'), $data), + 'col' => $this->getColOption($node->getAttribute('span') ?: $node->getAttribute('col')), + 'show_table' => $node->hasAttribute('show_table') ? $node->getAttribute('show_table') == 'true' : false, + 'data' => (array)$data, + 'tips' => $node->getAttribute('tips') ?: '', + 'runtime' => [ + 'use_ms' => (int)((microtime(true) - $part_start_time) * 1000), + 'sql' => df_collected('sql_logs'), + ], + ]; + df_collected_clear('sql_logs'); + df_dump_clear();; + } + } catch (\Exception $exception) { + $errors[] = sprintf("Exception:%s", $exception->getMessage()); + } catch (\Throwable $throwable) { + $errors[] = sprintf("Throwable:%s on line %s", $throwable->getMessage(), $throwable->getLine()); + } + $endTime = microtime(true); + + return [ + 'result' => $result, + 'errors' => array_map(function ($item) use ($namespace) { + return Str::replaceArray($namespace . '\\', [''], $item); + }, $errors), + 'info' => [ + 'use_ms' => (int)(($endTime - $startTime) * 1000), + ], + ]; + } + + // plugin_b:11,33|plugin_a + public function explodeTablePlugin($str) + { + if(!$str) { + return []; + } + $parts = explode('|', $str); + + return array_map(function ($item) { + $tokens = preg_split('/[:,]/', $item); + $func_name = array_shift($tokens); + + return [ + $func_name, + $tokens, + ]; + }, $parts); + } + + public function filterByTablePlugin($data, $plugins) + { + foreach($plugins as $item) { + $name = array_shift($item); + $params = $item[0] ?? []; + array_unshift($params, $data); + $function_name = $this->namespace ? "$this->namespace\\$name" : $name; + $data = call($function_name, $params); + } + + return $data; + } + + public function filterByFieldPlugin($data, $plugins) + { + foreach($plugins as $item) { + $field = array_shift($item); + foreach($item as $field_plugins) { + $field_values = array_column($data, $field); + foreach($field_plugins as $plugin) { + $name = array_shift($plugin); + array_unshift($plugin, $field, $field_values); + if(function_exists($name)) { + $function_name = $name; + } else { + $function_name = $this->namespace ? "$this->namespace\\$name" : $name; + } + $trans = call($function_name, $plugin); + foreach($data as &$row) { + $row[$field] = $trans[$row[$field]] ?? $row[$field]; + unset($row); + } + } + } + } + + return $data; + } + + public function executePHPCode($code) + { + return $this->runner->execute($code); + } + + public function getChartOptions($conf, &$data) + { + $json = df_json_decode($conf); + if($json) { + return $json; + } + if($conf == 'NumberPanel') { + $data = array_values($data); + if($data) { + $type = array_intersect([ + 'number', + 'label', + ], array_map('strtolower', array_keys($data[0]))); + if(count($type) !== 2) { + $new = []; + foreach($data[0] as $key => $val) { + $new[] = ["number" => $val, 'label' => $key]; + } + $data = $new; + } + } + + return [ + 'type' => 'NumberPanel', + ]; + } + if(Str::startsWith($conf, 'LineChart')) { + $token = explode('|', $conf); + array_shift($token); + $field = explode(',', array_shift($token)); + $x = array_shift($field); + $y = $field; + + return [ + 'type' => 'LineChart', + 'props' => [ + 'xAxis' => $x, + 'yAxis' => $y ?: null, + ], + ]; + } + if(Str::startsWith($conf, 'PieChart')) { + $data = array_values($data); + if($data) { + $type = array_intersect([ + 'number', + 'label', + ], array_map('strtolower', array_keys($data[0]))); + if(count($type) !== 2) { + $new = []; + foreach($data[0] as $key => $val) { + $new[] = ["number" => $val, 'label' => $key]; + } + $data = $new; + } + } + + return [ + 'type' => 'PieChart', + ]; + } + if(Str::startsWith($conf, 'ColumnChart')) { + $token = explode('|', $conf); + array_shift($token); + $field = explode(',', array_shift($token)); + $x = array_shift($field); + $y = $field; + + return [ + 'type' => 'ColumnChart', + 'props' => [ + 'xAxis' => $x, + 'yAxis' => $y ?: null, + ], + ]; + } + + return df_json_decode($conf); + } + + public function getColOption($conf) + { + if(!$conf) { + return ['span' => 24]; + } + $decode = df_json_decode($conf); + if(is_array($decode)) { + return $decode; + } + + return ['span' => (int)($conf ?: 24)]; + } + + public function pipFilter($str) + { + $parts = explode('|', trim($str)); + $first = array_shift($parts); + $code = ""; + $ret = $this->executePHPCode($code); + + return $raw ? $ret : "\"$ret\""; + } +} diff --git a/src/data-focus/src/Util/PHPSandbox.php b/src/data-focus/src/Util/PHPSandbox.php new file mode 100644 index 0000000..b4bbba9 --- /dev/null +++ b/src/data-focus/src/Util/PHPSandbox.php @@ -0,0 +1,173 @@ +/msui', $code, $match)) { + $code = trim($match[1]); + } + if($this->namespace) { + $code = sprintf("namespace %s {\n%s\n}", $this->namespace, $code); + } + $this->validateCode(sprintf("getMessage(), $exception->getCode(), $exception->getPrevious()); + } + } + + public function parserCode($code) + { + $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); + try { + return $parser->parse($code); + } catch (Error $error) { + throw new SandboxException($error->getMessage(), $error->getCode(), $error->getPrevious()); + } + } + + public function setAllowFunctions($name) + { + $this->allow_functions = array_merge($this->allow_functions, (array)$name); + + return $this; + } + + public function setFunctionValidator(callable $callable) + { + $this->function_validator = $callable; + + return $this; + } + + public function validateCode($code) + { + $parser = $this->parserCode($code); + $traverser = new NodeTraverser(); + $validator = new ValidatorVisitor($this); + $traverser->addVisitor($validator); + $traverser->traverse($parser); + + return $parser; + } + + public function filterFunction(Function_ $parserFunction, $namespace = '') + { + $function = $parserFunction->name->toString(); + if($this->namespace) { + $namespace = $this->namespace; + } + $name = $namespace ? "$namespace\\$function" : $function; + if(in_array($name, $this->define_function)) { + throw new SandboxException(sprintf('previously redeclare function [%s]', $function)); + } + $this->validatorFunc($function); + $this->define_function[] = $name; + } + + public function filterFuncCall(FuncCall $call, $namespace = '') + { + $call = $call->name->toString(); + $this->validatorFunc($call); + } + + public function filterClass(Class_ $parserClass) + { + $class = $parserClass->name->toString(); + throw new SandboxException(sprintf('define class [%s] not allowed!', $class)); + } + + public function validatorFunc($name) + { + if(in_array($name, $this->allow_functions)) { + return true; + } + if(!$this->function_validator) { + return true; + } + if(!call_user_func_array($this->function_validator, [$name, $this])) { + throw new SandboxException(sprintf('Function [%s] not allowed!', $name)); + } + } + + public function functionDefined($name) + { + return in_array($name, $this->define_function); + } + + public function setNamespace($namespace) + { + $this->namespace = $namespace; + } +} diff --git a/src/data-focus/src/Util/SandboxException.php b/src/data-focus/src/Util/SandboxException.php new file mode 100644 index 0000000..b073d3c --- /dev/null +++ b/src/data-focus/src/Util/SandboxException.php @@ -0,0 +1,12 @@ +size is the "real" number of bytes the dom was created from. + * but for most purposes, it's a really good estimation. + * Paperg - Added the forceTagsClosed to the dom constructor. Forcing tags closed is great for malformed html, but it CAN lead to parsing errors. + * Allow the user to tell us how much they trust the html. + * Paperg add the text and plaintext to the selectors for the find syntax. plaintext implies text in the innertext of a node. text implies that the tag is a text node. + * This allows for us to find tags based on the text they contain. + * Create find_ancestor_tag to see if a tag is - at any level - inside of another specific tag. + * Paperg: added parse_charset so that we know about the character set of the source document. + * NOTE: If the user's system has a routine called get_last_retrieve_url_contents_content_type availalbe, we will assume it's returning the content-type header from the + * last transfer or curl_exec, and we will parse that and use it in preference to any other method of charset detection. + * Found infinite loop in the case of broken html in restore_noise. Rewrote to protect from that. + * PaperG (John Schlick) Added get_display_size for "IMG" tags. + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @author S.C. Chen + * @author John Schlick + * @author Rus Carroll + * @version 1.5 ($Rev: 196 $) + * @package PlaceLocalInclude + * @subpackage simple_html_dom + */ +/** + * All of the Defines for the classes below. + * + * @author S.C. Chen + */ +define('HDOM_TYPE_ELEMENT', 1); +define('HDOM_TYPE_COMMENT', 2); +define('HDOM_TYPE_TEXT', 3); +define('HDOM_TYPE_ENDTAG', 4); +define('HDOM_TYPE_ROOT', 5); +define('HDOM_TYPE_UNKNOWN', 6); +define('HDOM_QUOTE_DOUBLE', 0); +define('HDOM_QUOTE_SINGLE', 1); +define('HDOM_QUOTE_NO', 3); +define('HDOM_INFO_BEGIN', 0); +define('HDOM_INFO_END', 1); +define('HDOM_INFO_QUOTE', 2); +define('HDOM_INFO_SPACE', 3); +define('HDOM_INFO_TEXT', 4); +define('HDOM_INFO_INNER', 5); +define('HDOM_INFO_OUTER', 6); +define('HDOM_INFO_ENDSPACE', 7); +define('DEFAULT_TARGET_CHARSET', 'UTF-8'); +define('DEFAULT_BR_TEXT', "\r\n"); +define('DEFAULT_SPAN_TEXT', " "); +if(!defined('MAX_FILE_SIZE')) { + define('MAX_FILE_SIZE', 600000); +} +// helper functions +// ----------------------------------------------------------------------------- +// get html dom from file +// $maxlen is defined in the code as PHP_STREAM_COPY_ALL which is defined as -1. +function file_get_html($url, $use_include_path = false, $context = null, $offset = -1, $maxLen = -1, $lowercase = true, $forceTagsClosed = true, $target_charset = DEFAULT_TARGET_CHARSET, $stripRN = true, $defaultBRText = DEFAULT_BR_TEXT, $defaultSpanText = DEFAULT_SPAN_TEXT) +{ + // We DO force the tags to be terminated. + $dom = new simple_html_dom(null, $lowercase, $forceTagsClosed, $target_charset, $stripRN, $defaultBRText, $defaultSpanText); + do { + $repeat = false; + if($context !== null) { + // Test if "Accept-Encoding: gzip" has been set in $context + $params = stream_context_get_params($context); + if(isset($params['options']['http']['header']) && preg_match('/gzip/', $params['options']['http']['header']) !== false) { + $contents = file_get_contents('compress.zlib://' . $url, $use_include_path, $context, $offset); + } else { + $contents = file_get_contents($url, $use_include_path, $context, $offset); + } + } else { + $contents = file_get_contents($url, $use_include_path, null, $offset); + } + // test if the URL doesn't return a 200 status + if(isset($http_response_header) && strpos($http_response_header[0], '200') === false) { + // has a 301 redirect header been sent? + $pattern = "/^Location:\s*(.*)$/i"; + $location_headers = preg_grep($pattern, $http_response_header); + if(!empty($location_headers) && preg_match($pattern, array_values($location_headers)[0], $matches)) { + // set the URL to that returned via the redirect header and repeat this loop + $url = $matches[1]; + $repeat = true; + } + } + } while($repeat); + // stop processing if the header isn't a good responce + if(isset($http_response_header) && strpos($http_response_header[0], '200') === false) { + return false; + } + // stop processing if the contents are too big + if(empty($contents) || strlen($contents) > MAX_FILE_SIZE) { + return false; + } + // The second parameter can force the selectors to all be lowercase. + $dom->load($contents, $lowercase, $stripRN); + + return $dom; +} + +// get html dom from string +function str_get_html($str, $lowercase = true, $forceTagsClosed = true, $target_charset = DEFAULT_TARGET_CHARSET, $stripRN = true, $defaultBRText = DEFAULT_BR_TEXT, $defaultSpanText = DEFAULT_SPAN_TEXT) +{ + $dom = new simple_html_dom(null, $lowercase, $forceTagsClosed, $target_charset, $stripRN, $defaultBRText, $defaultSpanText); + if(empty($str) || strlen($str) > MAX_FILE_SIZE) { + $dom->clear(); + + return false; + } + $dom->load($str, $lowercase, $stripRN); + + return $dom; +} + +// dump html dom tree +function dump_html_tree($node, $show_attr = true, $deep = 0) +{ + $node->dump($node); +} + +/** + * simple html dom node + * PaperG - added ability for "find" routine to lowercase the value of the selector. + * PaperG - added $tag_start to track the start position of the tag in the total byte index + * + * @package PlaceLocalInclude + */ +class simple_html_dom_node +{ + public $nodetype = HDOM_TYPE_TEXT; + + public $tag = 'text'; + + public $attr = []; + + public $children = []; + + public $nodes = []; + + public $parent = null; + + // The "info" array - see HDOM_INFO_... for what each element contains. + public $_ = []; + + public $tag_start = 0; + + private $dom = null; + + function __construct($dom) + { + $this->dom = $dom; + $dom->nodes[] = $this; + } + + function __destruct() + { + $this->clear(); + } + + function __toString() + { + return $this->outertext(); + } + + // clean up memory due to php5 circular references memory leak... + function clear() + { + $this->dom = null; + $this->nodes = null; + $this->parent = null; + $this->children = null; + } + + // dump node's tree + function dump($show_attr = true, $deep = 0) + { + $lead = str_repeat(' ', $deep); + echo $lead . $this->tag; + if($show_attr && count($this->attr) > 0) { + echo '('; + foreach($this->attr as $k => $v) { + echo "[$k]=>\"" . $this->$k . '", '; + } + echo ')'; + } + echo "\n"; + if($this->nodes) { + foreach($this->nodes as $c) { + $c->dump($show_attr, $deep + 1); + } + } + } + + // Debugging function to dump a single dom node with a bunch of information about it. + function dump_node($echo = true) + { + $string = $this->tag; + if(count($this->attr) > 0) { + $string .= '('; + foreach($this->attr as $k => $v) { + $string .= "[$k]=>\"" . $this->$k . '", '; + } + $string .= ')'; + } + if(count($this->_) > 0) { + $string .= ' $_ ('; + foreach($this->_ as $k => $v) { + if(is_array($v)) { + $string .= "[$k]=>("; + foreach($v as $k2 => $v2) { + $string .= "[$k2]=>\"" . $v2 . '", '; + } + $string .= ")"; + } else { + $string .= "[$k]=>\"" . $v . '", '; + } + } + $string .= ")"; + } + if(isset($this->text)) { + $string .= " text: (" . $this->text . ")"; + } + $string .= " HDOM_INNER_INFO: '"; + if(isset($node->_[HDOM_INFO_INNER])) { + $string .= $node->_[HDOM_INFO_INNER] . "'"; + } else { + $string .= ' NULL '; + } + $string .= " children: " . count($this->children); + $string .= " nodes: " . count($this->nodes); + $string .= " tag_start: " . $this->tag_start; + $string .= "\n"; + if($echo) { + echo $string; + + return; + } else { + return $string; + } + } + + // returns the parent of node + // If a node is passed in, it will reset the parent of the current node to that one. + function parent($parent = null) + { + // I am SURE that this doesn't work properly. + // It fails to unset the current node from it's current parents nodes or children list first. + if($parent !== null) { + $this->parent = $parent; + $this->parent->nodes[] = $this; + $this->parent->children[] = $this; + } + + return $this->parent; + } + + // verify that node has children + function has_child() + { + return !empty($this->children); + } + + // returns children of node + function children($idx = -1) + { + if($idx === -1) { + return $this->children; + } + if(isset($this->children[$idx])) { + return $this->children[$idx]; + } + + return null; + } + + // returns the first child of node + function first_child() + { + if(count($this->children) > 0) { + return $this->children[0]; + } + + return null; + } + + // returns the last child of node + function last_child() + { + if(($count = count($this->children)) > 0) { + return $this->children[$count - 1]; + } + + return null; + } + + // returns the next sibling of node + function next_sibling() + { + if($this->parent === null) { + return null; + } + $idx = 0; + $count = count($this->parent->children); + while($idx < $count && $this !== $this->parent->children[$idx]) { + ++$idx; + } + if(++$idx >= $count) { + return null; + } + + return $this->parent->children[$idx]; + } + + // returns the previous sibling of node + function prev_sibling() + { + if($this->parent === null) { + return null; + } + $idx = 0; + $count = count($this->parent->children); + while($idx < $count && $this !== $this->parent->children[$idx]) { + ++$idx; + } + if(--$idx < 0) { + return null; + } + + return $this->parent->children[$idx]; + } + + // function to locate a specific ancestor tag in the path to the root. + function find_ancestor_tag($tag) + { + global $debugObject; + if(is_object($debugObject)) { + $debugObject->debugLogEntry(1); + } + // Start by including ourselves in the comparison. + $returnDom = $this; + while(!is_null($returnDom)) { + if(is_object($debugObject)) { + $debugObject->debugLog(2, "Current tag is: " . $returnDom->tag); + } + if($returnDom->tag == $tag) { + break; + } + $returnDom = $returnDom->parent; + } + + return $returnDom; + } + + // get dom node's inner html + function innertext() + { + if(isset($this->_[HDOM_INFO_INNER])) { + return $this->_[HDOM_INFO_INNER]; + } + if(isset($this->_[HDOM_INFO_TEXT])) { + return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); + } + $ret = ''; + foreach($this->nodes as $n) { + $ret .= $n->outertext(); + } + + return $ret; + } + + // get dom node's outer text (with tag) + function outertext() + { + global $debugObject; + if(is_object($debugObject)) { + $text = ''; + if($this->tag == 'text') { + if(!empty($this->text)) { + $text = " with text: " . $this->text; + } + } + $debugObject->debugLog(1, 'Innertext of tag: ' . $this->tag . $text); + } + if($this->tag === 'root') { + return $this->innertext(); + } + // trigger callback + if($this->dom && $this->dom->callback !== null) { + call_user_func_array($this->dom->callback, [$this]); + } + if(isset($this->_[HDOM_INFO_OUTER])) { + return $this->_[HDOM_INFO_OUTER]; + } + if(isset($this->_[HDOM_INFO_TEXT])) { + return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); + } + // render begin tag + if($this->dom && $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]) { + $ret = $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]->makeup(); + } else { + $ret = ""; + } + // render inner text + if(isset($this->_[HDOM_INFO_INNER])) { + // If it's a br tag... don't return the HDOM_INNER_INFO that we may or may not have added. + if($this->tag != "br") { + $ret .= $this->_[HDOM_INFO_INNER]; + } + } else { + if($this->nodes) { + foreach($this->nodes as $n) { + $ret .= $this->convert_text($n->outertext()); + } + } + } + // render end tag + if(isset($this->_[HDOM_INFO_END]) && $this->_[HDOM_INFO_END] != 0) { + $ret .= 'tag . '>'; + } + + return $ret; + } + + // get dom node's plain text + function text() + { + if(isset($this->_[HDOM_INFO_INNER])) { + return $this->_[HDOM_INFO_INNER]; + } + switch($this->nodetype) { + case HDOM_TYPE_TEXT: + return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); + case HDOM_TYPE_COMMENT: + return ''; + case HDOM_TYPE_UNKNOWN: + return ''; + } + if(strcasecmp($this->tag, 'script') === 0) { + return ''; + } + if(strcasecmp($this->tag, 'style') === 0) { + return ''; + } + $ret = ''; + // In rare cases, (always node type 1 or HDOM_TYPE_ELEMENT - observed for some span tags, and some p tags) $this->nodes is set to NULL. + // NOTE: This indicates that there is a problem where it's set to NULL without a clear happening. + // WHY is this happening? + if(!is_null($this->nodes)) { + foreach($this->nodes as $n) { + $ret .= $this->convert_text($n->text()); + } + // If this node is a span... add a space at the end of it so multiple spans don't run into each other. This is plaintext after all. + if($this->tag == "span") { + $ret .= $this->dom->default_span_text; + } + } + + return $ret; + } + + function xmltext() + { + $ret = $this->innertext(); + $ret = str_ireplace('', '', $ret); + + return $ret; + } + + // build node's text with tag + function makeup() + { + // text, comment, unknown + if(isset($this->_[HDOM_INFO_TEXT])) { + return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); + } + $ret = '<' . $this->tag; + $i = -1; + foreach($this->attr as $key => $val) { + ++$i; + // skip removed attribute + if($val === null || $val === false) { + continue; + } + $ret .= $this->_[HDOM_INFO_SPACE][$i][0]; + //no value attr: nowrap, checked selected... + if($val === true) { + $ret .= $key; + } else { + switch($this->_[HDOM_INFO_QUOTE][$i]) { + case HDOM_QUOTE_DOUBLE: + $quote = '"'; + break; + case HDOM_QUOTE_SINGLE: + $quote = '\''; + break; + default: + $quote = ''; + } + $ret .= $key . $this->_[HDOM_INFO_SPACE][$i][1] . '=' . $this->_[HDOM_INFO_SPACE][$i][2] . $quote . $val . $quote; + } + } + $ret = $this->dom->restore_noise($ret); + + return $ret . $this->_[HDOM_INFO_ENDSPACE] . '>'; + } + + // find elements by css selector + //PaperG - added ability for find to lowercase the value of the selector. + function find($selector, $idx = null, $lowercase = false) + { + $selectors = $this->parse_selector($selector); + if(($count = count($selectors)) === 0) { + return []; + } + $found_keys = []; + // find each selector + for($c = 0; $c < $count; ++$c) { + // The change on the below line was documented on the sourceforge code tracker id 2788009 + // used to be: if (($levle=count($selectors[0]))===0) return array(); + if(($levle = count($selectors[$c])) === 0) { + return []; + } + if(!isset($this->_[HDOM_INFO_BEGIN])) { + return []; + } + $head = [$this->_[HDOM_INFO_BEGIN] => 1]; + // handle descendant selectors, no recursive! + for($l = 0; $l < $levle; ++$l) { + $ret = []; + foreach($head as $k => $v) { + $n = ($k === -1) ? $this->dom->root : $this->dom->nodes[$k]; + //PaperG - Pass this optional parameter on to the seek function. + $n->seek($selectors[$c][$l], $ret, $lowercase); + } + $head = $ret; + } + foreach($head as $k => $v) { + if(!isset($found_keys[$k])) { + $found_keys[$k] = 1; + } + } + } + // sort keys + ksort($found_keys); + $found = []; + foreach($found_keys as $k => $v) { + $found[] = $this->dom->nodes[$k]; + } + // return nth-element or array + if(is_null($idx)) { + return $found; + } elseif($idx < 0) { + $idx = count($found) + $idx; + } + + return (isset($found[$idx])) ? $found[$idx] : null; + } + + // seek for given conditions + // PaperG - added parameter to allow for case insensitive testing of the value of a selector. + protected function seek($selector, &$ret, $lowercase = false) + { + global $debugObject; + if(is_object($debugObject)) { + $debugObject->debugLogEntry(1); + } + [$tag, $key, $val, $exp, $no_key] = $selector; + // xpath index + if($tag && $key && is_numeric($key)) { + $count = 0; + foreach($this->children as $c) { + if($tag === '*' || $tag === $c->tag) { + if(++$count == $key) { + $ret[$c->_[HDOM_INFO_BEGIN]] = 1; + + return; + } + } + } + + return; + } + $end = (!empty($this->_[HDOM_INFO_END])) ? $this->_[HDOM_INFO_END] : 0; + if($end == 0) { + $parent = $this->parent; + while(!isset($parent->_[HDOM_INFO_END]) && $parent !== null) { + $end -= 1; + $parent = $parent->parent; + } + $end += $parent->_[HDOM_INFO_END]; + } + for($i = $this->_[HDOM_INFO_BEGIN] + 1; $i < $end; ++$i) { + $node = $this->dom->nodes[$i]; + $pass = true; + if($tag === '*' && !$key) { + if(in_array($node, $this->children, true)) { + $ret[$i] = 1; + } + continue; + } + // compare tag + if($tag && $tag != $node->tag && $tag !== '*') { + $pass = false; + } + // compare key + if($pass && $key) { + if($no_key) { + if(isset($node->attr[$key])) { + $pass = false; + } + } else { + if(($key != "plaintext") && !isset($node->attr[$key])) { + $pass = false; + } + } + } + // compare value + if($pass && $key && $val && $val !== '*') { + // If they have told us that this is a "plaintext" search then we want the plaintext of the node - right? + if($key == "plaintext") { + // $node->plaintext actually returns $node->text(); + $nodeKeyValue = $node->text(); + } else { + // this is a normal search, we want the value of that attribute of the tag. + $nodeKeyValue = $node->attr[$key]; + } + if(is_object($debugObject)) { + $debugObject->debugLog(2, "testing node: " . $node->tag . " for attribute: " . $key . $exp . $val . " where nodes value is: " . $nodeKeyValue); + } + //PaperG - If lowercase is set, do a case insensitive test of the value of the selector. + if($lowercase) { + $check = $this->match($exp, strtolower($val), strtolower($nodeKeyValue)); + } else { + $check = $this->match($exp, $val, $nodeKeyValue); + } + if(is_object($debugObject)) { + $debugObject->debugLog(2, "after match: " . ($check ? "true" : "false")); + } + // handle multiple class + if(!$check && strcasecmp($key, 'class') === 0) { + foreach(explode(' ', $node->attr[$key]) as $k) { + // Without this, there were cases where leading, trailing, or double spaces lead to our comparing blanks - bad form. + if(!empty($k)) { + if($lowercase) { + $check = $this->match($exp, strtolower($val), strtolower($k)); + } else { + $check = $this->match($exp, $val, $k); + } + if($check) { + break; + } + } + } + } + if(!$check) { + $pass = false; + } + } + if($pass) { + $ret[$i] = 1; + } + unset($node); + } + // It's passed by reference so this is actually what this function returns. + if(is_object($debugObject)) { + $debugObject->debugLog(1, "EXIT - ret: ", $ret); + } + } + + protected function match($exp, $pattern, $value) + { + global $debugObject; + if(is_object($debugObject)) { + $debugObject->debugLogEntry(1); + } + switch($exp) { + case '=': + return ($value === $pattern); + case '!=': + return ($value !== $pattern); + case '^=': + return preg_match("/^" . preg_quote($pattern, '/') . "/", $value); + case '$=': + return preg_match("/" . preg_quote($pattern, '/') . "$/", $value); + case '*=': + if($pattern[0] == '/') { + return preg_match($pattern, $value); + } + + return preg_match("/" . $pattern . "/i", $value); + } + + return false; + } + + protected function parse_selector($selector_string) + { + global $debugObject; + if(is_object($debugObject)) { + $debugObject->debugLogEntry(1); + } + // pattern of CSS selectors, modified from mootools + // Paperg: Add the colon to the attrbute, so that it properly finds like google does. + // Note: if you try to look at this attribute, yo MUST use getAttribute since $dom->x:y will fail the php syntax check. + // Notice the \[ starting the attbute? and the @? following? This implies that an attribute can begin with an @ sign that is not captured. + // This implies that an html attribute specifier may start with an @ sign that is NOT captured by the expression. + // farther study is required to determine of this should be documented or removed. + // $pattern = "/([\w-:\*]*)(?:\#([\w-]+)|\.([\w-]+))?(?:\[@?(!?[\w-]+)(?:([!*^$]?=)[\"']?(.*?)[\"']?)?\])?([\/, ]+)/is"; + $pattern = "/([\w\-:\*]*)(?:\#([\w\-]+)|\.([\w\-]+))?(?:\[@?(!?[\w\-:]+)(?:([!*^$]?=)[\"']?(.*?)[\"']?)?\])?([\/, ]+)/is"; + preg_match_all($pattern, trim($selector_string) . ' ', $matches, PREG_SET_ORDER); + if(is_object($debugObject)) { + $debugObject->debugLog(2, "Matches Array: ", $matches); + } + $selectors = []; + $result = []; + //print_r($matches); + foreach($matches as $m) { + $m[0] = trim($m[0]); + if($m[0] === '' || $m[0] === '/' || $m[0] === '//') { + continue; + } + // for browser generated xpath + if($m[1] === 'tbody') { + continue; + } + [$tag, $key, $val, $exp, $no_key] = [$m[1], null, null, '=', false]; + if(!empty($m[2])) { + $key = 'id'; + $val = $m[2]; + } + if(!empty($m[3])) { + $key = 'class'; + $val = $m[3]; + } + if(!empty($m[4])) { + $key = $m[4]; + } + if(!empty($m[5])) { + $exp = $m[5]; + } + if(!empty($m[6])) { + $val = $m[6]; + } + // convert to lowercase + if($this->dom->lowercase) { + $tag = strtolower($tag); + $key = strtolower($key); + } + //elements that do NOT have the specified attribute + if(isset($key[0]) && $key[0] === '!') { + $key = substr($key, 1); + $no_key = true; + } + $result[] = [$tag, $key, $val, $exp, $no_key]; + if(trim($m[7]) === ',') { + $selectors[] = $result; + $result = []; + } + } + if(count($result) > 0) { + $selectors[] = $result; + } + + return $selectors; + } + + function __get($name) + { + if(isset($this->attr[$name])) { + return $this->convert_text($this->attr[$name]); + } + switch($name) { + case 'outertext': + return $this->outertext(); + case 'innertext': + return $this->innertext(); + case 'plaintext': + return $this->text(); + case 'xmltext': + return $this->xmltext(); + default: + return array_key_exists($name, $this->attr); + } + } + + function __set($name, $value) + { + switch($name) { + case 'outertext': + return $this->_[HDOM_INFO_OUTER] = $value; + case 'innertext': + if(isset($this->_[HDOM_INFO_TEXT])) { + return $this->_[HDOM_INFO_TEXT] = $value; + } + + return $this->_[HDOM_INFO_INNER] = $value; + } + if(!isset($this->attr[$name])) { + $this->_[HDOM_INFO_SPACE][] = [' ', '', '']; + $this->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_DOUBLE; + } + $this->attr[$name] = $value; + } + + function __isset($name) + { + switch($name) { + case 'outertext': + return true; + case 'innertext': + return true; + case 'plaintext': + return true; + } + + //no value attr: nowrap, checked selected... + return (array_key_exists($name, $this->attr)) ? true : isset($this->attr[$name]); + } + + function __unset($name) + { + if(isset($this->attr[$name])) { + unset($this->attr[$name]); + } + } + + // PaperG - Function to convert the text from one character set to another if the two sets are not the same. + function convert_text($text) + { + global $debugObject; + if(is_object($debugObject)) { + $debugObject->debugLogEntry(1); + } + $converted_text = $text; + $sourceCharset = ""; + $targetCharset = ""; + if($this->dom) { + $sourceCharset = strtoupper($this->dom->_charset); + $targetCharset = strtoupper($this->dom->_target_charset); + } + if(is_object($debugObject)) { + $debugObject->debugLog(3, "source charset: " . $sourceCharset . " target charaset: " . $targetCharset); + } + if(!empty($sourceCharset) && !empty($targetCharset) && (strcasecmp($sourceCharset, $targetCharset) != 0)) { + // Check if the reported encoding could have been incorrect and the text is actually already UTF-8 + if((strcasecmp($targetCharset, 'UTF-8') == 0) && ($this->is_utf8($text))) { + $converted_text = $text; + } else { + $converted_text = iconv($sourceCharset, $targetCharset, $text); + } + } + // Lets make sure that we don't have that silly BOM issue with any of the utf-8 text we output. + if($targetCharset == 'UTF-8') { + if(substr($converted_text, 0, 3) == "\xef\xbb\xbf") { + $converted_text = substr($converted_text, 3); + } + if(substr($converted_text, -3) == "\xef\xbb\xbf") { + $converted_text = substr($converted_text, 0, -3); + } + } + + return $converted_text; + } + + /** + * Returns true if $string is valid UTF-8 and false otherwise. + * + * @param mixed $str String to be tested + * + * @return boolean + */ + static function is_utf8($str) + { + $c = 0; + $b = 0; + $bits = 0; + $len = strlen($str); + for($i = 0; $i < $len; $i++) { + $c = ord($str[$i]); + if($c > 128) { + if(($c >= 254)) { + return false; + } elseif($c >= 252) { + $bits = 6; + } elseif($c >= 248) { + $bits = 5; + } elseif($c >= 240) { + $bits = 4; + } elseif($c >= 224) { + $bits = 3; + } elseif($c >= 192) { + $bits = 2; + } else { + return false; + } + if(($i + $bits) > $len) { + return false; + } + while($bits > 1) { + $i++; + $b = ord($str[$i]); + if($b < 128 || $b > 191) { + return false; + } + $bits--; + } + } + } + + return true; + } + /* + function is_utf8($string) + { + //this is buggy + return (utf8_encode(utf8_decode($string)) == $string); + } + */ + /** + * Function to try a few tricks to determine the displayed size of an img on the page. + * NOTE: This will ONLY work on an IMG tag. Returns FALSE on all other tag types. + * + * @return array an array containing the 'height' and 'width' of the image on the page or -1 if we can't figure it out. + * @version April 19 2012 + * @author John Schlick + */ + function get_display_size() + { + global $debugObject; + $width = -1; + $height = -1; + if($this->tag !== 'img') { + return false; + } + // See if there is aheight or width attribute in the tag itself. + if(isset($this->attr['width'])) { + $width = $this->attr['width']; + } + if(isset($this->attr['height'])) { + $height = $this->attr['height']; + } + // Now look for an inline style. + if(isset($this->attr['style'])) { + // Thanks to user gnarf from stackoverflow for this regular expression. + $attributes = []; + preg_match_all("/([\w\-]+)\s*:\s*([^;]+)\s*;?/", $this->attr['style'], $matches, PREG_SET_ORDER); + foreach($matches as $match) { + $attributes[$match[1]] = $match[2]; + } + // If there is a width in the style attributes: + if(isset($attributes['width']) && $width == -1) { + // check that the last two characters are px (pixels) + if(strtolower(substr($attributes['width'], -2)) == 'px') { + $proposed_width = substr($attributes['width'], 0, -2); + // Now make sure that it's an integer and not something stupid. + if(filter_var($proposed_width, FILTER_VALIDATE_INT)) { + $width = $proposed_width; + } + } + } + // If there is a width in the style attributes: + if(isset($attributes['height']) && $height == -1) { + // check that the last two characters are px (pixels) + if(strtolower(substr($attributes['height'], -2)) == 'px') { + $proposed_height = substr($attributes['height'], 0, -2); + // Now make sure that it's an integer and not something stupid. + if(filter_var($proposed_height, FILTER_VALIDATE_INT)) { + $height = $proposed_height; + } + } + } + } + // Future enhancement: + // Look in the tag to see if there is a class or id specified that has a height or width attribute to it. + // Far future enhancement + // Look at all the parent tags of this image to see if they specify a class or id that has an img selector that specifies a height or width + // Note that in this case, the class or id will have the img subselector for it to apply to the image. + // ridiculously far future development + // If the class or id is specified in a SEPARATE css file thats not on the page, go get it and do what we were just doing for the ones on the page. + $result = [ + 'height' => $height, + 'width' => $width, + ]; + + return $result; + } + + // camel naming conventions + function getAllAttributes() + { + return $this->attr; + } + + function getAttribute($name) + { + return $this->__get($name); + } + + function setAttribute($name, $value) + { + $this->__set($name, $value); + } + + function hasAttribute($name) + { + return $this->__isset($name); + } + + function removeAttribute($name) + { + $this->__set($name, null); + } + + function getElementById($id) + { + return $this->find("#$id", 0); + } + + function getElementsById($id, $idx = null) + { + return $this->find("#$id", $idx); + } + + function getElementByTagName($name) + { + return $this->find($name, 0); + } + + function getElementsByTagName($name, $idx = null) + { + return $this->find($name, $idx); + } + + function parentNode() + { + return $this->parent(); + } + + function childNodes($idx = -1) + { + return $this->children($idx); + } + + function firstChild() + { + return $this->first_child(); + } + + function lastChild() + { + return $this->last_child(); + } + + function nextSibling() + { + return $this->next_sibling(); + } + + function previousSibling() + { + return $this->prev_sibling(); + } + + function hasChildNodes() + { + return $this->has_child(); + } + + function nodeName() + { + return $this->tag; + } + + function appendChild($node) + { + $node->parent($this); + + return $node; + } +} + +/** + * simple html dom parser + * Paperg - in the find routine: allow us to specify that we want case insensitive testing of the value of the selector. + * Paperg - change $size from protected to public so we can easily access it + * Paperg - added ForceTagsClosed in the constructor which tells us whether we trust the html or not. Default is to NOT trust it. + * + * @package PlaceLocalInclude + */ +class simple_html_dom +{ + public $root = null; + + public $nodes = []; + + public $callback = null; + + public $lowercase = false; + + // Used to keep track of how large the text was when we started. + public $original_size; + + public $size; + + protected $pos; + + protected $doc; + + protected $char; + + protected $cursor; + + protected $parent; + + protected $noise = []; + + protected $token_blank = " \t\r\n"; + + protected $token_equal = ' =/>'; + + protected $token_slash = " />\r\n\t"; + + protected $token_attr = ' >'; + + // Note that this is referenced by a child node, and so it needs to be public for that node to see this information. + public $_charset = ''; + + public $_target_charset = ''; + + protected $default_br_text = ""; + + public $default_span_text = ""; + + // use isset instead of in_array, performance boost about 30%... + protected $self_closing_tags = [ + 'img' => 1, + 'br' => 1, + 'input' => 1, + 'meta' => 1, + 'link' => 1, + 'hr' => 1, + 'base' => 1, + 'embed' => 1, + 'spacer' => 1, + ]; + + protected $block_tags = [ + 'root' => 1, + 'body' => 1, + 'form' => 1, + 'div' => 1, + 'span' => 1, + 'table' => 1, + ]; + + // Known sourceforge issue #2977341 + // B tags that are not closed cause us to return everything to the end of the document. + protected $optional_closing_tags = [ + 'tr' => ['tr' => 1, 'td' => 1, 'th' => 1], + 'th' => ['th' => 1], + 'td' => ['td' => 1], + 'li' => ['li' => 1], + 'dt' => ['dt' => 1, 'dd' => 1], + 'dd' => ['dd' => 1, 'dt' => 1], + 'dl' => ['dd' => 1, 'dt' => 1], + 'p' => ['p' => 1], + 'nobr' => ['nobr' => 1], + 'b' => ['b' => 1], + 'option' => ['option' => 1], + ]; + + function __construct($str = null, $lowercase = true, $forceTagsClosed = true, $target_charset = DEFAULT_TARGET_CHARSET, $stripRN = true, $defaultBRText = DEFAULT_BR_TEXT, $defaultSpanText = DEFAULT_SPAN_TEXT) + { + if($str) { + if(preg_match("/^http:\/\//i", $str) || is_file($str)) { + $this->load_file($str); + } else { + $this->load($str, $lowercase, $stripRN, $defaultBRText, $defaultSpanText); + } + } + // Forcing tags to be closed implies that we don't trust the html, but it can lead to parsing errors if we SHOULD trust the html. + if(!$forceTagsClosed) { + $this->optional_closing_array = []; + } + $this->_target_charset = $target_charset; + } + + function __destruct() + { + $this->clear(); + } + + // load html from string + function load($str, $lowercase = true, $stripRN = true, $defaultBRText = DEFAULT_BR_TEXT, $defaultSpanText = DEFAULT_SPAN_TEXT) + { + global $debugObject; + // prepare + $this->prepare($str, $lowercase, $stripRN, $defaultBRText, $defaultSpanText); + // strip out comments + $this->remove_noise("''is"); + // strip out cdata + $this->remove_noise("''is", true); + // Per sourceforge http://sourceforge.net/tracker/?func=detail&aid=2949097&group_id=218559&atid=1044037 + // Script tags removal now preceeds style tag removal. + // strip out